AGENTS.md: die WinAPI-Regel praeziser gefasst -- verboten bleiben Fenstermanipulation und globale Hooks, passive Layout-Abfragen sind es nicht (und die offizielle VersaGUI macht dasselbe). Dazu die neue Aufloesungsreihenfolge samt Warnung, nicht auf "Zeichen auswerten" zurueckzubauen, die Kollisionsregel bei Tastennamen, der Mehrmonitor-Fix und die Folgen der Titelleiste (welche Eigenbauten dadurch entfallen sind und warum randlos nicht ohne Ruecksprache zurueckkommt). README.md und docs/architecture.md entsprechend: Position statt Zeichen, layoutrichtige Beschriftungen inkl. der Anzeigeaenderung an bestehenden Belegungen, Taskleisten-Eintrag statt randlosem Fenster. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
16 KiB
Markdown
267 lines
16 KiB
Markdown
# Architektur
|
||
|
||
Überblick über die Schichten, Prozesse und Datenflüsse von VersaPad Viewer.
|
||
Für Domänenregeln, bekannte Bugs und Implementierungsdisziplin siehe
|
||
[`AGENTS.md`](../AGENTS.md); für Installation/Nutzung siehe
|
||
[`README.md`](../README.md). Für die genauen Datenformate siehe
|
||
[`data-model.md`](data-model.md), für das Serial-Wire-Protokoll
|
||
[`protocol.md`](protocol.md).
|
||
|
||
## Ziel und Kontext
|
||
|
||
VersaPad Viewer ist ein eigenständiges Python-Tool für das VersaPad-Makropad
|
||
(4×5-Button-Grid + 4 Encoder, SAMD21-Firmware). Es ergänzt die offizielle
|
||
VersaGUI (C#/.NET) um eine schlankere, plattformunabhängigere Alternative
|
||
zum Anzeigen, Live-Synchronisieren und Neuprogrammieren der Belegung, plus
|
||
einen MCP-Server, der dieselbe Programmierung KI-gesteuert per Tool-Aufruf
|
||
erlaubt. Beide GUIs (die offizielle VersaGUI und dieses Tool) konkurrieren
|
||
um denselben exklusiven USB-CDC-Port — siehe „Nebenläufigkeit" unten.
|
||
|
||
## Schichtenmodell
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Frontends │
|
||
│ ┌────────────┐ ┌──────────────────┐ ┌────────────────────┐ │
|
||
│ │ server.py │ │ desktop_viewer.py │ │ versapad_mcp_ │ │
|
||
│ │ (Browser, │ │ (Tkinter, Tray, │ │ server.py │ │
|
||
│ │ read-only)│ │ Live-Sync, Edit) │ │ (KI-Tool-Aufrufe) │ │
|
||
│ └─────┬──────┘ └────────┬──────────┘ └─────────┬──────────┘ │
|
||
│ │ │ │ │
|
||
│ └───────────┬───────┴──────────────┬───────────┘ │
|
||
│ ▼ ▼ │
|
||
│ versapad_combined.py versapad_data.py │
|
||
│ (Ein-Datei-Format, (Decoding fürs Anzeigen, │
|
||
│ Board-Sync, Auto- Legacy-Einzel-JSONs) │
|
||
│ Create) │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ versapad_protocol.py (pack/unpack, CRC16) │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ versapad_serial.py (VersaPadLink, 8-Byte-Pakete) │
|
||
│ │ │
|
||
└─────────────────────┼──────────────────────────────────────────────┘
|
||
▼
|
||
VersaPad-Board (USB-CDC, VID:PID 239A:0042)
|
||
```
|
||
|
||
`action_dialog.py` ist ein UI-Hilfsmodul von `desktop_viewer.py`
|
||
(Bearbeiten-Dialoge für den Programmiermodus) und taucht oben nicht separat
|
||
auf.
|
||
|
||
### Read-only-Schicht
|
||
|
||
- **`versapad_data.py`** — decodiert JSON-Rohdaten (Keycodes, Consumer-IDs,
|
||
Modifier-Bits) zu lesbarem Text, kennt die Grid-Geometrie
|
||
(`index = spalte*5 + reihe`). Liest wahlweise:
|
||
- die klassischen `versapad_config1/2/3.json` (`CONFIG_PATHS`) — Export
|
||
der offiziellen VersaGUI, kein von diesem Tool geschriebenes Format;
|
||
- oder (über `versapad_combined.py`) die kombinierte Datei.
|
||
- `app_dir()` liefert das Basisverzeichnis für die eigene Config, siehe
|
||
„Config-Speicherort" unten.
|
||
- **`server.py`** — generiert bei jedem HTTP-Request frisch HTML aus dem
|
||
aktuellen Zustand (`vcomb.load_or_fetch()`), Auto-Reload alle 4s per
|
||
`<meta http-equiv="refresh">`. Rein lesend, kein eigener Zustand zwischen
|
||
Requests, daher nie „veraltet" im Sinne von In-Memory-Staleness.
|
||
|
||
### Binär-/Serial-Schicht
|
||
|
||
- **`versapad_protocol.py`** — pack/unpack für `SDeviceConfig` (740B, alle
|
||
3 Profile) und `SMacroTable` (512B, 32 Slots), plus CRC16. 1:1 aus den
|
||
Firmware-Structs übernommen, siehe [`data-model.md`](data-model.md).
|
||
- **`versapad_serial.py`** — `VersaPadLink`: öffnet bei Bedarf den COM-Port
|
||
(per VID/PID-Erkennung), spricht das 8-Byte-Paket-Protokoll, siehe
|
||
[`protocol.md`](protocol.md). **Schließt die Verbindung nicht von selbst**
|
||
nach einem Befehl — Aufrufer müssen das selbst tun, wenn sie den Port
|
||
nicht dauerhaft blockieren wollen (siehe „Nebenläufigkeit" unten).
|
||
- **`versapad_combined.py`** — das Ein-Datei-Format: alle 3 Profile +
|
||
Makro-Tabelle + lokale Profilnamen in einer JSON
|
||
(`versapad_config_all.json`). Bindeglied zwischen den JSON-Strukturen und
|
||
den Binärblobs aus `versapad_protocol.py`. Zentrale Funktionen:
|
||
- `load_or_fetch()` — bevorzugt die lokale Datei, baut sie bei Bedarf
|
||
automatisch neu auf (erst Board-Versuch, sonst leere Default-Config).
|
||
- `save_file()`/`load_file()` — reines Lesen/Schreiben der JSON.
|
||
- `fetch_from_board()`/`to_binary()` — Konvertierung zu/von den
|
||
Binärblobs für Board-Lese-/Schreibvorgänge.
|
||
- `read_profile_names()` — liest nur die Profilnamen, ohne Board-Zugriff
|
||
(für Tab-Beschriftungen im Nur-Lese-Modus).
|
||
|
||
### UI
|
||
|
||
- **`desktop_viewer.py`** — Tkinter-Fenster mit drei unabhängig
|
||
umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit
|
||
MCP-Doku.
|
||
- **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`,
|
||
`MacroStepsDialog`) für den Programmiermodus, auf gemeinsamer Basis
|
||
`_ModalDialog`:
|
||
- positioniert sich beim Öffnen mittig über dem aufrufenden Fenster
|
||
(Aufbau `withdraw()`n, `_center_on_parent()`, dann `deiconify()`);
|
||
- bindet `<KeyPress>`/`<KeyRelease>` auf dem Toplevel und verteilt sie:
|
||
entweder an eine laufende Tastendruck-Aufnahme, sonst als
|
||
Enter = OK / Escape = Abbrechen;
|
||
- `_KeyCapture` schaltet ein Label in den Aufnahmemodus und schickt jeden
|
||
Tastendruck durch `versapad_data.tk_event_to_hid()` — reine
|
||
Tk-Fenster-Events, **kein globaler Tastaturhook** (siehe „Grenzen der
|
||
Tastendruck-Erkennung" unten).
|
||
|
||
### MCP-Server
|
||
|
||
- **`versapad_mcp_server.py`** — registriert als projektgebundener
|
||
MCP-Server "versapad" (`.mcp.json`) oder wahlweise user-scope
|
||
(`claude mcp add -s user versapad -- <python> versapad_mcp_server.py`).
|
||
Hält einen eigenen In-Memory-Zustand (`_state["combined"]`,
|
||
unabhängig von jeder laufenden GUI), der explizit per `save_local()` /
|
||
`load_local()` mit der Datei bzw. `load_from_board()` / `write_to_board()`
|
||
mit dem Board synchronisiert wird. Board-Serial-Tools schließen die
|
||
Verbindung nach jedem Aufruf wieder (siehe unten).
|
||
|
||
## Config-Speicherort
|
||
|
||
`versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene
|
||
Config-Datei (`versapad_combined.DEFAULT_PATH` =
|
||
`app_dir()/versapad_config_all.json`): `%APPDATA%\VersaPadViewer`
|
||
(Roaming-AppData), unabhängig davon, ob die gebaute `.exe` oder der
|
||
Quellcode gestartet wurde (kein `sys.frozen`-Zweig).
|
||
|
||
Bewusst **nicht** das Installationsverzeichnis (`%LOCALAPPDATA%\
|
||
VersaPadViewer`, wo die `.exe` liegt): `build_and_deploy.ps1` räumt das
|
||
Zielverzeichnis vor jedem Deploy komplett ab, läge die Config dort, würde
|
||
jeder Rebuild sie mitlöschen (genau das ist am 2026-08-15 passiert, siehe
|
||
`AGENTS.md`). Ebenso bewusst **nicht** vom Startweg abhängig — sonst sähe
|
||
die `.exe` eine andere Datei als ein aus dem Quellcode gestarteter
|
||
`versapad_mcp_server.py`, und Änderungen aus dem einen Weg wären im
|
||
anderen unsichtbar (ebenfalls am 2026-08-15 beobachtet).
|
||
|
||
Fehlt die Datei, legt `load_or_fetch()` sie automatisch an — zuerst per
|
||
Serial-Versuch vom Board (das ist die eigentliche Quelle der Wahrheit, die
|
||
Datei nur ein Lesecache dafür), sonst als leere Default-Config
|
||
(`default_combined()`). Das Tool ist damit auch ganz ohne vorhandene
|
||
Config oder angeschlossenes Board sofort benutzbar.
|
||
|
||
Die klassischen `versapad_config1/2/3.json` (`versapad_data.CONFIG_PATHS`)
|
||
bleiben bewusst getrennt hartkodiert auf `~\OneDrive\Desktop` — das ist
|
||
optionale Lese-Interop mit einem JSON-Export der offiziellen VersaGUI,
|
||
kein von diesem Tool selbst gepflegtes Format, und daher nicht Teil der
|
||
„portablen Installation".
|
||
|
||
## Modi in `desktop_viewer.py`
|
||
|
||
Drei Checkboxen, unabhängig voneinander:
|
||
|
||
| Modus | Zweck | Zustand |
|
||
|---|---|---|
|
||
| Nur-Lesen (Default) | Zeigt das aktuelle Profil an | Liest bei jedem Poll (alle 1,5s) frisch über `_current_profile_view()` → `vcomb.load_or_fetch()`. Kein eigener In-Memory-Snapshot, daher nie veraltet. |
|
||
| Live-Sync | Fragt per Serial das aktuell aktive Profil ab, schaltet die Ansicht mit | Hintergrund-Thread pollt `read_active_profile()`, hält dafür den COM-Port dauerhaft offen, solange die Checkbox an ist. Schließt sich mit VersaGUI/Programmiermodus/MCP-Board-Zugriff gegenseitig aus (exklusiver Port). |
|
||
| Programmiermodus | Zellen anklicken zum Bearbeiten | Lädt `self.combined` **einmalig pro Prozesslauf** beim ersten Aktivieren (bevorzugt `DEFAULT_PATH`, sonst `default_combined()`). Jede Bearbeitung speichert sofort automatisch (`_autosave_combined()`). **Achtung:** Da der Snapshot nur einmal geladen wird, sieht der Programmiermodus externe Änderungen (z.B. per MCP) erst nach einem Neustart der exe oder einem expliziten „Datei laden…“. |
|
||
|
||
### Tastendruck-Erkennung: Position statt Zeichen
|
||
|
||
Die Erkennung im Bearbeiten-Dialog nutzt ausschließlich Tk-Events des
|
||
fokussierten Fensters — kein globaler `SetWindowsHookEx`-Hook (siehe
|
||
`AGENTS.md`). Erste Folge: **nur was das Fenster erreicht, wird erkannt.**
|
||
Win+L, Strg+Alt+Entf und andere vom Betriebssystem abgefangene
|
||
Kombinationen kommen nie an.
|
||
|
||
Zweite und wichtigere Folge betrifft die *Zuordnung*. HID-Keycodes
|
||
bezeichnen **physische Tastenpositionen**: das Board sendet eine Position,
|
||
erst Windows macht daraus über das aktive Layout ein Zeichen. Wer die
|
||
Zuordnung über das *Zeichen* aufbaut, dreht diese Kette falsch herum — auf
|
||
deutschem Layout landete dadurch jedes Y auf der Z-Taste des Boards und
|
||
ÄÖÜ/#/+ waren gar nicht erfassbar. `versapad_data.tk_event_to_hid()` löst
|
||
deshalb in dieser Reihenfolge auf:
|
||
|
||
1. **Benannte Tasten über den Tk-keysym** (Enter, Escape, Pfeile, F-Tasten,
|
||
Numpad, Entf …). Layoutunabhängig eindeutig — und hier zwingend, weil
|
||
`MapVirtualKeyW` für die Pfeiltasten denselben Scan-Code liefert wie für
|
||
ihre Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B).
|
||
2. **Zeichentasten über die physische Position** —
|
||
`versapad_keylayout.hid_for_vk()`: Virtual-Key → Scan-Code
|
||
(`MapVirtualKeyW`) → HID über die layoutunabhängige Tabelle
|
||
`SCANCODE_TO_HID`. Der Virtual-Key ist unabhängig davon, ob Shift oder
|
||
AltGr mitgehalten wird.
|
||
3. **Näherung ohne WinAPI** (keysym-Zeichentabelle, dann VK-Tabelle) — nur
|
||
relevant, wenn `versapad_keylayout` nicht verfügbar ist (Nicht-Windows,
|
||
kein ctypes). Auf dieser Ebene bleibt es bei der US-Layout-Näherung
|
||
inklusive vertauschtem Y/Z.
|
||
|
||
### Tastenbeschriftungen
|
||
|
||
`versapad_data.hid_key_name()` fragt für Zeichentasten `GetKeyNameTextW`
|
||
und zeigt damit den Namen des aktiven Layouts (deutsch: HID 0x1C → „Z“,
|
||
0x34 → „ä“). Für alles andere bleiben die gepflegten deutschen Namen aus
|
||
`_SPECIAL_KEYS` („Enter“, „Bild↑“, „Num5“) — die lesen sich besser als das,
|
||
was Windows dafür liefert („EINGABE“, „4 (ZEHNERTASTATUR)“).
|
||
|
||
Diese Namen sind zugleich Schlüssel (Dropdown-Einträge,
|
||
`hid_key_code_for_name()` für den MCP-Server) und müssen eindeutig bleiben.
|
||
Echte Kollisionen kommen vor: auf deutschem Layout heißt HID 0x31 schlicht
|
||
„#“, und diesen Namen trug bisher HID 0x32. Der Layoutname gewinnt, der
|
||
verdrängte US-Name wird als „# (US-Layout)“ gekennzeichnet statt verworfen.
|
||
`hid_key_code_for_name()` akzeptiert beide Schreibweisen; bei Kollision
|
||
gewinnt das Layout, damit `set_button_key(key="Z")` die Taste trifft, die
|
||
auf dieser Tastatur ein Z tippt.
|
||
|
||
Bestehende Belegungen ändern dadurch ihre **Anzeige, nicht ihre Daten**:
|
||
eine früher über das Dropdown gesetzte „Z“ steht als 0x1D in der Config und
|
||
wird jetzt wahrheitsgemäß als „Y“ angezeigt, weil sie auf dieser Tastatur
|
||
ein Y tippt. Ein Layoutwechsel zur Laufzeit wird nicht nachgezogen.
|
||
|
||
### Kopieren/Einfügen zwischen Tasten
|
||
|
||
Rechtsklick auf eine Karte im Programmiermodus (bzw. `Strg+C`/`Strg+V` auf
|
||
der Karte unter dem Mauszeiger) kopiert Belegung und/oder LED-Farbe auf
|
||
andere Tasten. Die Ablage ist eine reine In-Memory-Struktur in
|
||
`desktop_viewer.VersaPadViewer._clip` (`{"action": …, "led": …}`), **nicht**
|
||
die System-Zwischenablage — dort lägen nur Textrepräsentationen, hier
|
||
werden ganze Action-Dicts übertragen. Eingefügt wird immer eine
|
||
`copy.deepcopy()`, damit zwei Tasten nicht dasselbe Dict teilen. Encoder
|
||
haben keine eigene LED; eine kopierte Farbe auf einen Encoder einzufügen ist
|
||
deshalb wirkungslos.
|
||
|
||
Tk-Aufrufe passieren nie direkt aus dem Serial- oder Tray-Hintergrundthread
|
||
— Ergebnisse landen in einer `queue.Queue`, der Main-Thread holt sie per
|
||
`after()`-Polling ab (Absturzrisiko bei Cross-Thread-Tk-Zugriff, siehe
|
||
`AGENTS.md`).
|
||
|
||
## Nebenläufigkeit / Prozessmodell
|
||
|
||
Der USB-CDC-Port ist **exklusiv** — nur eine Verbindung gleichzeitig. Drei
|
||
potenzielle Halter existieren parallel und wissen nichts voneinander:
|
||
|
||
1. Die offizielle VersaGUI (C#/.NET), läuft dauerhaft als Tray-App.
|
||
2. `desktop_viewer.py`, wenn Live-Sync an ist (hält den Port dauerhaft) oder
|
||
während eines Board-Lese-/Schreibvorgangs im Programmiermodus (hält ihn
|
||
nur kurz).
|
||
3. `versapad_mcp_server.py`, während eines Board-Tool-Aufrufs — schließt
|
||
die Verbindung danach explizit wieder (`finally: _link.close()` in
|
||
`get_board_status()`, `load_from_board()`, `write_to_board()`), damit ein
|
||
einzelner MCP-Aufruf nicht dauerhaft blockiert, was Live-Sync/VersaGUI
|
||
sonst mit „busy“ aussperren würde.
|
||
|
||
**Mehrere MCP-Server-Prozesse:** Je nach Host-Umgebung können mehrere
|
||
unabhängige `versapad_mcp_server.py`-Prozesse gleichzeitig laufen (z.B.
|
||
durch wiederholte Tool-Ladevorgänge/Reconnects), jeder mit eigenem,
|
||
nicht geteiltem In-Memory-Zustand. Ein `write_to_board()`-Aufruf kann daher
|
||
auf einem anderen Prozess landen als vorherige `set_*`-Aufrufe und einen
|
||
veralteten/leeren Zustand schreiben, obwohl die Antwort `{"ok": true}`
|
||
meldet. Empfohlenes Muster: vor `write_to_board()` immer `load_local()`
|
||
aufrufen (liest die Datei prozessunabhängig frisch von der Platte) und
|
||
nach dem Schreiben mit `load_from_board()` + `get_profile()` gegenlesen,
|
||
statt dem ACK allein zu vertrauen. Details siehe „Bug beobachtet
|
||
2026-08-14“ in `AGENTS.md`.
|
||
|
||
## Build/Deploy
|
||
|
||
`build_and_deploy.ps1` installiert `requirements.txt` selbst
|
||
(`pip install -r`), baut mit PyInstaller (`--onedir --windowed`, nur
|
||
`desktop_viewer.py` wird gebündelt) und kopiert das Ergebnis nach
|
||
`%LOCALAPPDATA%\VersaPadViewer\`. `--onedir` statt `--onefile`, um
|
||
AV-Fehlalarme zu verringern. Räumt das Zielverzeichnis vor dem Kopieren
|
||
komplett ab, rettet dabei aber zuvor gefundene `versapad_config*.json`
|
||
(Altinstallationen, bei denen die Config noch im Installationsordner
|
||
liegt) über den Deploy hinweg. Läuft komplett in try/catch mit
|
||
Exit-Code-Prüfung und pausiert am Ende (Erfolg wie Fehler) auf
|
||
Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem
|
||
Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details:
|
||
[`README.md`](../README.md#als-eigenständige-exe-windows).
|