VersaGUI-py/AGENTS.md
Julian Appel 8a00b3ae36 Document key capture, dialog placement and copy/paste
AGENTS.md: die Regel "Auswahl ist ein Dropdown, kein Tastendruck-Capture"
ist ueberholt -- Capture gibt es jetzt, aber weiterhin ohne WinAPI-Hook, und
genau diese Grenze muss bleiben. Dazu die Layout-Naeherung (Y/Z vertauscht
auf deutschem Layout, minus-Kollision bewusst zugunsten der US-Position
aufgeloest), die neuen UI-Regeln (Dialogposition, Grab-Rueckgabe bei
verschachtelten Dialogen, feste Panel-Hoehe, In-Memory-Ablage fuers
Kopieren) und ein Verifikationsrezept fuer UI-Aenderungen inkl. der
DPI-Falle beim Screenshot-Vergleich. Der Deferred-Work-Eintrag zum Capture
faellt weg.

README.md: neue Bedienung in den Features, Tastenkuerzel-Uebersicht, und die
Einschraenkung praezisiert (nicht mehr "Dropdown statt Capture", sondern
was die Fenster-basierte Erkennung nicht sehen kann).

docs/architecture.md: _ModalDialog/_KeyCapture in der UI-Schicht, plus zwei
neue Abschnitte zu den Grenzen der Erkennung und zur Kopier-Ablage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:33:38 +02:00

28 KiB
Raw Blame History

AGENTS.md

Project Goal

Eigenständiges Begleit-Tool für das VersaPad-Makropad (Grovy311-Kontext, nicht Teil der fremden jappel-Repos VersaGUI/VersaMCU). Zeigt die Steuermatrix (4×5-Button-Grid + 4 Encoder) an, synchronisiert sie live mit dem Board und kann sie komplett neu programmieren — inzwischen ein schlankes Python-Pendant zur offiziellen VersaGUI (C#/.NET), plus ein MCP-Server, der dieselbe Programmierung per KI-Tool-Aufruf ermöglicht. Nutzerorientierte Einführung: README.md.

Aktuell unterstützte Architektur

Read-only-Schicht (JSON, beide Frontends):

  • versapad_data.py — Decoding für Anzeige: JSON laden, HID-Keycode/ Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie (index = spalte*5+reihe), hid_key_choices()/consumer_choices(). Seit 2026-08-28 zusätzlich tk_event_to_hid() (Tk-Tastendruck → HID-Keycode+Modifier, siehe Tastendruck-Erkennung unten) und macro_slot_choices()/macro_slot_from_choice() für die Slot-Auswahl. action_label()/annotate_profile() nehmen die Makrotabelle optional entgegen und zeigen dann statt „Makro (Slot 7)" die echte Tastenfolge. app_dir() liefert das Verzeichnis für die eigene Config (versapad_combined.DEFAULT_PATH) — bei der .exe der Installations- ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten. CONFIG_PATHS bleibt bewusst auf dem OneDrive-Desktop hartkodiert (Lese- Interop mit einem JSON-Export der offiziellen VersaGUI, kein von diesem Tool geschriebenes Format, siehe dort).
  • server.py — stdlib http.server, generiert HTML pro Request neu, Profil-Wechsel über ?profile=0|1|2, Auto-Reload alle 4s. Rein lesend, kein Programmiermodus (bewusst einfach gehalten).

Binär-/Serial-Schicht (Programmiermodus + MCP):

  • versapad_protocol.py — pack/unpack für SDeviceConfig (740B, alle 3 Profile) und SMacroTable (512B, 32 Slots) + CRC16 (Poly 0x1021, Init 0xFFFF), 1:1 aus den Firmware-Structs. Gegen echtes Board validiert: read → unpack → pack ist byte-identisch zum Original, inkl. CRC.
  • versapad_serial.pyVersaPadLink: liest/schreibt komplette Config + Makros per 8-Byte-Paket-Protokoll, Board-Identifikation per VID/PID 239A:0042. Schreiben ist sicher im Sinne von "kann NVM nicht zerlegen" — Firmware prüft Magic/CRC/Keycode-Bereich vor jedem Save, antwortet sonst nur mit NACK. read_active_profile() (für Live-Sync-Polling) nutzt seit 2026-08-07 CMD_READ_STATUS/EVT_STATUS (0x06/0x86, ein Antwortpaket) statt eines vollen CONFIG_READ-Dumps (124 Pakete) — Letzterer blockiert die Firmware in poll_vendor() lang genug, dass laufende LED-Pulse-Animationen sichtbar stottern (Root Cause + Fix in VersaMCU-Commit "Add lightweight READ_STATUS command..."). Braucht entsprechend neue Firmware auf dem Board — mit altem versapad-Firmwarestand liefert READ_STATUS schlicht Timeout, kein Absturz.
  • versapad_combined.py — Ein-Datei-Format (alle 3 Profile + Makros + nur lokal gespeicherte Profilnamen), Default-Pfad versapad_data.app_dir()\versapad_config_all.json (neben der Installation, nicht mehr hartkodiert auf einer bestimmten Maschine). Passt zum Wire-Protokoll: CONFIG_BEGIN/COMMIT überträgt ohnehin immer den kompletten 740B-Block. load_or_fetch() legt bei fehlender Datei automatisch eine neue an — erst Versuch per Serial vom Board, sonst als leere default_combined() (kein Board noetig fuer die Erstbenutzung). read_profile_names() liest nur die Namen (kein Board-Zugriff, fuer Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz).

UI:

  • desktop_viewer.py — Tkinter, flaches Design, drei unabhängige Modi (Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln), Tray-Icon statt Taskleisten-Minimierung, Info-Button mit MCP-Doku.
  • action_dialog.py — Bearbeiten-Dialoge (ActionEditDialog, MacroStepsDialog) für den Programmiermodus. Gemeinsame Basis _ModalDialog (Positionierung über dem Elternfenster, Enter/Escape, Verteilung der Tastenevents) und _KeyCapture (Tastendruck-Erkennung).

MCP-Server:

  • versapad_mcp_server.py — registriert als projektgebundener MCP-Server "versapad" über .mcp.json im Projektordner (Alternative: claude mcp add -s user versapad -- <python> versapad_mcp_server.py). Nutzt MCP-SDK v2 (Paket mcp, Klasse mcp.server.mcpserver.MCPServernicht FastMCP aus mcp.server.fastmcp, das existiert in dieser SDK-Version nicht mehr, wurde umbenannt). @mcp.tool()-dekorierte Funktionen bleiben direkt aufrufbar (kein .fn-Unterschied wie bei älteren FastMCP-Versionen). Tool-Liste: siehe README oder MCP_INFO_TEXT in desktop_viewer.py.
  • Board-Serial-Tools schliessen den Link nach jedem Aufruf (get_board_status, load_from_board, write_to_boardfinally: _link.close()). Grund: VersaPadLink schliesst nie von selbst, ein einzelner Aufruf hätte sonst den exklusiven COM-Port dauerhaft für den Rest des MCP-Serverprozesses blockiert und Live-Sync/VersaGUI/den nächsten Aufruf mit "busy" ausgesperrt (am 2026-08-14 live so aufgetreten, siehe unten).
  • Bug beobachtet 2026-08-14: In diesem Agenten-Environment (Claude-Code- VSCode-Extension) können mehrere unabhängige versapad_mcp_server.py- Prozesse gleichzeitig laufen (bis zu 8 beobachtet, vermutlich durch wiederholte Tool-Ladevorgänge/Reconnects innerhalb einer Session) — jeder mit eigenem, nicht geteiltem In-Memory-State (_state["combined"]). Konkret beobachtet: set_button_*/set_macro + save_local() liefen korrekt auf einem Prozess, ein späterer write_to_board()-Aufruf landete aber auf einem anderen (frischen, leeren) Prozess und schrieb versehentlich eine leere Default-Config aufs Board, trotz {"ok": true}-Antwort. Fix: vor write_to_board() immer erst load_local() (liest die Datei frisch von der Platte, unabhängig davon welcher Prozess antwortet), und nach jedem Schreibvorgang mit load_from_board() + get_profile() gegenlesen statt dem ACK allein zu vertrauen — genau dieses Verify-Pattern hat den Fehler hier live aufgedeckt.

Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code nachschlagen. Menschenlesbare Referenzdoku (Architektur, Datenmodell, Protokoll) liegt in docs/, siehe „Dokumentation und Verifikation" unten.

Kritische Domänenregeln

  • Geometrie: index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (Firmware-Reihenfolge). Nicht raten, nicht neu herleiten.
  • Encoder 0 sw ist auf allen 3 Profilen der Profilwechsel — nicht ohne Rücksprache mit dem User ändern.
  • Makros sind eine EINE globale 32-Slot-Tabelle, nicht pro Profil. Slot 0-19 = MX-Button-Index, Slot 20-31 = 20 + enc*3 + act_idx. Zwei Profile, die denselben Slot referenzieren, spielen dieselben Schritte ab.
  • Ein Makro-Schritt mit keycode=0 beendet die Sequenz (Firmware-Konvention) — keine Lücken vor dem letzten belegten Schritt lassen.
  • Makro-Schritte erlauben nur Strg/Shift/Alt als Modifier, kein Win (passend zum Original-ActionDialog.cs-Verhalten).
  • Profilnamen leben NUR lokal in versapad_config_all.json (profile_names) — die Firmware-Structs haben keinen Platz für einen String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie landen nie aufs Board, egal welcher Schreibpfad benutzt wird.
  • Notizen (freier Text je Button/Encoder-Aktion, seit 2026-08-15) sind aus demselben Grund rein lokal: leben im "note"-Feld JEDER Action ({"type","data","note"}), nicht in einer separaten Struktur. to_binary/ pack_config ignorieren das Feld beim Schreiben (liest nur type/data), from_binary liefert frisch vom Board immer note="" (Board kennt keine Notizen) — versapad_combined.merge_notes(neu, alt) kopiert bestehende Notizen nach jedem load_from_board()/fetch_from_board() zurück, sonst gingen sie bei jedem Board-Refresh verloren. Action-Typ wechseln (set_button_key etc.) darf die Notiz NICHT loeschen (baut die neue Action ueber _replace_action()/dlg.action["note"] mit der alten Notiz), nur set_button_note()/set_encoder_note()/das Notiz-Feld im Programmiermodus-Dialog aendern sie gezielt.
  • Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich gegenseitig aus (ein VersaPadLink kann nicht von zwei Konsumenten gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im Hintergrund weiter, auch wenn nur ihr Konfigurationsfenster geschlossen wird — für Parallelbetrieb mit diesem Tool muss sie über ihr eigenes Tray-Menü ("Beenden") wirklich beendet werden.
  • Programmiermodus-Änderungen werden bei jeder Bearbeitung automatisch in versapad_config_all.json gespeichert (nicht erst bei explizitem "Datei speichern"). Der Nur-Lese-Modus bevorzugt dieselbe Datei, falls vorhanden, und fällt sonst auf die klassischen versapad_config{1,2,3}.json zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine Bearbeitung "verschwunden".
  • Config-Pfad darf NIE im Installationsverzeichnis liegen (2026-08-15, Datenverlust-Bug): build_and_deploy.ps1 räumt sein Zielverzeichnis vor jedem Deploy komplett ab (Remove-Item $localDir -Recurse -Force). Nachdem app_dir() am selben Tag auf genau dieses Verzeichnis gezeigt hatte, hat jeder Rebuild die Nutzer-Config mitgelöscht; die App hat sie danach kommentarlos leer vom Board neu aufgebaut (load_or_fetch()fetch_from_board()), wodurch alle Notizen weg waren. Besonders tückisch: Board-Bindings und LEDs sahen danach völlig normal aus, nur die rein lokalen Felder (Notizen, Profilnamen) fehlten -- der Schaden ist also unsichtbar, wenn man nur aufs Grid schaut. Fix: app_dir() liefert jetzt %APPDATA%\VersaPadViewer (Roaming), getrennt vom Installationsordner in %LOCALAPPDATA%; zusätzlich rettet build_and_deploy.ps1 vorgefundene versapad_config*.json aus dem Zielverzeichnis über den Deploy hinweg (für Altinstallationen). Regel für künftige Änderungen: Programm- und Datenverzeichnis nie zusammenlegen, egal wie praktisch "alles an einem Ort" klingt.
  • Nur EIN Config-Pfad, unabhängig vom Startweg (2026-08-15): app_dir() hat bewusst KEINEN sys.frozen-Zweig mehr. Vorher sah die gebaute .exe eine andere Datei als der aus dem Quellcode gestartete MCP-Server, was zu zwei auseinanderlaufenden Configs führte (die .exe zeigte ein anderes Profil 0 als der MCP-Server meldete). Wer hier wieder nach Startweg unterscheidet, baut denselben Bug erneut ein.
  • Installierbarkeit verbessert 2026-08-14: Drei Probleme beim Weitergeben an andere Leute behoben. (1) build_and_deploy.ps1 starb bei fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei Doppelklick im Explorer, weil das Fenster sich sofort schließt. Fix: requirements.txt (alle Pakete an einer Stelle, README und Skript nutzen dieselbe Datei), Skript installiert sie selbst per pip install -r, läuft komplett in try/catch, prüft $LASTEXITCODE nach jedem nativen Aufruf, und pausiert am Ende (Erfolg wie Fehler) auf Tastendruck -- -NoPause für CI/Automation. (2) versapad_combined.DEFAULT_PATH war hartkodiert auf ~\OneDrive\Desktop einer bestimmten Maschine -- für andere Nutzer unbrauchbar. Fix: versapad_data.app_dir() (neue Funktion) liefert bei der gebauten .exe deren Installationsordner (sys.executable-Verzeichnis), sonst den Projektordner (__file__- Verzeichnis) -- DEFAULT_PATH hängt jetzt daran, landet also immer neben der laufenden Installation. Diese app_dir()-Variante ist seit 2026-08-15 überholt und war aktiv schädlich, siehe oben "Config-Pfad darf nie im Installationsverzeichnis liegen". versapad_data.CONFIG_PATHS (Lese-Interop mit der C#-VersaGUI) bleibt bewusst auf dem Desktop, siehe oben. (3) Fehlte die Config UND war kein Board erreichbar, blockierte das Tool mit einer Fehlermeldung statt zu starten. Fix: load_or_fetch() legt jetzt bei Board-Fehlschlag eine leere default_combined() an statt RuntimeError zu werfen -- Erstbenutzung ganz ohne vorhandene Config oder Board funktioniert jetzt. (4) Profilnamen waren zusätzlich hartkodiert in versapad_data.PROFILE_NAMES (Dict, jetzt entfernt, ersetzt durch NUM_PROFILES = 3) und liefen der JSON-profile_names parallel -- Programmiermodus zeigte umbenannte Profile, Lesemodus/Browser-Ansicht weiterhin die alten Namen. Fix: server.py und desktop_viewer.py lesen Namen jetzt immer aus der kombinierten JSON (combined["profile_names"] bzw. versapad_combined.read_profile_names()), eine einzige Quelle der Wahrheit für alle Frontends.
  • Herkunft der drei Punkte oben + Tab-Umbenennen-Fix: ursprünglich per Cherry-Pick aus Julian Appels dev/jappel-Branch auf main übernommen (der sich zeitgleich mit dem eigenen COM-Port-Fix entwickelt hatte, kein Fork-Sync-Automatismus -- manuell gegengelesen). Auf main bewusst NICHT übernommen wurde damals der dist/VersaPadViewer-Build-Zielpfad (statt %LOCALAPPDATA%\VersaPadViewer) sowie .mcp.json und die docs/*.md- Referenzdateien. 2026-08-15: main wurde zurück in dev/jappel gemerged, damit landen beide Linien wieder in einem Branch -- inklusive .mcp.json, docs/*.md und dem %LOCALAPPDATA%-Zielpfad (der hat sich im Merge durchgesetzt, siehe Config-Pfad-Bullets oben). Der Notes-Feature- Teil dieser Historie ist der Grund, warum docs/data-model.md das "note"-Feld nachträglich braucht.
  • Bug behoben 2026-08-08: server.py (vp.load_profile()) und desktop_viewer.py (_current_profile_view()) lasen im Nur-Lese-Modus hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es eine ungefangene FileNotFoundError bzw. "Config-Datei fehlt"-Anzeige, obwohl das Board die Config laengst dauerhaft im NVM haelt. Fix: neue versapad_combined.fetch_from_board()/load_or_fetch() -- fehlt die Kombi-JSON, wird sie automatisch per Serial vom Board neu aufgebaut und als Cache gespeichert (self-healing), nur bei unerreichbarem Board (Port belegt/kein Board) bleibt der Fallback auf die alten Einzel-JSONs bzw. eine Klartext-Fehlermeldung. In desktop_viewer.py nur versucht, wenn Live-Sync aus ist (sonst haelt der Serial-Hintergrundthread den COM-Port -- zwei gleichzeitige Zugriffe auf denselben self.ser waeren eine Race Condition). Die Desktop-JSONs sind damit reiner Lesecache, kein Pflegeaufwand mehr fuers Board-Backup.
  • Bug behoben 2026-08-07: _on_toggle_editing() initialisierte self.combined beim ersten Aktivieren des Programmiermodus mit vcomb.default_combined() — das seedet ALLE 3 Profile aus den alten Einzel-JSONs versapad_config{1,2,3}.json, nicht aus der aktuellen versapad_config_all.json oder vom Board. Ein Klick auf "Zum Board übertragen" hat dadurch beim Testen alle 3 Profile auf einen veralteten Stand zurückgesetzt, obwohl nur ein Profil-Tab sichtbar bearbeitet wurde — der Schaden an den anderen beiden Profilen blieb unbemerkt, bis explizit jedes Profil einzeln gegengelesen wurde. Fix: lädt jetzt zuerst vcomb.DEFAULT_PATH, fällt nur bei fehlender/kaputter Datei auf default_combined() zurück. Bei jedem "komisches Layout"-Report hier immer ALLE 3 Profile prüfen, nicht nur das gemeldete.
  • Randloses Fenster (seit 2026-08-15): Die Titelleiste ist per reinem Tk overrideredirect(True) ausgeblendet -- niemals per ctypes/WinAPI nachhelfen (kein SetWindowLongW/ShowWindow auf das eigene Fenster), siehe Speicher-Notiz "keine Selbstversteck-Fenstertricks": genau diese Kombination hat in einem anderen Projekt Bitdefender-Fehlalarme ausgelöst. Konsequenzen, die mitgebaut werden müssen: kein Taskleisten-Eintrag (Rückweg nur über das Tray-Icon), kein Ziehen am Rahmen (Kopfzeile ist deshalb per <B1-Motion> verschiebbar), keine System-Buttons (eigene / in der Kopfzeile, beide legen ins Tray) und keine Resize-Ränder.
  • Der Größen-Anfasser hängt per place() an der Fensterecke, nicht gepackt am Ende des Inhalts. Gepackt verschwindet er, sobald das Fenster kleiner als der Inhalt ist -- also exakt dann, wenn man ihn zum Vergrößern bräuchte. MIN_W/MIN_H begrenzen das Verkleinern.
  • Fensterhöhe muss zum Karteninhalt passen: seit die Karten eine Notizzeile haben (CARD_H 84 → 114) braucht das Fenster ~960px, sonst liegen Encoder-Bereich und Fußzeile unterhalb des Rands. Wer CARD_H ändert, muss geometry() mit anpassen.
  • Dialoge öffnen über dem Hauptfenster, nicht in der Bildschirmecke (2026-08-28): _ModalDialog baut sich withdraw()n auf, positioniert sich in run() per _center_on_parent() und wird erst dann deiconify()t. Ohne das legt Tk jeden Toplevel bei +0+0 an — bei 20 Tasten hintereinander wandert der Blick jedes Mal in die linke obere Ecke. Das withdraw() gehört zwingend dazu, sonst blitzt der Dialog dort auf, bevor er springt.
  • Verschachtelte Dialoge geben den Grab zurück: MacroStepsDialog läuft im ActionEditDialog. _finish() ruft grab_release(), was den Grab des aufrufenden Dialogs mit wegnimmt — run() setzt ihn deshalb am Ende wieder, wenn der Parent ein _ModalDialog ist.
  • Panel-Höhe im ActionEditDialog ist fix (grid_propagate(False)): sonst springt die Fenstergröße bei jedem Typwechsel (Keine/Taste/Makro/…) und OK/Abbrechen wandern unter dem Mauszeiger weg.
  • Kopieren/Einfügen zwischen Tasten (Rechtsklickmenü bzw. Strg+C/Strg+V auf der Karte unter dem Mauszeiger) benutzt eine reine In-Memory-Ablage (self._clip), nicht die System-Zwischenablage — dort liegen Action-Dicts, kein Text. Immer copy.deepcopy(), sonst teilen sich zwei Tasten dasselbe Dict und eine spätere Bearbeitung ändert beide. „Leeren" behält die Notiz (gleiche Regel wie beim Typwechsel im Dialog, siehe Notizen-Bullet oben). Eine kopierte LED-Farbe auf einen Encoder einzufügen ist wirkungslos (Encoder haben keine eigene LED) — das ist Absicht, kein Fehler.
  • Die Toolbar des Programmiermodus ist bei der Standardbreite (790px) fast voll — zusätzliche Hinweistexte dort kurz halten, sonst werden sie rechts abgeschnitten.
  • Tastendruck-Erkennung (seit 2026-08-28, auf expliziten User-Wunsch): Tasten lassen sich im Bearbeiten-Dialog per „⌨ Taste drücken" erfassen, Makro-Schritte zusätzlich als Folge am Stück („⏺ Folge aufnehmen"). Umgesetzt ausschließlich über Tk-Fenster-Events (<KeyPress>/ <KeyRelease> auf dem Dialog-Toplevel, ausgewertet in versapad_data.tk_event_to_hid()) — weiterhin kein WinAPI-Hook (SetWindowsHookEx o.ä.), aus demselben AV-Fehlalarm-Grund wie bei den Fensterverstecktricks. Wer hier auf einen globalen Hook „aufrüstet", baut genau dieses Risiko ein. Konsequenzen, die so bleiben müssen:
    • Erkannt wird nur, was das fokussierte Fenster erreicht — Win+L, Strg+Alt+Entf und andere vom System abgefangene Kombinationen nicht.
    • Das Dropdown bleibt daneben stehen (Korrekturmöglichkeit), es ersetzt die Erkennung nicht und wird von ihr nicht ersetzt.
    • Modifier werden doppelt ermittelt (selbst mitgeführte Press/Release-Bits plus event.state), weil ein KeyRelease bei Fokuswechsel verloren gehen kann.
    • _KeyCapture hängt an einem tk.Label, nicht an einem tk.Button: Buttons reagieren per Klassen-Binding selbst auf Leertaste/Enter und würden die laufende Aufnahme mit ihrem eigenen Klick beantworten.
    • Makro-Schritte filtern das Win-Bit weg (allow_win=False), passend zur Firmware-Regel oben.
  • Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich aktive Windows-Tastaturlayout (die echte VersaGUI löst das über GetKeyNameText(), das bilden wir ohne WinAPI-Call nicht nach). Dieselbe Näherung gilt für die Tastendruck-Erkennung: HID-Keycodes sind physische US-Tastenpositionen, Tk liefert aber nur keysym/VK-Code des aktiven Layouts — die Position (Scan-Code) wäre dafür nötig und ist ohne WinAPI nicht zu bekommen. Auf deutschem Layout landen Y und Z deshalb vertauscht auf dem Board, und die einzige echte Keysym-Kollision (minus: US-Position 0x2D vs. deutsche Position 0x38) ist bewusst zugunsten der US-Position aufgelöst, damit Erkennung und Dropdown-Beschriftung dasselbe sagen. Bei gehaltenem Shift zählt zuerst der VK-Code, weil der keysym dann das verschobene Zeichen ist (deutsch: Shift+7 → slash, was sonst fälschlich auf Taste 0x38 zeigen würde).
  • Tk-Aufrufe (self.after(), Widget-Konfiguration) NIE direkt aus einem Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in eine queue.Queue, der Main-Thread holt sie per after()-Polling ab.
  • .exe (PyInstaller) läuft NICHT vom Netzlaufwerk aus (Z:\Git\..., SMB-Share) — Windows blockiert das Nachladen der _internal-DLLs von einem Netzwerkpfad ohne jede Fehlermeldung. Bauen scheitert dort zusätzlich an Tcl/Tk-tzdata-Pfadlängen. build_and_deploy.ps1 kopiert den Quellcode deshalb zuerst nach %TEMP%, baut nur dort, deployt nach %LOCALAPPDATA%.
  • --onedir, nicht --onefile beim PyInstaller-Build — Single-File-Bundles lösen öfter AV-Fehlalarme aus (sehen strukturell wie ein Packer aus).

Existing-Codebase-Regel

Es gibt bereits eine Codebasis mit validierter, gegen die echte Firmware getesteter Logik (Binärformat, CRC, Protokoll). Nicht blind neu raten oder umschreiben. Vor größeren Änderungen am Binärformat/Protokoll immer gegen die Firmware- und GUI-Quellen abgleichen, nicht aus dem Gedächtnis rekonstruieren:

  • VersaGUI/src/ActionDialog.cs (HidKeyName, s_consumer, Modifier-Bits)
  • VersaGUI/src/Protocol.cs (Paket-IDs, Chunk-Größen)
  • VersaMCU/src/config/nvm_config.h + .cpp (SDeviceConfig-Layout, CRC)
  • VersaMCU/src/config/action.h (SAction, ActionType-Enum-Werte)
  • VersaMCU/src/config/macro_config.h + .cpp (SMacroTable-Layout)
  • VersaMCU/src/CButton.h (LEDAnim-Enum-Werte)
  • VersaMCU/src/CMainController.cpp (welche Commands die Firmware tatsächlich behandelt — Protocol.cs definiert mehr Konstanten als zwingend implementiert sind, immer hier gegenchecken)

Frühere falsche Annahme, die hier stand: Makros seien nur per SWD/JTAG schreibbar. Falsch — USB_CMD_MACRO_BEGIN/DATA/COMMIT in CMainController.cpp sind normal über USB implementiert. Korrigiert, auch im versapad-Skill (~/.claude/skills/versapad/SKILL.md).

Implementierungsdisziplin

In Phasen arbeiten, nicht mehrere unabhängige Features in einem Rutsch ohne Zwischenverifikation bauen. Vor jeder Änderung am Binärformat/Protokoll: gegen ein echtes Board testen (read → unpack → pack → Vergleich auf Byte-Identität), nicht nur gegen Beispieldaten. Vor jedem Schreibvorgang aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor echte Änderungen geschrieben werden.

Änderungen an der Config immer aus der Sicht prüfen, die die laufende App hat (2026-08-15 gelernt): Claudes Bash- und PowerShell-Werkzeuge sehen unter C:\Users\chris\AppData\... teilweise unterschiedliche Dateien — derselbe Pfad lieferte gleichzeitig 29826 Bytes (Bash/MCP-Server) und 28455 Bytes (PowerShell/.exe). Konkret heißt das: Config-Schreibvorgänge über den versapad-MCP-Server (save_local()) erreichen die installierte .exe nicht zuverlässig. Das hat eine Fehlersuche über viele Runden verschleppt, weil "Notizen sind in der Datei" (Bash) und "App zeigt keine Notizen" gleichzeitig stimmten. Vorgehen:

  • Config-Dateien, die die installierte App lesen soll, über PowerShell schreiben/prüfen (Get-Item, dort ausgeführtes python), nicht über Bash.
  • Bei "Änderung wirkt nicht"-Symptomen als Erstes Dateigröße/mtime aus beiden Sichten vergleichen, bevor Code-Ursachen gesucht werden.
  • Z:\Git\... (SMB-Share) ist von diesem Effekt nicht betroffen — Quellcode und Build verhalten sich normal.

Verlässlich zum Ziel führt bei solchen Widersprüchen ein temporäres Diagnose-Modul, das im gebauten Bundle mitläuft und app_dir(), DEFAULT_PATH, os.stat() und den tatsächlich geladenen JSON-Inhalt in eine Datei schreibt — damit war die Ursache in einem Durchlauf sichtbar, nachdem Vermutungen mehrfach danebenlagen.

Naming

Code-Identifier englisch (Python-Konvention: pack_config, read_macros, ActionEditDialog). UI-Text, Kommentare und Docstrings deutsch (Projekt- und User-Konvention). Domänenbegriffe aus der Firmware direkt übernehmen, nicht neu erfinden: profile, action, led, slot, macro, encoder, chunk. Keine generischen Namen wie data/item/entry, wo eine Bedeutung existiert — Ausnahme: data als Feldname ist durch die Firmware selbst vorgegeben (SAction.data), dort beibehalten statt umzubenennen.

Deferred Work

  • Board-seitiges Umschalten des aktiven Profils per Button in der GUI — explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only
  • Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im Firmware-Struct), bleibt lokal
  • Hintergrund-Thread für "Vom Board laden"/"Zum Board übertragen" — laufen aktuell synchron im UI-Thread (kurzzeitiges Einfrieren möglich)
  • Vorgefertigte .exe im Repo/als Release-Asset — bewusst nicht committet (Build-Artefakt), Anleitung zum Selbstbauen steht in README.md

Nicht an diesen Punkten arbeiten, ohne dass der User es explizit anfragt.

Dokumentation und Verifikation

  • README.md ist der Einstiegspunkt (Installation, Nutzung, Architektur- Überblick).
  • AGENTS.md (diese Datei) ist die agentenseitige Quelle der Wahrheit für Domänenregeln, Architekturgrenzen und Bug-Historie — jede Session aktualisieren, die daran etwas ändert oder etwas Wichtiges lernt.
  • docs/ (seit 2026-08-14, auf expliziten User-Wunsch) enthält die menschenlesbare Referenzdoku: architecture.md (Schichten, Prozess-/ Nebenläufigkeitsmodell, Config-Speicherort), data-model.md (JSON- Formate, binäres NVM-Layout, Geometrie, Enums), protocol.md (Serial-Wire-Protokoll). Bug-Historie/Domänenregeln bleiben bewusst nur in AGENTS.md, nicht dupliziert in docs/. Bei Änderungen am Binärformat/Protokoll/Datenmodell docs/data-model.md bzw. docs/protocol.md mitpflegen.

Prüfungen vor einem Commit an Binärformat/Protokoll:

python -m py_compile *.py

Für UI-Änderungen (Dialoge, Tastendruck-Erkennung, Kopieren/Einfügen) hat sich zusätzlich bewährt, ein Wegwerf-Skript im Scratchpad zu fahren, das die Dialoge ohne Board aufbaut, Tk-Events als kleine Fake-Event-Objekte (keysym/keycode/state) durchreicht und das Ergebnis-Dict prüft — die komplette Capture- und Copy/Paste-Logik ist so ohne Klicken verifizierbar. Wichtig dabei: versapad_combined.DEFAULT_PATH vorher auf eine Temp-Datei umbiegen, sonst schreibt _autosave_combined() in die echte Nutzer-Config. Für Screenshots gilt: Tk rechnet in logischen Pixeln, ImageGrab liefert physische — bei aktiver Windows-Skalierung (hier 125%) sonst ein zu kleiner Ausschnitt, der wie ein Layout-Fehler aussieht. Danach ein Live-Testskript gegen ein angeschlossenes Board laufen lassen (read → unpack → pack → Bytevergleich, siehe Existing-Codebase-Regel) — es gibt keine automatisierten Unit-Tests dafür, die Verifikation läuft manuell/interaktiv pro Session.

Committen: prägnante Commit-Message je abgeschlossenem, verifiziertem Arbeitspaket. Nach jedem Push: alle bekannten Remotes prüfen (origin auf GitHub, jappel auf git.jappel.io) — beide müssen synchron bleiben, siehe Speicher-Notiz "Multi-Remote-Repos synchron halten".

PR-Erstellung auf git.jappel.io per API/curl mit Access-Token wird vom Bash-Classifier geblockt (Auto-Mode, gilt auch für git credential fill), siehe Speicher-Notiz "Bash-Klassifikator blockt Credential/Auth-Schreibzugriffe". Branch pushen geht (nutzt den Git-eigenen Credential-Helper, kein Token im Klartext im Bash-Aufruf), den fertigen PR muss der User über den von Forgejo nach dem Push ausgegebenen Compare-Link selbst anlegen (oder Claude einen Token geben, der dann NICHT wiederverwendbar im Bash-Aufruf landen darf, sondern nur für den einen curl-Call — auch das kann der Classifier trotzdem blocken, dann bleibt nur der manuelle Link).