chore: initial project structure with spec and plan

This commit is contained in:
Miłosz Matysiak
2026-04-09 09:58:31 +02:00
commit f1ebdbfe1e
2 changed files with 1902 additions and 0 deletions

View File

@@ -0,0 +1,274 @@
# 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.