VersaGUI-py/docs/architecture.md
Julian Appel 78e9640dec Document the layout fix and the return of the title bar
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>
2026-08-29 01:02:18 +02:00

16 KiB
Raw Blame History

Architektur

Überblick über die Schichten, Prozesse und Datenflüsse von VersaPad Viewer. Für Domänenregeln, bekannte Bugs und Implementierungsdisziplin siehe AGENTS.md; für Installation/Nutzung siehe README.md. Für die genauen Datenformate siehe data-model.md, für das Serial-Wire-Protokoll 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.
  • versapad_serial.pyVersaPadLink: öffnet bei Bedarf den COM-Port (per VID/PID-Erkennung), spricht das 8-Byte-Paket-Protokoll, siehe 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 Positionversapad_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.