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>
16 KiB
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.
- die klassischen
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ürSDeviceConfig(740B, alle 3 Profile) undSMacroTable(512B, 32 Slots), plus CRC16. 1:1 aus den Firmware-Structs übernommen, siehedata-model.md.versapad_serial.py—VersaPadLink: öffnet bei Bedarf den COM-Port (per VID/PID-Erkennung), spricht das 8-Byte-Paket-Protokoll, sieheprotocol.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 ausversapad_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(), danndeiconify()); - bindet
<KeyPress>/<KeyRelease>auf dem Toplevel und verteilt sie: entweder an eine laufende Tastendruck-Aufnahme, sonst als Enter = OK / Escape = Abbrechen; _KeyCaptureschaltet ein Label in den Aufnahmemodus und schickt jeden Tastendruck durchversapad_data.tk_event_to_hid()— reine Tk-Fenster-Events, kein globaler Tastaturhook (siehe „Grenzen der Tastendruck-Erkennung" unten).
- positioniert sich beim Öffnen mittig über dem aufrufenden Fenster
(Aufbau
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 persave_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:
- Benannte Tasten über den Tk-keysym (Enter, Escape, Pfeile, F-Tasten,
Numpad, Entf …). Layoutunabhängig eindeutig — und hier zwingend, weil
MapVirtualKeyWfür die Pfeiltasten denselben Scan-Code liefert wie für ihre Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B). - Zeichentasten über die physische Position —
versapad_keylayout.hid_for_vk(): Virtual-Key → Scan-Code (MapVirtualKeyW) → HID über die layoutunabhängige TabelleSCANCODE_TO_HID. Der Virtual-Key ist unabhängig davon, ob Shift oder AltGr mitgehalten wird. - Näherung ohne WinAPI (keysym-Zeichentabelle, dann VK-Tabelle) — nur
relevant, wenn
versapad_keylayoutnicht 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:
- Die offizielle VersaGUI (C#/.NET), läuft dauerhaft als Tray-App.
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).versapad_mcp_server.py, während eines Board-Tool-Aufrufs — schließt die Verbindung danach explizit wieder (finally: _link.close()inget_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.