# 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 ``. 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 ``/`` 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 -- 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…“. | ### Grenzen der Tastendruck-Erkennung Die Erkennung im Bearbeiten-Dialog nutzt ausschließlich Tk-Events des fokussierten Fensters. Daraus folgt zweierlei, und beides ist bewusst so: 1. **Nur was das Fenster erreicht, wird erkannt.** Win+L, Strg+Alt+Entf und andere vom Betriebssystem abgefangene Kombinationen kommen nie an. Ein globaler `SetWindowsHookEx`-Hook würde sie sehen, ist aber ausgeschlossen (AV-Fehlalarm-Risiko, siehe `AGENTS.md`). 2. **Die Zuordnung ist eine US-Layout-Näherung.** HID-Keycodes bezeichnen physische Tastenpositionen des US-Layouts; Tk liefert nur `keysym` und Windows-Virtual-Key-Code, beide vom *aktiven* Layout abgeleitet. Die physische Position (Scan-Code) wäre nötig, um das exakt aufzulösen, und ist ohne WinAPI-Aufruf nicht verfügbar. Praktische Folge auf deutschem Layout: Y und Z landen vertauscht auf dem Board. Das Ergebnis wird immer ins Dropdown und in die Modifier-Checkboxen geschrieben und ist dort korrigierbar — die Erkennung ersetzt die manuelle Auswahl nicht, sie beschleunigt sie nur. Details der Zuordnungstabellen: `versapad_data.tk_event_to_hid()` und die `_TK_*`/`_WIN_VK_TO_HID`-Dicts darüber. ### 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).