VersaGUI-py/AGENTS.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

32 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).

  • versapad_keylayout.py (seit 2026-08-29) — passive Abfragen ans aktive Windows-Tastaturlayout: hid_for_vk() (Virtual-Key → Scan-Code → HID über MapVirtualKeyW) und key_name() (Beschriftung über GetKeyNameTextW). Optional: auf Nicht-Windows/ohne ctypes bleibt AVAILABLE False und versapad_data faellt auf seine US-Naeherung zurueck. Zur Abgrenzung gegen die WinAPI-Verbote siehe Domaenenregeln.

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.
  • WinAPI: was verboten bleibt und was nicht. Verboten sind Fenstermanipulation am eigenen Fenster (SetWindowLongW, ShowWindow) und globale Eingabehooks (SetWindowsHookEx) — genau diese Kombination hat laut Speicher-Notiz „keine Selbstversteck- Fenstertricks" in einem anderen Projekt Bitdefender-Fehlalarme ausgeloest. Erlaubt und seit 2026-08-29 in Benutzung sind passive Layout-Abfragen (MapVirtualKeyW, GetKeyNameTextW in versapad_keylayout.py): sie lesen nur die Tastaturbelegung, fassen weder Fenster noch Prozesse noch den Eingabestrom an; die offizielle VersaGUI (C#) benutzt GetKeyNameText() fuer denselben Zweck. Die Grenze verlaeuft also nicht bei „ctypes", sondern bei „greift ins System ein".
  • Normales Fenster mit Titelleiste (seit 2026-08-29). Von 2026-08-15 bis dahin lief das Fenster randlos (overrideredirect(True)) — das kostet unter Windows zwingend den Taskleisten-Eintrag. Als der ausdruecklich gebraucht wurde, gab es nur drei Wege: Titelleiste zurueck, SetWindowLongW+WS_EX_APPWINDOW (siehe Verbot oben) oder ein unsichtbares Proxy-Fenster. Der User hat sich fuer die Titelleiste entschieden. Damit sind ersatzlos entfallen: ziehbare Kopfzeile (_start_move/_on_move), Groessen-Anfasser unten rechts (_start_resize/_on_resize, ersetzt durch self.minsize()), die eigenen /-Knoepfe und der <Unmap>-Handler, der Minimieren ins Tray umgeleitet hat. Wer das Fenster wieder randlos machen will, nimmt dem User den Taskleisten-Eintrag weg — nicht ohne Ruecksprache.
  • Schliessen () legt weiterhin ins Tray statt zu beenden (wie die offizielle VersaGUI, das Programm laeuft im Hintergrund weiter), Minimieren geht jetzt aber ganz normal in die Taskleiste. Escape minimiert ebenfalls, statt wie frueher ins Tray zu legen — mit Taskleisten-Eintrag waere „verschwindet spurlos" die unangenehmere Ueberraschung.
  • 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.
  • Dialogposition wird gegen das ELTERNFENSTER begrenzt, nie gegen winfo_screenwidth() (2026-08-29): Tk meldet dort nur den Hauptbildschirm. Die erste Fassung hat damit geklemmt — lag das Hauptfenster auf einem zweiten Monitor, zog genau diese Begrenzung den Dialog zurueck an den Rand des ersten. Passt der Dialog nicht ins Elternfenster (der Makro-Schritte-Dialog ist breiter als der Action-Dialog), wird er an dessen linker oberer Ecke ausgerichtet statt zentriert.
  • 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.
    • Welche Taste gemeint ist, wird ueber die PHYSISCHE POSITION aufgeloest, nie ueber das erzeugte Zeichen (2026-08-29, nach Fehlermeldung aus der Praxis). HID-Keycodes sind Positionen: das Board sendet eine Position, erst Windows macht daraus ein Zeichen. Die erste Fassung ging ueber keysym/Zeichen und war auf deutschem Layout entsprechend kaputt — Y und Z landeten vertauscht auf dem Board, ÄÖÜ und #/+ waren gar nicht erfassbar. Reihenfolge jetzt: (1) benannte Tasten ueber den keysym, (2) Zeichentasten ueber versapad_keylayout.hid_for_vk(), (3) die alte Naeherung nur noch als Fallback ohne WinAPI. Nicht auf "Zeichen auswerten" zurueckbauen — das ist genau der Fehler, der hier behoben wurde.
    • Schritt (1) ist keine Bequemlichkeit, sondern noetig: MapVirtualKeyW liefert fuer die Pfeiltasten denselben Scan-Code wie fuer ihre Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B, das E0-Praefix von MAPVK_VK_TO_VSC_EX bleibt dort aus). Ueber den Scan-Code allein waeren Pfeiltasten nicht von Numpad-Tasten zu unterscheiden.
    • 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.
  • Tastenbeschriftungen kommen vom aktiven Layout (seit 2026-08-29). versapad_data.hid_key_name() fragt fuer Zeichentasten GetKeyNameTextW (deutsch: HID 0x1C → „Z", 0x34 → „ä"); fuer alles andere bleiben die gepflegten deutschen Namen aus _SPECIAL_KEYS („Enter", „Bild↑", „Num5") — die lesen sich besser als das, was Windows liefert („EINGABE", „4 (ZEHNERTASTATUR)"). Ohne Layout-Abfrage (Nicht-Windows) faellt alles auf die alte US-Naeherung zurueck. Konsequenzen, die man kennen muss:
    • Bestehende Belegungen aendern ihre Anzeige, nicht ihre Daten. Wer frueher im Dropdown „Z" gewaehlt hat, bekam 0x1D — das steht so in der Config und zeigt jetzt wahrheitsgemaess „Y", weil es auf dieser Tastatur ein Y tippt. Das ist keine Regression, sondern der sichtbar gewordene Altfehler.
    • Namen sind Schluessel (Dropdown, hid_key_code_for_name()) und muessen eindeutig bleiben. Es gibt echte Kollisionen: auf deutschem Layout heisst HID 0x31 schlicht „#" — den Namen trug bisher HID 0x32. Der Layoutname gewinnt, der verdraengte US-Name wird als „# (US-Layout)" gekennzeichnet statt verworfen, damit die Taste ansprechbar bleibt.
    • hid_key_code_for_name() akzeptiert weiterhin beide Schreibweisen, bei Kollision gewinnt das Layout. Fuer den MCP-Server heisst das: set_button_key(key="Z") trifft die Taste, die auf dieser Tastatur ein Z tippt (0x1C), nicht mehr die US-Position 0x1D.
    • Ein Layoutwechsel zur Laufzeit wird nicht bemerkt (Namen werden einmal ermittelt und behalten).
  • 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).