Files
MonitorSwitcher/docs/superpowers/specs/2026-04-09-monitor-switcher-design.md
2026-04-09 09:58:31 +02:00

7.4 KiB
Raw Blame History

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.

{
  "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 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:

{
  "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.