chore: initial project structure with spec and plan
This commit is contained in:
1628
docs/superpowers/plans/2026-04-09-monitor-switcher.md
Normal file
1628
docs/superpowers/plans/2026-04-09-monitor-switcher.md
Normal file
File diff suppressed because it is too large
Load Diff
274
docs/superpowers/specs/2026-04-09-monitor-switcher-design.md
Normal file
274
docs/superpowers/specs/2026-04-09-monitor-switcher-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user