# 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`](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.py` — `VersaPadLink`: 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 -- versapad_mcp_server.py`). Nutzt MCP-SDK v2 (Paket `mcp`, Klasse `mcp.server.mcpserver.MCPServer` — **nicht** `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_board` — `finally: _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 ``-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** (``/ `` 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: ```bash 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).