7.4 KiB
MonitorSwitcher — Design Spec
Date: 2026-04-09
Status: Approved
Problem
User has two Lenovo L27qe (2560×1440) monitors connected to two computers simultaneously:
- PC1 (DP) — current machine (Windows 11), monitors connected via DisplayPort
- PC2 (HDMI) — second machine, monitors connected via HDMI
Switching monitor inputs manually via OSD is slow and cumbersome. The switch should happen automatically when the user switches their keyboard/mouse to the other computer, and should also be triggerable via hotkey or tray menu.
Hardware
| Device | Connection to PC1 | Connection to PC2 |
|---|---|---|
| Lenovo L27qe #1 | DisplayPort | HDMI |
| Lenovo L27qe #2 | DisplayPort | HDMI |
| HP 975 Dual-Mode keyboard | Bluetooth LE (BT1), MAC: F62EECE9E0DA |
USB 2.4GHz dongle |
| Logitech MX Anywhere 2S mouse | Bluetooth (channel 1), VID:046D PID:B01A | channel 3 (BT or Unifying) |
When the user presses the dongle button on HP 975 → keyboard disconnects from PC1 BT → connects to PC2 via dongle.
When the user presses BT1 on HP 975 → keyboard connects to PC1 via BT.
MX Anywhere 2S behaves analogically (channel 1 ↔ channel 3).
Solution Overview
A Python system tray application that:
- Watches for BT device connect/disconnect events via WMI
- Switches monitor input sources via DDC/CI (VCP code
0x60) - Supports configurable hotkeys for manual switching
- Has a PyQt6 configuration UI with profile management
Profiles
The core abstraction is a profile — a named configuration describing which input source each monitor should use.
{
"profiles": [
{
"id": "pc1_dp",
"name": "PC1 — DP",
"monitor_inputs": [
{"monitor_index": 0, "vcp_value": 15},
{"monitor_index": 1, "vcp_value": 15}
]
},
{
"id": "pc2_hdmi",
"name": "PC2 — HDMI",
"monitor_inputs": [
{"monitor_index": 0, "vcp_value": 17},
{"monitor_index": 1, "vcp_value": 17}
]
}
]
}
VCP value 0x0F (15) = DisplayPort-1, 0x11 (17) = HDMI-1.
Actual values are detected/confirmed at first run via DDC/CI query.
Device Triggers
Each BT device event is mapped to a profile:
{
"device_triggers": [
{
"name": "HP 975 keyboard",
"bt_device_id": "VID&0203F0_PID&6343",
"on_connect": "pc1_dp",
"on_disconnect": "pc2_hdmi",
"enabled": true
},
{
"name": "MX Anywhere 2S",
"bt_device_id": "VID&02046D_PID&B01A",
"on_connect": "pc1_dp",
"on_disconnect": "pc2_hdmi",
"enabled": true
}
]
}
Both triggers independently activate the same profiles — if either device connects to PC1, monitors switch to DP; if either disconnects, monitors switch to HDMI.
Architecture
MonitorSwitcher/
├── main.py # Entry point — bootstraps all components
├── core/
│ ├── switcher.py # DDC/CI via monitorcontrol — switches monitor VCP input
│ ├── device_watcher.py # WMI async event watcher for BT connect/disconnect
│ └── hotkey_manager.py # Global hotkey registration via keyboard lib
├── config/
│ ├── config_manager.py # Load/save config.json with validation
│ └── config.json # User configuration (profiles + triggers + hotkeys)
├── ui/
│ ├── tray.py # pystray tray icon + context menu
│ └── settings_window.py # PyQt6 settings window
└── requirements.txt
Component responsibilities
core/switcher.py
- Enumerates physical monitors via
monitorcontrol - Exposes
apply_profile(profile_id)— sends VCP0x60with configured value to each monitor - Detects available monitors and their supported VCP input values on demand
core/device_watcher.py
- Runs WMI
__InstanceCreationEvent/__InstanceDeletionEventwatcher in background thread - Filters events by BT device ID strings from config
- Calls
apply_profile()on match - Debounces rapid connect/disconnect events (500ms window)
core/hotkey_manager.py
- Registers global hotkeys via
keyboardlibrary - Each hotkey maps to a profile ID or "toggle" action
config/config_manager.py
- Reads/writes
config.json - Provides defaults for first run
- Notifies components on config change (simple callback pattern)
ui/tray.py
- pystray icon showing current active profile name
- Right-click menu: profile list (click to activate), separator, Settings, Exit
- Left-click: toggle between profiles (if exactly 2 profiles configured)
ui/settings_window.py
- PyQt6 window, 4 tabs:
| Tab | Content |
|---|---|
| Profiles | Create/edit/delete profiles; per-monitor input selection with dropdown |
| Devices | List of BT triggers; enable/disable; map connect/disconnect to profile |
| Hotkeys | Per-profile hotkey recorder (press key combo to assign) |
| General | Autostart with Windows (registry key), minimize to tray on close |
Data Flow
[BT device connects to PC1]
│
▼
device_watcher.py (WMI event)
│ matches trigger config
▼
switcher.py.apply_profile("pc1_dp")
│ sends VCP 0x60 = 15 to both monitors
▼
tray.py updates icon label → "PC1 — DP"
[User presses hotkey]
│
▼
hotkey_manager.py
│
▼
switcher.py.apply_profile(mapped_profile)
│
▼
tray.py updates icon
Tech Stack
| Library | Purpose | Version |
|---|---|---|
monitorcontrol |
DDC/CI monitor control | latest |
pystray |
System tray icon | latest |
keyboard |
Global hotkeys | latest |
wmi |
WMI event watching | latest |
pywin32 |
Windows registry (autostart) | latest |
PyQt6 |
Configuration UI | latest |
Pillow |
Tray icon image generation | latest |
Bundled to single .exe via PyInstaller.
Config File Schema
Full config.json:
{
"active_profile": "pc1_dp",
"profiles": [
{
"id": "pc1_dp",
"name": "PC1 — DP",
"hotkey": "ctrl+alt+1",
"monitor_inputs": [
{"monitor_index": 0, "vcp_value": 15},
{"monitor_index": 1, "vcp_value": 15}
]
},
{
"id": "pc2_hdmi",
"name": "PC2 — HDMI",
"hotkey": "ctrl+alt+2",
"monitor_inputs": [
{"monitor_index": 0, "vcp_value": 17},
{"monitor_index": 1, "vcp_value": 17}
]
}
],
"device_triggers": [
{
"name": "HP 975 keyboard",
"bt_device_id": "VID&0203F0_PID&6343",
"on_connect": "pc1_dp",
"on_disconnect": "pc2_hdmi",
"enabled": true
},
{
"name": "MX Anywhere 2S",
"bt_device_id": "VID&02046D_PID&B01A",
"on_connect": "pc1_dp",
"on_disconnect": "pc2_hdmi",
"enabled": true
}
],
"general": {
"autostart": true,
"minimize_to_tray": true,
"debounce_ms": 500
}
}
Error Handling
- Monitor not responding to DDC/CI → log warning, skip that monitor, continue with others
- WMI watcher crashes → restart watcher thread with 5s backoff, max 3 retries
- Config file corrupt → fall back to defaults, notify user via tray notification
- Monitor index out of range → skip silently (handles monitor disconnection)
Packaging
pyinstaller --onefile --windowed --name MonitorSwitcher \
--add-data "config/config.json;config" main.py
Produces dist/MonitorSwitcher.exe — no Python runtime required on target machine.