# 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: 1. Watches for BT device connect/disconnect events via WMI 2. Switches monitor input sources via DDC/CI (VCP code `0x60`) 3. Supports configurable hotkeys for manual switching 4. 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. ```json { "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: ```json { "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 VCP `0x60` with configured value to each monitor - Detects available monitors and their supported VCP input values on demand **`core/device_watcher.py`** - Runs WMI `__InstanceCreationEvent` / `__InstanceDeletionEvent` watcher 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 `keyboard` library - 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`: ```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.