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

275 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.