diff --git a/.gitignore b/.gitignore index 2a1de3f..6c4e83b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ __pycache__/ *.log build/ dist/ + +# Nutzer-Config -- landet neben der Installation (versapad_data.app_dir()), +# beim Start aus dem Quellcode also direkt hier im Projektordner +versapad_config_all.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..2b7f17d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "versapad": { + "command": "py", + "args": ["C:\\Users\\Julian\\Documents\\__CODE\\VersaGUI-py\\versapad_mcp_server.py"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 05fd16f..aab3b1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,17 @@ Nutzerorientierte Einführung: [`README.md`](README.md). - `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). @@ -29,33 +40,75 @@ Nutzerorientierte Einführung: [`README.md`](README.md). 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. + 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 - `~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll: - `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den kompletten 740B-Block. + `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. + `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 User-Scope-MCP-Server - "versapad" (`claude mcp add -s user versapad -- versapad_mcp_server.py`). +- `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 — die Dateien sind klein genug, dass eine separate -`docs/current-architecture.md` hier keinen Mehrwert hätte (siehe -Dokumentation und Verifikation unten für die Größeneinschätzung). +nachschlagen. Menschenlesbare Referenzdoku (Architektur, Datenmodell, +Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. ## Kritische Domänenregeln @@ -74,6 +127,18 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). (`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 @@ -86,12 +151,226 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). 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". -- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein - Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm - wie bei den Fensterverstecktricks in anderen Projekten zu riskieren). -- 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). +- **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 @@ -136,6 +415,28 @@ 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`, @@ -148,14 +449,10 @@ selbst vorgegeben (`SAction.data`), dort beibehalten statt umzubenennen. ## Deferred Work -- Volle `docs/`-Baumstruktur (siehe Dokumentation und Verifikation unten — - Projektgröße rechtfertigt das aktuell nicht, kein DB-/API-Dienst) - 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 -- Tastendruck-Capture statt Dropdown im Programmiermodus — bewusst - vermieden (WinAPI-Hook-Risiko) - 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 @@ -168,18 +465,32 @@ Nicht an diesen Punkten arbeiten, ohne dass der User es explizit anfragt. - `README.md` ist der Einstiegspunkt (Installation, Nutzung, Architektur- Überblick). - `AGENTS.md` (diese Datei) ist die agentenseitige Quelle der Wahrheit für - Domänenregeln und Architekturgrenzen — jede Session aktualisieren, die - daran etwas ändert oder etwas Wichtiges lernt. -- Größeneinschätzung nach Projekt-Dokumentationsstandard: kleines/mittleres - Tool ohne eigene Datenbank und ohne persistenten API-Dienst (der - Browser-Server ist ein einfacher lokaler Lese-Viewer, kein - Mehrbenutzer-Backend) → `README.md` + `AGENTS.md` sind Pflicht und - vorhanden, ein voller `docs/`-Baum ist nicht angemessen. + 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 @@ -189,3 +500,13 @@ 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). diff --git a/README.md b/README.md index ebe6691..0c84f10 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original, inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum -Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die -Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte -Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`, -`versapad_combined.DEFAULT_PATH`) — für einen anderen Rechner dort anpassen. +Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene +Config-Datei (`versapad_config_all.json`) liegt automatisch unter +`%APPDATA%\VersaPadViewer\` (siehe „Aufbau" unten) und wird bei Bedarf +automatisch neu angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* +Lese-Interop mit den JSON-Exports der offiziellen VersaGUI +(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine +hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen, +falls gewünscht. ## Features @@ -26,26 +30,63 @@ Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`, und schaltet die Ansicht automatisch mit - **Programmiermodus** — Zellen anklicken und bearbeiten (Taste, Medientaste, Makro, Profilwechsel, LED-Farbe/Animation), direkt aufs Board schreiben - oder als Datei speichern + oder als Datei speichern. Der Dialog öffnet über dem Hauptfenster, + Enter bestätigt, Escape bricht ab +- **Tastendruck-Erkennung** — statt die Taste im Dropdown zu suchen, + „⌨ Taste drücken" klicken und die gewünschte Kombination einfach + drücken (Modifier inklusive). Erkannt wird die *physische* Taste, nicht + das Zeichen — Y/Z, ÄÖÜ, `#`, `+` und `ß` landen also richtig auf dem + Board, auch auf deutschem Layout. Läuft über das Dialogfenster, nicht + über einen System-Hook: vom System abgefangene Kombinationen (Win+L, + Strg+Alt+Entf) kommen nicht an +- **Layoutrichtige Tastennamen** — Beschriftungen kommen vom aktiven + Windows-Layout (`Strg+Z` heißt auf deutscher Tastatur auch `Strg+Z`, und + `ä`/`ö`/`ü` heißen so). Bestehende Belegungen ändern dadurch ihre + Anzeige, nicht ihre Funktion - **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte - Makro-Tabelle vom Board + Makro-Tabelle vom Board. Schritte einzeln erfassen oder die ganze Folge + am Stück aufnehmen („⏺ Folge aufnehmen"). Die Slot-Auswahl listet alle 32 + Slots samt Inhalt, statt sie einzeln durchklicken zu müssen +- **Makros im Grid lesbar** — eine Makro-Belegung zeigt die tatsächliche + Tastenfolge (`Makro 3: Strg+C → Strg+V`) statt nur der Slot-Nummer; das + gilt auch in der Browser-Ansicht und in den MCP-Antworten +- **Farb-Schnellwahl** — zwölf Grundfarben direkt in der LED-Zeile des + Dialogs, der System-Farbdialog nur noch für den Rest („mehr…") +- **Kopieren/Einfügen zwischen Tasten** — Rechtsklick auf eine Karte im + Programmiermodus: Belegung und/oder Farbe kopieren und auf andere Tasten + anwenden, oder die Belegung leeren. Strg+C/Strg+V wirken auf die Karte + unter dem Mauszeiger +- **Notizen** — freier Text pro Button/Encoder-Aktion, was sie tatsächlich + tut (z.B. "Speichern in Fusion 360"), zusätzlich zur automatischen + Beschriftung ("Strg+S"). Rein lokal wie Profilnamen, geht nie aufs Board, + bleibt beim Tastenwechsel und beim "Vom Board laden" erhalten - **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten) - **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie die offizielle VersaGUI +- **Normales Fenster mit Taskleisten-Eintrag** — Titelleiste, Alt+Tab, + Aero-Snap und Größe ändern am Rahmen funktionieren nativ. `✕` beendet + nicht, sondern legt ins Tray (wie die offizielle VersaGUI — das Programm + läuft im Hintergrund weiter); Minimieren geht normal in die Taskleiste. +- **Tastenkürzel im Hauptfenster** — `Strg+1/2/3` Profil wechseln, `F2` + Profil umbenennen, `Strg+E` Programmiermodus an/aus, `Strg+C`/`Strg+V` + Taste unter dem Mauszeiger kopieren/einfügen, `Esc` minimieren. ## Voraussetzungen - Python 3.11 oder neuer -- Pakete: `pyserial` (Board-Kommunikation), `pystray` + `pillow` - (Tray-Icon/Desktop-App), optional `mcp` (nur für den MCP-Server) +- Alle Pakete stehen in `requirements.txt` (`pyserial` fürs Board, + `pystray` + `pillow` fürs Tray-Icon/Desktop-Fenster, `mcp` für den + MCP-Server, `pyinstaller` fürs `.exe`-Bauen): ```bash -pip install pyserial pystray pillow mcp +pip install -r requirements.txt ``` Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek — -keine zusätzlichen Pakete nötig. +keine zusätzlichen Pakete nötig. `build_and_deploy.ps1` installiert +`requirements.txt` beim Bauen automatisch selbst — ein manuelles +`pip install` vorher ist dafür nicht nötig. ## Starten @@ -65,8 +106,15 @@ Maschine/Python-Version unterschiedlich). Selbst bauen: .\build_and_deploy.ps1 ``` -Das Skript baut mit PyInstaller (`--onedir --windowed`, eigenes Icon) und -kopiert das Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. +Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus +`requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann +mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das +Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. +Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das +Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich +bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause` +unterdrückt das für automatisierte Aufrufe/CI). + **Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und @@ -75,13 +123,12 @@ das Nachladen der Bundle-DLLs von einem Netzwerkpfad, ohne jede Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb automatisch zuerst nach `%TEMP%` und baut nur dort. -Voraussetzung: `pip install pyinstaller` zusätzlich zu den obigen Paketen. - ## Aufbau | Datei | Zweck | |---|---| -| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text | +| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text, Tastendruck → HID-Keycode | +| `versapad_keylayout.py` | Abfragen ans aktive Windows-Tastaturlayout: physische Tastenposition und Tastenname (optional, nur Windows) | | `versapad_protocol.py` | Binäres NVM-Layout des Boards (740B Config + 512B Makros), CRC16 — pack/unpack | | `versapad_serial.py` | Serial-Client: liest/schreibt Config + Makros per 8-Byte-Paket-Protokoll | | `versapad_combined.py` | Ein-Datei-Format für alle 3 Profile + Makros + lokale Profilnamen | @@ -94,19 +141,38 @@ Das Binärformat (`versapad_protocol.py`) ist 1:1 aus den VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). -Config-Dateien liegen standardmäßig auf dem Desktop -(`versapad_config1/2/3.json` für den reinen Lesemodus, -`versapad_config_all.json` für den Programmiermodus — Pfade sind aktuell -hartkodiert für eine bestimmte Windows-Maschine, siehe `CONFIG_PATHS` in -`versapad_data.py` bzw. `DEFAULT_PATH` in `versapad_combined.py`). +Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile + +Makros + lokale Profilnamen + Notizen, siehe `versapad_combined.py`) liegt +unter `%APPDATA%\VersaPadViewer\` (`versapad_data.app_dir()`), also +**getrennt vom Installationsordner** und unabhängig davon, ob die `.exe` +oder der Quellcode gestartet wurde. Beides ist Absicht: das Build-Skript +räumt sein Zielverzeichnis vor jedem Deploy komplett ab (läge die Config +dort, würde jeder Rebuild sie löschen), und ein vom Startweg abhängiger +Pfad hatte zu zwei auseinanderlaufenden Configs geführt. Fehlt die Datei +(z.B. frische Installation), wird sie automatisch angelegt — per Serial vom +Board, falls eins angeschlossen ist, sonst als leere Default-Config. +Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich +per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder +Programmiermodus) oder `rename_profile()` (MCP) ändern. Notizen ebenso — +im Bearbeiten-Dialog des Programmiermodus oder per +`set_button_note()`/`set_encoder_note()` (MCP). + +Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool +geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export +der offiziellen VersaGUI (C#/.NET) — Pfad aktuell hartkodiert auf den +OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in +`versapad_data.py`. ## MCP-Server `versapad_mcp_server.py` macht die Config per Tool-Aufruf statt Hand-JSON programmierbar — nutzbar von jeder MCP-fähigen KI-Anwendung (Claude Code, -Claude Desktop, andere). In der MCP-Server-Liste der jeweiligen Anwendung -eintragen: Kommando `python`/`py`, Argument der Pfad zu -`versapad_mcp_server.py`. +Claude Desktop, andere). Für Claude Code liegt bereits eine projektgebundene +[`.mcp.json`](.mcp.json) im Repo (Server "versapad", Kommando `py +versapad_mcp_server.py`) — beim Öffnen des Projekts wird sie automatisch +zum Verbinden angeboten. Für andere Anwendungen oder user-scope-Registrierung +in der jeweiligen MCP-Server-Liste eintragen: Kommando `python`/`py`, +Argument der Pfad zu `versapad_mcp_server.py`. Werkzeuge (Auszug): `list_profiles`, `get_profile`, `get_macro`, `get_board_status` (lesen) · `set_button_key`/`_consumer`/`_macro`/ @@ -123,18 +189,33 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`. gleichzeitig mit der offiziellen VersaGUI laufen (die läuft dauerhaft als Tray-App weiter, auch wenn nur ihr Konfigurationsfenster geschlossen wird — für Parallelbetrieb muss sie über ihr Tray-Menü beendet werden) -- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein - Tastendruck-Capture (bewusst, um keinen WinAPI-Hook zu brauchen) -- Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich - aktive Tastatur-Layout +- Die Tastendruck-Erkennung läuft bewusst über das Dialogfenster statt über + einen globalen WinAPI-Hook — vom System abgefangene Kombinationen (Win+L, + Strg+Alt+Entf) erreichen das Fenster nie und lassen sich so nicht erfassen +- Ein Wechsel des Tastaturlayouts im laufenden Programm wird nicht bemerkt + (die Tastennamen werden einmal beim ersten Zugriff ermittelt) — Neustart + hilft. Unter Nicht-Windows fällt die Beschriftung auf eine + US-Layout-Näherung zurück - Unsignierte `.exe` — kann von Antivirus/Smart App Control blockiert werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert es aber nicht +## Dokumentation + +Ausführlichere technische Doku im [`docs/`](docs/)-Ordner: + +- [`docs/architecture.md`](docs/architecture.md) — Schichtenmodell, + Prozessmodell, Modi, Config-Speicherort, Nebenläufigkeit +- [`docs/data-model.md`](docs/data-model.md) — JSON-Formate (kombiniert + + Legacy), binäres NVM-Layout, Geometrie, Enums +- [`docs/protocol.md`](docs/protocol.md) — Serial-Wire-Protokoll (Befehle, + Events, Paketformat, CRC16) + ## Weiterentwicklung -Tiefere technische Notizen (Protokoll-Details, bekannte Stolpersteine beim -Bauen, Design-Entscheidungen) stehen in [`AGENTS.md`](AGENTS.md). +Agentenseitige Notizen (Domänenregeln, bekannte Bugs und ihre Fixes, +Implementierungsdisziplin, Design-Entscheidungen) stehen in +[`AGENTS.md`](AGENTS.md). ## Lizenz / Herkunft diff --git a/VP-Board Enclosure.f3d b/VP-Board Enclosure.f3d new file mode 100644 index 0000000..f5b250b Binary files /dev/null and b/VP-Board Enclosure.f3d differ diff --git a/action_dialog.py b/action_dialog.py index 09a9f0d..511c091 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -1,12 +1,24 @@ """ Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu -VersaGUI/src/ActionDialog.cs, aber in Tkinter und ohne Tastendruck-Capture -(kein WinAPI-Hook -- stattdessen Tasten-Auswahl per Dropdown, siehe -versapad_data.hid_key_choices()). +VersaGUI/src/ActionDialog.cs, in Tkinter. ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons). MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur echten GUI ("8 Step-Buttons + je Strg/Shift/Alt-Checkboxen", kein Win). + +Tastendruck-Erkennung (_KeyCapture): Tasten lassen sich statt per Dropdown +auch einfach druecken. Das laeuft ueber ganz normale Tk-Fokus-Events des +Dialogfensters -- KEIN globaler WinAPI-Tastaturhook (siehe Domaenenregel +"keine Selbstversteck-/Hook-Fenstertricks"). Konsequenz: erkannt wird nur, +was das fokussierte Fenster erreicht -- Win+L, Strg+Alt+Entf und aehnliche +vom System abgefangene Kombinationen also nicht. + +Welche physische Taste gemeint ist, loest versapad_data.tk_event_to_hid() +ueber den Scan-Code des aktiven Layouts auf (versapad_keylayout), nicht +ueber das erzeugte Zeichen -- sonst landet auf deutschem Layout jedes Y auf +der Z-Taste des Boards und AeOeUe/#/+ sind gar nicht erfassbar. Das Dropdown +daneben zeigt dieselbe Taste unter ihrem Layout-Namen und bleibt als +Korrekturmoeglichkeit stehen. """ import tkinter as tk from tkinter import colorchooser, ttk @@ -18,6 +30,7 @@ BG2 = "#14161b" TEXT = "#e8e8ec" TEXT_DIM = "#8a8d98" ACCENT = "#3a6ff0" +CAPTURE_BG = "#c04a2a" TYPE_CHOICES = [ ("None", "Keine"), @@ -34,8 +47,100 @@ PROFILE_SWITCH_CHOICES = [ ("Profil 3", 2), ] +# Schnellzugriff direkt in der LED-Zeile -- der Umweg ueber den +# System-Farbdialog lohnt sich fuer die paar Standardfarben nicht. +# ("aus" ist Schwarz: LED bleibt dunkel, die Firmware kennt kein +# separates Enable-Flag.) +QUICK_COLORS = [ + ("aus", (0, 0, 0)), + ("weiß", (255, 255, 255)), + ("rot", (255, 0, 0)), + ("orange", (255, 80, 0)), + ("gelb", (255, 200, 0)), + ("grün", (0, 255, 0)), + ("türkis", (0, 255, 160)), + ("cyan", (0, 200, 255)), + ("blau", (0, 64, 255)), + ("violett", (128, 0, 255)), + ("magenta", (255, 0, 200)), + ("rosa", (255, 128, 128)), +] + +MOD_CHECKBOXES = (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08)) +MACRO_MOD_CHECKBOXES = MOD_CHECKBOXES[:3] # Firmware kennt in Makros kein Win + + +class _KeyCapture: + """Macht ein Label zum "Taste drücken"-Schalter: solange er aktiv ist, + wandert jeder Tastendruck im Dialog nicht ins Widget, sondern durch + versapad_data.tk_event_to_hid() in ein (keycode, modifier)-Paar. + + on_result(keycode, modifier) wird im Tk-Main-Thread aufgerufen. + repeat=True laesst die Aufnahme nach einem Treffer weiterlaufen (fuer + "Folge aufnehmen" im Makro-Dialog), sonst endet sie nach dem ersten. + allow_win=False filtert das Win-Bit weg (Makro-Schritte). + """ + + def __init__(self, dialog, label, on_result, idle_text, allow_win=True, repeat=False): + self.dialog = dialog + self.label = label + self.on_result = on_result + self.idle_text = idle_text + self.allow_win = allow_win + self.repeat = repeat + self.active = False + self._held = 0 + label.configure(text=idle_text, cursor="hand2") + label.bind("", lambda e: self.toggle()) + + def toggle(self): + self.stop() if self.active else self.start() + + def start(self): + self.active = True + self._held = 0 + self.dialog.begin_capture(self) + self.label.configure(text="… jetzt drücken (Klick = Stopp)", bg=CAPTURE_BG, fg="#fff") + self.label.focus_set() + + def stop(self): + if not self.active: + return + self.active = False + self._held = 0 + self.dialog.end_capture(self) + self.label.configure(text=self.idle_text, bg=BG, fg=TEXT) + + def handle_key(self, event, pressed): + """Vom Dialog aufgerufen, solange diese Aufnahme aktiv ist. + Gibt immer "break" zurueck -- waehrend der Aufnahme darf kein + Tastendruck als Text im Notizfeld oder als Dialog-Shortcut landen.""" + mod = vp.TK_MODIFIER_KEYSYMS.get(event.keysym) + if mod is not None: + # Modifier selbst sind nie das Ziel, sie sammeln nur Bits -- + # ein KeyRelease sieht der Dialog nicht immer (Fokuswechsel), + # das state-Feld im Treffer-Event faengt das ab. + self._held = (self._held | mod) if pressed else (self._held & ~mod) + return "break" + if not pressed: + return "break" + hit = vp.tk_event_to_hid(event.keysym, event.keycode, event.state, self._held) + if hit is None: + return "break" # nicht zuordenbar -> einfach weiter warten + keycode, modifier = hit + if not self.allow_win: + modifier &= ~0x08 + if not self.repeat: + self.stop() + self.on_result(keycode, modifier) + return "break" + class _ModalDialog(tk.Toplevel): + """Basis: oeffnet mittig ueber dem aufrufenden Fenster (nicht in der + Bildschirmecke), Enter = OK, Escape = Abbrechen, und verteilt + Tastendruecke an eine ggf. laufende _KeyCapture.""" + def __init__(self, parent, title): super().__init__(parent) self.title(title) @@ -43,18 +148,116 @@ class _ModalDialog(tk.Toplevel): self.transient(parent) self.resizable(False, False) self.cancelled = True + self._active_capture = None + # Erst unsichtbar aufbauen, in run() positionieren und dann zeigen -- + # sonst blitzt der Dialog kurz in der linken oberen Bildschirmecke auf. + self.withdraw() + self.bind("", self._on_key_press) + self.bind("", self._on_key_release) + self.protocol("WM_DELETE_WINDOW", lambda: self._finish(True)) + + # ── Tastendruck-Aufnahme ────────────────────────────────────────── + + def begin_capture(self, capture): + if self._active_capture is not None and self._active_capture is not capture: + self._active_capture.stop() + self._active_capture = capture + + def end_capture(self, capture): + if self._active_capture is capture: + self._active_capture = None + + def _on_key_press(self, event): + if self._active_capture is not None: + return self._active_capture.handle_key(event, pressed=True) + if event.keysym in ("Return", "KP_Enter"): + self._on_ok() + return "break" + if event.keysym == "Escape": + self._finish(True) + return "break" + return None + + def _on_key_release(self, event): + if self._active_capture is not None: + return self._active_capture.handle_key(event, pressed=False) + return None + + def _on_ok(self): + """Von Enter und vom OK-Knopf -- Unterklassen ueberschreiben das.""" + self._finish(False) def _finish(self, cancelled): + if self._active_capture is not None: + self._active_capture.stop() self.cancelled = cancelled self.grab_release() self.destroy() + # ── Positionierung + Ablauf ─────────────────────────────────────── + + def _center_on_parent(self): + """Mittig ueber dem Elternfenster. Ohne das oeffnet Tk jeden Toplevel + bei +0+0, also bei jeder bearbeiteten Taste erneut in der + Bildschirmecke -- weit weg vom Fenster, in dem gerade geklickt wurde. + + Begrenzt wird bewusst gegen das ELTERNFENSTER, nicht gegen + `winfo_screenwidth()`: Tk meldet dort nur den Hauptbildschirm. Lag + das Hauptfenster auf einem zweiten Monitor, hat genau diese + Begrenzung den Dialog wieder auf den Rand des ersten Monitors + gezogen. 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 -- so bleibt + er in jedem Fall auf dem Monitor, auf dem gearbeitet wird.""" + parent = self.master + width, height = self.winfo_reqwidth(), self.winfo_reqheight() + try: + px, py = parent.winfo_rootx(), parent.winfo_rooty() + pw, ph = parent.winfo_width(), parent.winfo_height() + except tk.TclError: + px = py = 0 + pw, ph = self.winfo_screenwidth(), self.winfo_screenheight() + x = px + max(0, (pw - width) // 2) + y = py + max(0, (ph - height) // 3) # etwas oberhalb der Mitte wirkt ruhiger + self.geometry(f"+{x}+{y}") + def run(self): self.update_idletasks() + self._center_on_parent() + self.deiconify() self.grab_set() + self.focus_force() self.wait_window(self) + # Ein verschachtelter Dialog (Makro-Schritte aus dem Action-Dialog) + # nimmt beim Schliessen den Grab mit -- ohne Rueckgabe waere der + # aufrufende Dialog danach nicht mehr modal. + parent = self.master + if isinstance(parent, _ModalDialog) and parent.winfo_exists(): + parent.grab_set() + parent.focus_force() return not self.cancelled + # ── gemeinsame kleine Bausteine ─────────────────────────────────── + + def _capture_label(self, parent, on_result, idle_text="⌨ Taste drücken", + allow_win=True, repeat=False): + """Bewusst ein Label statt tk.Button: Buttons reagieren per + Klassen-Binding selbst auf Leertaste/Enter und wuerden die laufende + Aufnahme mit ihrem eigenen Klick beantworten.""" + label = tk.Label(parent, bg=BG, fg=TEXT, font=("Segoe UI", 9), + padx=10, pady=3, relief="flat") + _KeyCapture(self, label, on_result, idle_text, allow_win=allow_win, repeat=repeat) + return label + + def _button_row(self, row, columnspan, ok_text="OK"): + btns = tk.Frame(self, bg=BG2) + btns.grid(row=row, column=0, columnspan=columnspan, pady=14) + tk.Button(btns, text=f"{ok_text} (Enter)", command=self._on_ok, bg=ACCENT, fg="#fff", + activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) + tk.Button(btns, text="Abbrechen (Esc)", command=lambda: self._finish(True), bg=BG, + fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + return btns + class MacroStepsDialog(_ModalDialog): """Ergebnis in self.steps nach run()==True.""" @@ -68,43 +271,81 @@ class MacroStepsDialog(_ModalDialog): self._code_by_name = {name: code for code, name in key_choices} names = ["(leer)"] + [name for _, name in key_choices] - tk.Label(self, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.", - bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).grid( - row=0, column=0, columnspan=5, sticky="w", padx=12, pady=(12, 6)) + head = tk.Frame(self, bg=BG2) + head.grid(row=0, column=0, columnspan=6, sticky="w", padx=12, pady=(12, 6)) + tk.Label(head, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w") + tk.Label(head, text="Einzeln erfassen mit ⌨ je Zeile, oder die ganze Folge am Stück " + "aufnehmen. Win ist in Makros nicht möglich (Firmware).", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w") + + tools = tk.Frame(self, bg=BG2) + tools.grid(row=1, column=0, columnspan=6, sticky="w", padx=12, pady=(0, 6)) + self._record_label = tk.Label(tools, bg=BG, fg=TEXT, font=("Segoe UI", 9), + padx=10, pady=3) + self._record = _KeyCapture(self, self._record_label, self._on_record_step, + "⏺ Folge aufnehmen", allow_win=False, repeat=True) + self._record_label.pack(side="left") + tk.Button(tools, text="Alle leeren", command=self._clear_all, bg=BG, fg=TEXT, + activebackground=BG, relief="flat", padx=10).pack(side="left", padx=6) + self._record_hint = tk.Label(tools, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)) + self._record_hint.pack(side="left", padx=6) self._key_vars = [] self._mod_vars = [] for i in range(8): - r = i + 1 + r = i + 2 tk.Label(self, text=f"{i + 1}.", bg=BG2, fg=TEXT_DIM, - font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e") + font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e") key_var = tk.StringVar(value="(leer)") - combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly") - combo.grid(row=r, column=1, padx=4, pady=2) + ttk.Combobox(self, textvariable=key_var, values=names, width=16, + state="readonly").grid(row=r, column=1, padx=4, pady=2) self._key_vars.append(key_var) mods = {} - for j, label in enumerate(("Strg", "Shift", "Alt")): + for j, (label, _bit) in enumerate(MACRO_MOD_CHECKBOXES): v = tk.BooleanVar(value=False) tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT, - selectcolor=BG, activebackground=BG2, activeforeground=TEXT, - font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, sticky="w") + selectcolor=BG, activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, + sticky="w") mods[label] = v self._mod_vars.append(mods) - for i, step in enumerate(steps[:8]): - self._key_vars[i].set(self._name_by_code.get(step["keycode"], "(leer)")) - self._mod_vars[i]["Strg"].set(bool(step["modifier"] & 0x01)) - self._mod_vars[i]["Shift"].set(bool(step["modifier"] & 0x02)) - self._mod_vars[i]["Alt"].set(bool(step["modifier"] & 0x04)) + self._capture_label( + self, lambda code, mod, idx=i: self._apply_step(idx, code, mod), + idle_text="⌨", allow_win=False, + ).grid(row=r, column=5, padx=(6, 12), pady=2) - btns = tk.Frame(self, bg=BG2) - btns.grid(row=9, column=0, columnspan=5, pady=12) - tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff", - activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) - tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG, - fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + for i, step in enumerate(steps[:8]): + self._apply_step(i, step["keycode"], step["modifier"]) + + self._button_row(row=10, columnspan=6) + + def _apply_step(self, index, keycode, modifier): + self._key_vars[index].set(self._name_by_code.get(keycode, "(leer)")) + for label, bit in MACRO_MOD_CHECKBOXES: + self._mod_vars[index][label].set(bool(modifier & bit)) + + def _clear_all(self): + for i in range(8): + self._apply_step(i, 0, 0) + self._record_hint.configure(text="") + + def _on_record_step(self, keycode, modifier): + """Aufnahmemodus: jeder Tastendruck fuellt den naechsten freien + Schritt. Nach dem 8. stoppt die Aufnahme selbst (mehr Schritte + kennt SMacroTable nicht).""" + free = next((i for i in range(8) if self._key_vars[i].get() == "(leer)"), None) + if free is None: + self._record.stop() + self._record_hint.configure(text="alle 8 Schritte belegt") + return + self._apply_step(free, keycode, modifier) + self._record_hint.configure(text=f"Schritt {free + 1} aufgenommen") + if free == 7: + self._record.stop() def _on_ok(self): steps = [] @@ -112,11 +353,8 @@ class MacroStepsDialog(_ModalDialog): name = self._key_vars[i].get() if name == "(leer)": break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren - modifier = ( - (0x01 if self._mod_vars[i]["Strg"].get() else 0) | - (0x02 if self._mod_vars[i]["Shift"].get() else 0) | - (0x04 if self._mod_vars[i]["Alt"].get() else 0) - ) + modifier = sum(bit for label, bit in MACRO_MOD_CHECKBOXES + if self._mod_vars[i][label].get()) steps.append({"keycode": self._code_by_name[name], "modifier": modifier}) self.steps = steps self._finish(False) @@ -124,7 +362,9 @@ class MacroStepsDialog(_ModalDialog): class ActionEditDialog(_ModalDialog): """Ergebnis in self.action / self.led nach run()==True. led bleibt None - wenn led_in None war (Encoder -- keine eigene Farbe).""" + wenn led_in None war (Encoder -- keine eigene Farbe). self.action enthaelt + immer ein "note"-Feld (freie Notiz, was die Aktion tut -- rein lokal wie + Profilnamen, geht nie aufs Board).""" def __init__(self, parent, title, action, led, macros): super().__init__(parent, title) @@ -134,21 +374,30 @@ class ActionEditDialog(_ModalDialog): self.led = None row = 0 + note_row = tk.Frame(self, bg=BG2) + note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1 + tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + self._note_var = tk.StringVar(value=action.get("note", "")) + tk.Entry(note_row, textvariable=self._note_var, width=52, bg=BG, fg=TEXT, + insertbackground=TEXT, relief="flat").pack(side="left", padx=6) + tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid( - row=row, column=0, sticky="w", padx=12, pady=(12, 4)); row += 1 + row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1 self._type_var = tk.StringVar(value=action["type"]) type_frame = tk.Frame(self, bg=BG2) type_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12); row += 1 for value, label in TYPE_CHOICES: tk.Radiobutton(type_frame, text=label, variable=self._type_var, value=value, - command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG, - activebackground=BG2, activeforeground=TEXT, - font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) + command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG, + activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) - self._panel_row = row - self._panel = tk.Frame(self, bg=BG2) - self._panel.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=8) + # Feste Groesse: sonst springt die Fensterhoehe (und damit die + # Position von OK/Abbrechen) bei jedem Typwechsel. + self._panel = tk.Frame(self, bg=BG2, width=600, height=90) + self._panel.grid(row=row, column=0, columnspan=3, sticky="ew", padx=12, pady=8) + self._panel.grid_propagate(False) row += 1 # HidKey-Panel @@ -165,17 +414,16 @@ class ActionEditDialog(_ModalDialog): self._consumer_var = tk.StringVar() # Macro-Panel - self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0) - self._macro_preview_var = tk.StringVar() + self._macro_slot = action["data"] if action["type"] == "Macro" else 0 + self._macro_choice_var = tk.StringVar() # ProfileSwitch-Panel self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0]) if action["type"] == "HidKey": keycode = action["data"] & 0xFF - modifier = (action["data"] >> 8) & 0xFF self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)")) - self._hidkey_mods_init = modifier + self._hidkey_mods_init = (action["data"] >> 8) & 0xFF else: self._hidkey_key_var.set("(leer)") self._hidkey_mods_init = 0 @@ -197,45 +445,67 @@ class ActionEditDialog(_ModalDialog): self._led_color = (self._led["r"], self._led["g"], self._led["b"]) if self._led else (80, 40, 0) if self._led is not None: - led_frame = tk.Frame(self, bg=BG2) - led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)); row += 1 - tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).pack(anchor="w") - - color_row = tk.Frame(led_frame, bg=BG2) - color_row.pack(anchor="w", pady=4) - self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4) - self._swatch.pack(side="left") - tk.Button(color_row, text="Farbe wählen...", command=self._pick_color, bg=BG, - fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) - - anim_row = tk.Frame(led_frame, bg=BG2) - anim_row.pack(anchor="w", pady=4) - tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()), - width=12, state="readonly").pack(side="left", padx=6) - tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left", padx=(12, 0)) - tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var, - width=7).pack(side="left", padx=6) - - btns = tk.Frame(self, bg=BG2) - btns.grid(row=row, column=0, columnspan=3, pady=14) - tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff", - activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) - tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG, - fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + row = self._build_led_panel(row) + self._button_row(row=row, columnspan=3) self._on_type_change() + # ── LED ─────────────────────────────────────────────────────────── + + def _build_led_panel(self, row): + led_frame = tk.Frame(self, bg=BG2) + led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)) + tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9, "bold")).pack(anchor="w") + + color_row = tk.Frame(led_frame, bg=BG2) + color_row.pack(anchor="w", pady=4) + self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4) + self._swatch.pack(side="left") + self._hex_label = tk.Label(color_row, text=self._hex(), bg=BG2, fg=TEXT_DIM, + font=("Consolas", 9), width=9) + self._hex_label.pack(side="left", padx=(6, 6)) + for name, rgb in QUICK_COLORS: + chip = tk.Label(color_row, bg="#%02x%02x%02x" % rgb, width=2, height=1, + cursor="hand2", relief="flat", + highlightbackground="#3a3d47", highlightthickness=1) + chip.pack(side="left", padx=1) + chip.bind("", lambda e, c=rgb: self._set_color(c)) + # Tk kennt keine Tooltips -- der Farbname landet in der Zeile darunter. + chip.bind("", lambda e, n=name: self._color_hint.configure(text=n)) + chip.bind("", lambda e: self._color_hint.configure(text="")) + tk.Button(color_row, text="mehr…", command=self._pick_color, bg=BG, fg=TEXT, + activebackground=BG, relief="flat", padx=8).pack(side="left", padx=(8, 0)) + self._color_hint = tk.Label(led_frame, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)) + self._color_hint.pack(anchor="w") + + anim_row = tk.Frame(led_frame, bg=BG2) + anim_row.pack(anchor="w", pady=4) + tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).pack(side="left") + ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()), + width=12, state="readonly").pack(side="left", padx=6) + tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).pack(side="left", padx=(12, 0)) + tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var, + width=7).pack(side="left", padx=6) + return row + 1 + def _hex(self): r, g, b = self._led_color return f"#{r:02x}{g:02x}{b:02x}" + def _set_color(self, rgb): + self._led_color = tuple(rgb) + self._swatch.configure(bg=self._hex()) + self._hex_label.configure(text=self._hex()) + def _pick_color(self): - result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe") + result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe", parent=self) if result and result[0]: - r, g, b = (int(c) for c in result[0]) - self._led_color = (r, g, b) - self._swatch.configure(bg=self._hex()) + self._set_color(tuple(int(c) for c in result[0])) + + # ── Action-Panels ───────────────────────────────────────────────── def _on_type_change(self): for w in self._panel.winfo_children(): @@ -243,83 +513,109 @@ class ActionEditDialog(_ModalDialog): t = self._type_var.get() if t == "HidKey": - row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - self._hidkey_mods = {} - for label, bit in (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08)): - v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit)) - tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG, - activebackground=BG2, activeforeground=TEXT, - font=("Segoe UI", 9)).pack(side="left", padx=(0, 6)) - self._hidkey_mods[bit] = v - row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4) - tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - names = ["(leer)"] + [n for _, n in vp.hid_key_choices()] - ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names, - width=18, state="readonly").pack(side="left", padx=6) - + self._build_hidkey_panel() elif t == "HidConsumer": row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - names = [n for _, n in vp.consumer_choices()] - ttk.Combobox(row1, textvariable=self._consumer_var, values=names, - width=20, state="readonly").pack(side="left", padx=6) - + tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).pack(side="left") + ttk.Combobox(row1, textvariable=self._consumer_var, + values=[n for _, n in vp.consumer_choices()], + width=20, state="readonly").pack(side="left", padx=6) elif t == "Macro": - row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - tk.Label(row1, text="Slot (0-31):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - tk.Spinbox(row1, from_=0, to=31, textvariable=self._macro_slot_var, width=5, - command=self._refresh_macro_preview).pack(side="left", padx=6) - tk.Button(row1, text="Schritte bearbeiten...", command=self._edit_macro_steps, - bg=BG, fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) - row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=(4, 0)) - tk.Label(row2, textvariable=self._macro_preview_var, bg=BG2, fg=TEXT_DIM, - font=("Segoe UI", 8), wraplength=340, justify="left").pack(anchor="w") - self._refresh_macro_preview() - + self._build_macro_panel() elif t == "ProfileSwitch": row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) tk.Label(row1, text="Ziel:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") ttk.Combobox(row1, textvariable=self._profile_switch_var, - values=[n for n, _ in PROFILE_SWITCH_CHOICES], - width=22, state="readonly").pack(side="left", padx=6) + values=[n for n, _ in PROFILE_SWITCH_CHOICES], + width=22, state="readonly").pack(side="left", padx=6) self.update_idletasks() - def _refresh_macro_preview(self): - slot = self._macro_slot_var.get() - steps = self._macros[slot] if 0 <= slot < len(self._macros) else [] - self._macro_preview_var.set(f"Slot {slot}: {vp.macro_slot_label(steps)}") + def _build_hidkey_panel(self): + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + self._hidkey_mods = {} + for label, bit in MOD_CHECKBOXES: + v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit)) + tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG, + activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 6)) + self._hidkey_mods[bit] = v + + row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4) + tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + names = ["(leer)"] + [n for _, n in vp.hid_key_choices()] + ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names, + width=18, state="readonly").pack(side="left", padx=6) + self._capture_label(row2, self._apply_captured_key).pack(side="left", padx=(8, 0)) + + tk.Label(self._panel, text="Erkennung läuft über das Dialogfenster, nicht über einen " + "System-Hook: Win+L o.ä. fängt Windows selbst ab.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w") + + def _apply_captured_key(self, keycode, modifier): + self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)")) + self._hidkey_mods_init = modifier + for bit, var in self._hidkey_mods.items(): + var.set(bool(modifier & bit)) + + def _build_macro_panel(self): + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + tk.Label(row1, text="Slot:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + # Dropdown listet alle 32 Slots MIT Inhalt -- vorher musste man sich + # per Spinbox durch die Tabelle klicken, um ein Makro wiederzufinden. + self._macro_combo = ttk.Combobox(row1, textvariable=self._macro_choice_var, + values=vp.macro_slot_choices(self._macros), + width=52, state="readonly") + self._macro_combo.pack(side="left", padx=6) + self._macro_combo.bind("<>", self._on_macro_slot_selected) + tk.Button(row1, text="Schritte bearbeiten…", command=self._edit_macro_steps, + bg=BG, fg=TEXT, activebackground=BG, relief="flat", + padx=8).pack(side="left", padx=8) + + tk.Label(self._panel, text="Slots sind global (nicht pro Profil) – dasselbe Makro auf " + "zwei Tasten meint denselben Slot.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w", pady=(6, 0)) + self._refresh_macro_choices() + + def _on_macro_slot_selected(self, _event=None): + self._macro_slot = vp.macro_slot_from_choice(self._macro_choice_var.get()) + + def _refresh_macro_choices(self): + choices = vp.macro_slot_choices(self._macros) + self._macro_combo.configure(values=choices) + self._macro_choice_var.set(choices[self._macro_slot]) def _edit_macro_steps(self): - slot = self._macro_slot_var.get() + slot = self._macro_slot current = self._macros[slot] if 0 <= slot < len(self._macros) else [] dlg = MacroStepsDialog(self, current) if dlg.run(): while len(self._macros) <= slot: self._macros.append([]) self._macros[slot] = dlg.steps - self._refresh_macro_preview() + self._refresh_macro_choices() + + # ── Ergebnis ────────────────────────────────────────────────────── def _on_ok(self): t = self._type_var.get() - if t == "None": - action = {"type": "None", "data": 0} - elif t == "HidKey": - name = self._hidkey_key_var.get() - keycode = self._key_code_by_name.get(name, 0) + if t == "HidKey": + keycode = self._key_code_by_name.get(self._hidkey_key_var.get(), 0) modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get()) action = {"type": "HidKey", "data": (modifier << 8) | keycode} elif t == "HidConsumer": action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]} elif t == "Macro": - action = {"type": "Macro", "data": self._macro_slot_var.get()} + action = {"type": "Macro", "data": self._macro_slot} elif t == "ProfileSwitch": name = self._profile_switch_var.get() - data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name) - action = {"type": "ProfileSwitch", "data": data} + action = {"type": "ProfileSwitch", + "data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)} else: action = {"type": "None", "data": 0} + action["note"] = self._note_var.get() self.action = action if self._led is not None: r, g, b = self._led_color diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index 8243798..0accd2c 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -1,5 +1,8 @@ # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis -# nach lokal (C:\Users\\AppData\Local\VersaPadViewer). +# nach %LOCALAPPDATA%\VersaPadViewer (nicht $projectDir\dist wie im +# jappel-Upstream -- hier bereits die tatsaechlich installierte/genutzte +# Instanz, siehe Speicher-Notiz "VersaPad Viewer Tool"; ein Pfadwechsel +# wuerde bestehende Verknuepfungen/Autostart-Eintraege brechen). # # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf # dem Netzlaufwerk (Z:\Git\...): @@ -12,35 +15,106 @@ # SMB-Share, kombiniert mit dem eh schon langen Projektpfad. # # Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach -# das fertige Bundle nach %LOCALAPPDATA% kopieren. Z: wird nur zum Lesen der -# Quelldateien angefasst. +# das fertige Bundle nach $projectDir\dist kopieren. Z: wird nur zum Lesen +# der Quelldateien angefasst. +# +# Fehlerverhalten: Bei Doppelklick im Explorer schliesst sich das Fenster +# sofort nach Skriptende -- ohne Pause waeren Fehlermeldungen unsichtbar +# ("stirbt ohne jede Fehlermeldung"). Deshalb: alles in try/catch, im +# Fehlerfall UND am Ende eine Pause, ausser bei -NoPause (fuer CI/Automation). + +param( + [switch]$NoPause +) $ErrorActionPreference = "Stop" $projectDir = $PSScriptRoot $buildSrc = "$env:TEMP\versapad_build_src" $localDir = "$env:LOCALAPPDATA\VersaPadViewer" +$requirementsFile = "$projectDir\requirements.txt" -if (Test-Path $buildSrc) { - Remove-Item $buildSrc -Recurse -Force +function Assert-LastExitCode([string]$step) { + if ($LASTEXITCODE -ne 0) { + throw "$step ist fehlgeschlagen (Exit-Code $LASTEXITCODE) -- Ausgabe oben pruefen." + } +} + +function Wait-ForKeyIfInteractive { + if ($NoPause) { return } + try { + Read-Host "Taste druecken zum Schliessen" + } catch { + # z.B. nicht-interaktiver Aufruf (stdin nicht verfuegbar) -- einfach ignorieren + } } -New-Item -ItemType Directory -Path $buildSrc | Out-Null -Copy-Item "$projectDir\*.py" -Destination $buildSrc -Copy-Item "$projectDir\icon.ico" -Destination $buildSrc -Copy-Item "$projectDir\icon.png" -Destination $buildSrc -Push-Location $buildSrc try { - py -m PyInstaller --windowed --onedir --name VersaPadViewer ` - --icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` - desktop_viewer.py --noconfirm -} finally { - Pop-Location -} + Write-Host "Pruefe Python-Installation..." + py --version + Assert-LastExitCode "'py --version' (ist Python installiert und im PATH?)" -if (Test-Path $localDir) { - Remove-Item $localDir -Recurse -Force -} -Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse -Remove-Item $buildSrc -Recurse -Force + if (-not (Test-Path $requirementsFile)) { + throw "requirements.txt nicht gefunden unter $requirementsFile" + } + Write-Host "Installiere/aktualisiere benoetigte Pakete aus requirements.txt..." + py -m pip install --quiet --disable-pip-version-check -r $requirementsFile + Assert-LastExitCode "Paketinstallation (pip install -r requirements.txt)" -Write-Host "Fertig: $localDir\VersaPadViewer.exe" + if (Test-Path $buildSrc) { + Remove-Item $buildSrc -Recurse -Force + } + New-Item -ItemType Directory -Path $buildSrc | Out-Null + Copy-Item "$projectDir\*.py" -Destination $buildSrc + Copy-Item "$projectDir\icon.ico" -Destination $buildSrc + Copy-Item "$projectDir\icon.png" -Destination $buildSrc + + Push-Location $buildSrc + try { + Write-Host "Baue mit PyInstaller (--onedir --windowed)..." + py -m PyInstaller --windowed --onedir --name VersaPadViewer ` + --icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` + desktop_viewer.py --noconfirm + Assert-LastExitCode "PyInstaller-Build" + } finally { + Pop-Location + } + + if (-not (Test-Path "$buildSrc\dist\VersaPadViewer\VersaPadViewer.exe")) { + throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen." + } + + # Zielverzeichnis abraeumen, aber NIE Nutzerdaten mitloeschen: die Config + # liegt zwar inzwischen woanders (Roaming-AppData, siehe + # versapad_data.app_dir()), aeltere Installationen haben sie aber noch + # hier liegen -- ohne diese Sicherung loescht jeder Rebuild sie mit + # (am 2026-08-15 genau so passiert, alle Notizen weg). + $userData = Get-ChildItem $localDir -Filter "versapad_config*.json" -File -ErrorAction SilentlyContinue + $rescued = @() + foreach ($f in $userData) { + $tmp = Join-Path $env:TEMP $f.Name + Copy-Item $f.FullName $tmp -Force + $rescued += @{ Tmp = $tmp; Name = $f.Name } + Write-Host "Nutzerdatei gesichert: $($f.Name)" -ForegroundColor Yellow + } + + if (Test-Path $localDir) { + Remove-Item $localDir -Recurse -Force + } + Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse + + foreach ($r in $rescued) { + Copy-Item $r.Tmp (Join-Path $localDir $r.Name) -Force + Remove-Item $r.Tmp -Force + } + Remove-Item $buildSrc -Recurse -Force + + Write-Host "" + Write-Host "Fertig: $localDir\VersaPadViewer.exe" -ForegroundColor Green + Wait-ForKeyIfInteractive +} +catch { + Write-Host "" + Write-Host "FEHLER: $($_.Exception.Message)" -ForegroundColor Red + Wait-ForKeyIfInteractive + exit 1 +} diff --git a/desktop_viewer.py b/desktop_viewer.py index 1b80b6d..6909ed5 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -19,6 +19,7 @@ automatisch ausgeschaltet, damit sich beide nicht um den Port streiten). Start: python desktop_viewer.py """ +import copy import os import queue import sys @@ -54,7 +55,11 @@ OK_GREEN = "#3ecf6e" WARN_RED = "#e0895a" POLL_MS = 1500 -CARD_W, CARD_H = 150, 84 +CARD_W, CARD_H = 150, 114 +# Untergrenze fuers Verkleinern (self.minsize) -- knapp unter der Groesse, +# die das 4x5-Grid + Encoder mindestens brauchen, damit nichts +# abgeschnitten wird. +MIN_W, MIN_H = 700, 500 SERIAL_POLL_S = 1.5 SERIAL_IDLE_S = 3.0 @@ -97,9 +102,11 @@ Buttons setzen (profile 0-2, index 0-19): set_button_profile_switch(profile, index, target) set_button_none(profile, index) set_button_led(profile, index, r, g, b, anim, period_ms) + set_button_note(profile, index, note) freie Notiz, was der Button tut Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'): set_encoder_key / _consumer / _macro / _profile_switch / _none(...) + set_encoder_note(profile, index, field, note) Makro: set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...] @@ -122,13 +129,23 @@ class VersaPadViewer(tk.Tk): super().__init__() self.title("VersaPad Steuermatrix") self.configure(bg=BG) - self.geometry("760x760") + # Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile + # fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114) + # reichten die alten 760px nicht mehr: der Encoder-Bereich lag + # unterhalb des Fensterrands. Wer CARD_H aendert, muss hier mit. + self.geometry("790x960") self.profile = 0 self._mtimes = {} self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt self._link = vs.VersaPadLink() self._closing = False + # Zwischenablage fuer "Belegung/Farbe auf andere Taste uebertragen" + # (Rechtsklickmenue bzw. Strg+C/V auf der Karte unter dem Mauszeiger). + # Bewusst NUR im Speicher, nicht die System-Zwischenablage: hier + # liegen Action-Dicts, kein Text. + self._clip = None + self._hover = None self.live_sync = tk.BooleanVar(value=False) self.editing = tk.BooleanVar(value=False) self._serial_results = queue.Queue() @@ -149,55 +166,57 @@ class VersaPadViewer(tk.Tk): ) self._tray_icon.run_detached() - header = tk.Frame(self, bg=BG) - header.pack(fill="x", padx=20, pady=(18, 4)) - tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT, - font=("Segoe UI", 15, "bold")).pack(anchor="w") - self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG, - fg=TEXT_DIM, font=("Segoe UI", 9)) - self.header_sub.pack(anchor="w") + # Normales Fenster MIT Windows-Titelleiste. Bis 2026-08-28 lief es + # randlos (`overrideredirect(True)`) -- das kostet unter Windows aber + # zwingend den Taskleisten-Eintrag, und der wurde ausdruecklich + # gebraucht. Nachruesten liesse er sich nur per `SetWindowLongW` + # (WS_EX_APPWINDOW) -- genau der Aufruf, der laut Speicher-Notiz + # "keine Selbstversteck-Fenstertricks" schon AV-Fehlalarme + # ausgeloest hat und deshalb nicht in Frage kommt. Mit der echten + # Titelleiste kommen Taskleiste, Alt+Tab, Aero-Snap, Ziehen und + # Groessenaendern am Rahmen nativ zurueck; die Eigenbau-Loesungen + # dafuer (ziehbare Kopfzeile, Anfasser unten rechts, eigene + # ✕/—-Knoepfe) sind damit ersatzlos entfallen. + self.minsize(MIN_W, MIN_H) - info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14), - cursor="hand2") - info_btn.place(relx=1.0, x=0, y=-4, anchor="ne") + self.toggles_row = header = tk.Frame(self, bg=BG) + header.pack(fill="x", padx=20, pady=(10, 6)) + tk.Label(header, text="VersaPad", bg=BG, fg=TEXT, + font=("Segoe UI", 11, "bold")).pack(side="left", anchor="w", padx=(0, 16)) + + info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12), + cursor="hand2", padx=6) + info_btn.pack(side="right", padx=(0, 8)) info_btn.bind("", lambda e: self._show_mcp_info()) info_btn.bind("", lambda e: info_btn.configure(fg=ACCENT)) info_btn.bind("", lambda e: info_btn.configure(fg=TEXT_DIM)) - self.tabs = tk.Frame(self, bg=BG) - self.tabs.pack(fill="x", padx=20, pady=(12, 10)) - self.tab_buttons = {} - for p in sorted(vp.PROFILE_NAMES): - btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT, - font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2") - btn.pack(side="left", padx=(0, 8)) - btn.bind("", lambda e, prof=p: self.set_profile(prof, manual=True)) - btn.bind("", lambda e, prof=p: self._rename_tab(prof)) - self.tab_buttons[p] = btn - - toggles_row = tk.Frame(self, bg=BG) - toggles_row.pack(fill="x", padx=20, pady=(0, 6)) + # Modus-Umschalter direkt neben der Ueberschrift statt in eigener + # Zeile -- spart eine komplette Zeile Fensterhoehe. self.sync_check = tk.Checkbutton( - toggles_row, text="Live-Sync mit Board", variable=self.live_sync, + header, text="Live-Sync", variable=self.live_sync, command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG, - activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) + activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8)) self.sync_check.pack(side="left") - self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) - self.sync_status.pack(side="left", padx=(8, 20)) + self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) + self.sync_status.pack(side="left", padx=(4, 14)) self.edit_check = tk.Checkbutton( - toggles_row, text="Programmiermodus", variable=self.editing, + header, text="Programmiermodus", variable=self.editing, command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG, - activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) - self.edit_check.pack(side="left", padx=(0, 20)) + activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8)) + self.edit_check.pack(side="left", padx=(0, 14)) self.always_on_top = tk.BooleanVar(value=False) self.topmost_check = tk.Checkbutton( - toggles_row, text="Immer im Vordergrund", variable=self.always_on_top, + header, text="Immer im Vordergrund", variable=self.always_on_top, command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG, - activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) + activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8)) self.topmost_check.pack(side="left") + # Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im + # Programmiermodus (siehe _on_toggle_editing), direkt unter den + # Checkboxen statt weit unten zwischen Tabs und Matrix. self.prog_row = tk.Frame(self, bg=BG) for text, cmd in ( ("Vom Board laden", self._load_from_board), @@ -207,24 +226,49 @@ class VersaPadViewer(tk.Tk): ): tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT, activebackground=ACCENT, activeforeground="#fff", relief="flat", - padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) - self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) + padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6)) + self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) self.prog_status.pack(side="left", padx=(8, 0)) + # Kurz halten: die Toolbar ist bei der Standardbreite (790px) schon + # fast voll, laengerer Text wird rechts abgeschnitten. + tk.Label(self.prog_row, text="Rechtsklick = kopieren/einfügen", + bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="right") # prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing) + # Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am + # Fensterkopf -- naeher an dem, was sie auswaehlen. + self.tabs = tk.Frame(self, bg=BG) + self.tabs.pack(fill="x", padx=20, pady=(8, 8)) + self.tab_buttons = {} + for p in range(vp.NUM_PROFILES): + btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT, + font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2") + btn.pack(side="left", padx=(0, 8)) + btn.bind("", lambda e, prof=p: self.set_profile(prof, manual=True)) + btn.bind("", lambda e, prof=p: self._rename_tab(prof)) + self.tab_buttons[p] = btn + self.grid_frame = tk.Frame(self, bg=BG) - self.grid_frame.pack(padx=20, pady=(8, 0)) + self.grid_frame.pack(padx=20, pady=(0, 0), anchor="w") tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8)) self.enc_frame = tk.Frame(self, bg=BG) self.enc_frame.pack(fill="x", padx=20) - self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) - self.footer.pack(anchor="w", padx=20, pady=(20, 10)) + footer_row = tk.Frame(self, bg=BG) + footer_row.pack(fill="x", padx=20, pady=(14, 8)) + self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) + self.footer.pack(side="left", anchor="w") + # Schliessen legt weiterhin ins Tray statt zu beenden (wie die + # offizielle VersaGUI -- das Programm soll im Hintergrund + # weiterlaufen). Minimieren geht jetzt aber ganz normal in die + # Taskleiste; frueher hat ein -Handler auch das ins Tray + # umgeleitet, was ohne Taskleisten-Eintrag sinnvoll war und mit + # einem das Fenster nur unauffindbar machen wuerde. self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) - self.bind("", self._on_unmap) + self._bind_shortcuts() self._serial_thread.start() self.set_profile(0) @@ -242,21 +286,49 @@ class VersaPadViewer(tk.Tk): self._render() def _update_tab_labels(self): + """Profilnamen kommen immer aus der kombinierten JSON (nicht mehr + hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst + per schlankem Datei-Read (kein Board-Zugriff, siehe + versapad_combined.profile_names()).""" + if self.editing.get() and self.combined: + names = self.combined["profile_names"] + else: + names = vcomb.read_profile_names() for p, btn in self.tab_buttons.items(): - if self.editing.get() and self.combined: - btn.configure(text=self.combined["profile_names"][p]) - else: - btn.configure(text=vp.PROFILE_NAMES[p]) + btn.configure(text=names[p]) def _rename_tab(self, profile): - if not self.editing.get() or self.combined is None: - return - current = self.combined["profile_names"][profile] + """Profilname per Doppelklick auf den Tab umbenennen -- geht in + jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische + Domänenregeln). Im Programmiermodus wird der bereits geladene + In-Memory-State direkt bearbeitet + autosaved; sonst wird die + kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf + automatisch an, siehe versapad_combined.load_or_fetch()).""" + if self.editing.get() and self.combined is not None: + combined = self.combined + else: + try: + combined = vcomb.load_or_fetch() + except (OSError, ValueError, KeyError) as e: + messagebox.showerror("Umbenennen fehlgeschlagen", str(e)) + return + + current = combined["profile_names"][profile] name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):", initialvalue=current, parent=self) - if name: - self.combined["profile_names"][profile] = name - self._update_tab_labels() + if not name: + return + combined["profile_names"][profile] = name + + if combined is self.combined: + self._autosave_combined() + else: + try: + vcomb.save_file(combined, vcomb.DEFAULT_PATH) + except OSError as e: + messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}") + return + self._update_tab_labels() def _show_mcp_info(self): win = tk.Toplevel(self) @@ -342,13 +414,7 @@ class VersaPadViewer(tk.Tk): text = SERIAL_STATUS_TEXT.get(error, error or "Fehler") self.sync_status.configure(text=text, fg=WARN_RED) - # ── Tray-Icon (kein Taskleisten-Eintrag beim Minimieren, wie VersaGUI) ── - - def _on_unmap(self, event): - """Minimieren faengt Windows normalerweise als Taskleisten-Icon ab -- - wir wollen stattdessen: Fenster komplett weg, nur noch Tray-Icon.""" - if event.widget is self and self.state() == "iconic": - self._hide_to_tray() + # ── Tray-Icon (Schliessen beendet nicht, wie bei der VersaGUI) ── def _hide_to_tray(self): self.withdraw() @@ -391,13 +457,17 @@ class VersaPadViewer(tk.Tk): self._on_toggle_sync() self.sync_check.configure(state="disabled") if self.combined is None: - self.combined = vcomb.default_combined() - self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs) - self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten") + if os.path.exists(vcomb.DEFAULT_PATH): + try: + self.combined = vcomb.load_file(vcomb.DEFAULT_PATH) + except (OSError, ValueError, KeyError): + self.combined = vcomb.default_combined() + else: + self.combined = vcomb.default_combined() + self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row) else: self.sync_check.configure(state="normal") self.prog_row.pack_forget() - self.header_sub.configure(text="pollt Config-JSONs alle 1.5s") self._update_tab_labels() self._render() @@ -426,7 +496,9 @@ class VersaPadViewer(tk.Tk): macro_slots = vproto.unpack_macros(raw_macros) names = self.combined["profile_names"] if self.combined else None - self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) + previous = self.combined + self.combined = vcomb.merge_notes( + vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous) self.profile = self.combined["active_profile"] self._status("vom Board geladen", True) self._update_tab_labels() @@ -513,6 +585,183 @@ class VersaPadViewer(tk.Tk): except OSError as e: self._status(f"Auto-Speichern fehlgeschlagen: {e}", False) + # ── Kopieren/Einfuegen + Tastenkuerzel ───────────────────────── + + def _bind_shortcuts(self): + """Fensterweite Kuerzel. Waehrend ein Bearbeiten-Dialog offen ist, + haelt dessen grab_set() die Tastatur -- die Kuerzel hier koennen ihm + also nicht dazwischenfunken.""" + for seq, handler in ( + # Minimieren statt ins Tray: seit das Fenster eine Titelleiste + # und damit einen Taskleisten-Eintrag hat, waere "verschwindet + # spurlos" die unangenehmere Ueberraschung. Ins Tray legt + # weiterhin das ✕ der Titelleiste. + ("", lambda e: self.iconify()), + ("", lambda e: self._rename_tab(self.profile)), + ("", lambda e: self._toggle_editing_shortcut()), + ("", lambda e: self._toggle_editing_shortcut()), + ("", lambda e: self._copy_hovered()), + ("", lambda e: self._copy_hovered()), + ("", lambda e: self._paste_hovered()), + ("", lambda e: self._paste_hovered()), + ): + self.bind(seq, handler) + for p in range(vp.NUM_PROFILES): + self.bind(f"", + lambda e, prof=p: self.set_profile(prof, manual=True)) + + def _toggle_editing_shortcut(self): + self.editing.set(not self.editing.get()) + self._on_toggle_editing() + + def _hint(self, text): + """Kurze Rueckmeldung in der Fusszeile -- die Programmiermodus- + Statuszeile haengt an der Toolbar und ist sonst leicht zu uebersehen. + Der naechste _render() setzt die Quellenangabe wieder ein.""" + self.footer.configure(text=text) + + def _target_entry(self, target): + """Liefert den Eintrag im combined-State, auf den ein Ziel zeigt -- + Button-Karte oder Encoder (dort waehlt target["field"] danach noch + sw/cw/ccw aus).""" + profile = self.combined["profiles"][self.profile] + items = profile["buttons"] if target["kind"] == "button" else profile["encoders"] + return next(item for item in items if item["index"] == target["index"]) + + def _set_hover(self, target): + self._hover = target + + def _clear_hover(self, target): + """ feuert auch beim Wechsel auf ein Kindwidget derselben + Karte -- deshalb erst pruefen, ob der Zeiger die Karte wirklich + verlassen hat.""" + card = target["card"] + if not card.winfo_exists(): + self._hover = None + return + x, y = card.winfo_pointerxy() + inside = (card.winfo_rootx() <= x < card.winfo_rootx() + card.winfo_width() + and card.winfo_rooty() <= y < card.winfo_rooty() + card.winfo_height()) + if not inside and self._hover is target: + self._hover = None + + def _copy_hovered(self): + if self._hover is not None: + self._copy_target(self._hover, "all") + + def _paste_hovered(self): + if self._hover is not None: + self._paste_target(self._hover, "all") + + def _copy_target(self, target, what): + if not self.editing.get() or self.combined is None: + return + entry = self._target_entry(target) + if target["kind"] == "button": + action, led = entry["action"], entry["led"] + else: + action, led = entry[target["field"]], None + self._clip = { + "action": copy.deepcopy(action) if what in ("all", "action") else None, + "led": copy.deepcopy(led) if what in ("all", "led") else None, + } + parts = [name for name, key in (("Belegung", "action"), ("Farbe", "led")) + if self._clip[key] is not None] + self._hint("kopiert: {} von {}".format(" + ".join(parts) or "nichts", target["label"])) + + def _paste_target(self, target, what): + if not self.editing.get() or self.combined is None or not self._clip: + return + entry = self._target_entry(target) + applied = [] + if what in ("all", "action") and self._clip["action"] is not None: + action = copy.deepcopy(self._clip["action"]) + if target["kind"] == "button": + entry["action"] = action + else: + entry[target["field"]] = action + applied.append("Belegung") + if what in ("all", "led") and self._clip["led"] is not None and target["kind"] == "button": + entry["led"] = copy.deepcopy(self._clip["led"]) + applied.append("Farbe") + if not applied: + self._hint("nichts eingefügt -- Zwischenablage passt nicht zu diesem Ziel") + return + self._autosave_combined() + self._render() + self._hint("eingefügt: {} → {}".format(" + ".join(applied), target["label"])) + + def _clear_target(self, target): + if not self.editing.get() or self.combined is None: + return + entry = self._target_entry(target) + # Notiz bleibt bewusst erhalten (gleiche Regel wie beim Typwechsel im + # Dialog): sie beschreibt die Taste, nicht die konkrete Aktion. + if target["kind"] == "button": + entry["action"] = {"type": "None", "data": 0, + "note": entry["action"].get("note", "")} + else: + old = entry[target["field"]] + entry[target["field"]] = {"type": "None", "data": 0, "note": old.get("note", "")} + self._autosave_combined() + self._render() + self._hint("geleert: {}".format(target["label"])) + + def _show_context_menu(self, event, target): + menu = tk.Menu(self, tearoff=0, bg=CARD_BG, fg=TEXT, activebackground=ACCENT, + activeforeground="#ffffff", bd=0, font=("Segoe UI", 9)) + is_button = target["kind"] == "button" + has_action = bool(self._clip and self._clip.get("action")) + has_led = bool(self._clip and self._clip.get("led")) + + menu.add_command(label="Bearbeiten…", command=lambda: self._edit_target(target)) + menu.add_separator() + if is_button: + menu.add_command(label="Kopieren: Belegung + Farbe (Strg+C)", + command=lambda: self._copy_target(target, "all")) + menu.add_command(label="Kopieren: nur Belegung", + command=lambda: self._copy_target(target, "action")) + menu.add_command(label="Kopieren: nur Farbe", + command=lambda: self._copy_target(target, "led")) + else: + menu.add_command(label="Kopieren: Belegung (Strg+C)", + command=lambda: self._copy_target(target, "action")) + menu.add_separator() + paste_all = has_action or (has_led and is_button) + menu.add_command(label="Einfügen: alles (Strg+V)", + state="normal" if paste_all else "disabled", + command=lambda: self._paste_target(target, "all")) + menu.add_command(label="Einfügen: nur Belegung", + state="normal" if has_action else "disabled", + command=lambda: self._paste_target(target, "action")) + if is_button: + menu.add_command(label="Einfügen: nur Farbe", + state="normal" if has_led else "disabled", + command=lambda: self._paste_target(target, "led")) + menu.add_separator() + menu.add_command(label="Leeren (Belegung entfernen)", + command=lambda: self._clear_target(target)) + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _edit_target(self, target): + if target["kind"] == "button": + self._edit_button(target["index"]) + else: + self._edit_encoder_action(target["index"], target["field"], target["field_label"]) + + def _bind_cell_interaction(self, card, widgets, target): + """Linksklick = bearbeiten, Rechtsklick = Kontextmenue; der + Mauszeiger merkt sich das Ziel fuer Strg+C/Strg+V.""" + card.configure(cursor="hand2") + for widget in widgets: + widget.bind("", lambda e: self._edit_target(target)) + widget.bind("", lambda e: self._show_context_menu(e, target)) + widget.bind("", lambda e: self._set_hover(target)) + card.bind("", lambda e: self._clear_hover(target)) + # ── Rendering ──────────────────────────────────────────────────── def _current_profile_view(self): @@ -520,22 +769,36 @@ class VersaPadViewer(tk.Tk): Bevorzugt die kombinierte Datei (versapad_config_all.json), falls vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen. - Faellt zurueck auf die klassischen versapad_config{1,2,3}.json, wenn - es noch keine kombinierte Datei gibt.""" - if os.path.exists(vcomb.DEFAULT_PATH): + Fehlt sie (z.B. versehentlich geloescht, oder frische Installation + ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird + sie automatisch angelegt -- per Serial vom Board, oder als leere + Default-Config, wenn auch kein Board erreichbar ist (siehe + versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz + ohne vorhandene Config sofort benutzbar, kein Datei-Handling von + Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den + COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die + klassischen versapad_config{1,2,3}.json (Export der offiziellen + VersaGUI) aus.""" + if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get(): try: - data = vcomb.load_file(vcomb.DEFAULT_PATH) + data = vcomb.load_or_fetch(link=self._link) raw = data["profiles"][self.profile] cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], - }) + }, data.get("macros")) return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" except (KeyError, IndexError, ValueError): - pass # kaputte/unvollstaendige Datei -- auf Einzel-JSONs ausweichen - return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}" + pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen + try: + return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}" + except FileNotFoundError as e: + hint = (" (Live-Sync ist an -- COM-Port belegt, zum automatischen " + "Neuladen vom Board erst ausschalten)") if self.live_sync.get() else "" + raise FileNotFoundError(f"{e}{hint}") from e def _render(self): + self._update_tab_labels() for w in self.grid_frame.winfo_children(): w.destroy() for w in self.enc_frame.winfo_children(): @@ -549,7 +812,7 @@ class VersaPadViewer(tk.Tk): cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], - }) + }, self.combined.get("macros")) source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert" else: try: @@ -591,19 +854,23 @@ class VersaPadViewer(tk.Tk): tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT, font=("Segoe UI", 10, "normal" if empty else "bold"), wraplength=CARD_W - 20, justify="left", anchor="nw").place( - x=10, y=26, width=CARD_W - 20, height=36) + x=10, y=24, width=CARD_W - 20, height=28) + + # Notiz bekommt den ganzen Rest der Karte (bis zur Animationszeile) -- + # bei 34px wurden laengere Notizen mitten im Wort abgeschnitten. + tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM, + font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place( + x=10, y=53, width=CARD_W - 20, height=CARD_H - 69) anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"]) tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7), anchor="sw").place( - x=10, y=CARD_H - 20, width=CARD_W - 20, height=14) + x=10, y=CARD_H - 16, width=CARD_W - 20, height=13) if editable: - card.configure(cursor="hand2") - handler = lambda e, idx=btn["index"]: self._edit_button(idx) - card.bind("", handler) - for child in card.winfo_children(): - child.bind("", handler) + target = {"kind": "button", "index": btn["index"], "card": card, + "label": "Button #{}".format(btn["index"])} + self._bind_cell_interaction(card, [card] + list(card.winfo_children()), target) def _render_encoder(self, enc, editable=False): card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER, @@ -613,18 +880,31 @@ class VersaPadViewer(tk.Tk): inner.pack(fill="both", expand=True, padx=10, pady=8) tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4)) - for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]), - ("CCW", "ccw", enc["ccw_label"])): + for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]), + ("CW", "cw", enc["cw_label"], enc["cw_note"]), + ("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])): row = tk.Frame(inner, bg=CARD_BG) - row.pack(fill="x", pady=1) - tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left") - tk.Label(row, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right") + row.pack(fill="x", pady=(1, 4)) + top = tk.Frame(row, bg=CARD_BG) + top.pack(fill="x") + tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left") + tk.Label(top, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right") + # Notizzeile nur anlegen, wenn es wirklich eine Notiz gibt -- + # ein leeres Label belegt sonst pro Encoder-Aktion eine Zeile + # Hoehe (12x im Fenster) und schiebt die Fusszeile aus dem Bild. + note_label = None + if note: + note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7), + wraplength=150, justify="left", anchor="w") + note_label.pack(fill="x") if editable: - row.configure(cursor="hand2") - handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl) - row.bind("", handler) - for child in row.winfo_children(): - child.bind("", handler) + target = {"kind": "encoder", "index": enc["index"], "field": field, + "field_label": key, "card": row, + "label": "Encoder {} {}".format(enc["index"], key)} + widgets = [row, top] + list(top.winfo_children()) + if note_label is not None: + widgets.append(note_label) + self._bind_cell_interaction(row, widgets, target) if __name__ == "__main__": diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..66424cd --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,267 @@ +# Architektur + +Überblick über die Schichten, Prozesse und Datenflüsse von VersaPad Viewer. +Für Domänenregeln, bekannte Bugs und Implementierungsdisziplin siehe +[`AGENTS.md`](../AGENTS.md); für Installation/Nutzung siehe +[`README.md`](../README.md). Für die genauen Datenformate siehe +[`data-model.md`](data-model.md), für das Serial-Wire-Protokoll +[`protocol.md`](protocol.md). + +## Ziel und Kontext + +VersaPad Viewer ist ein eigenständiges Python-Tool für das VersaPad-Makropad +(4×5-Button-Grid + 4 Encoder, SAMD21-Firmware). Es ergänzt die offizielle +VersaGUI (C#/.NET) um eine schlankere, plattformunabhängigere Alternative +zum Anzeigen, Live-Synchronisieren und Neuprogrammieren der Belegung, plus +einen MCP-Server, der dieselbe Programmierung KI-gesteuert per Tool-Aufruf +erlaubt. Beide GUIs (die offizielle VersaGUI und dieses Tool) konkurrieren +um denselben exklusiven USB-CDC-Port — siehe „Nebenläufigkeit" unten. + +## Schichtenmodell + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontends │ +│ ┌────────────┐ ┌──────────────────┐ ┌────────────────────┐ │ +│ │ server.py │ │ desktop_viewer.py │ │ versapad_mcp_ │ │ +│ │ (Browser, │ │ (Tkinter, Tray, │ │ server.py │ │ +│ │ read-only)│ │ Live-Sync, Edit) │ │ (KI-Tool-Aufrufe) │ │ +│ └─────┬──────┘ └────────┬──────────┘ └─────────┬──────────┘ │ +│ │ │ │ │ +│ └───────────┬───────┴──────────────┬───────────┘ │ +│ ▼ ▼ │ +│ versapad_combined.py versapad_data.py │ +│ (Ein-Datei-Format, (Decoding fürs Anzeigen, │ +│ Board-Sync, Auto- Legacy-Einzel-JSONs) │ +│ Create) │ +│ │ │ +│ ▼ │ +│ versapad_protocol.py (pack/unpack, CRC16) │ +│ │ │ +│ ▼ │ +│ versapad_serial.py (VersaPadLink, 8-Byte-Pakete) │ +│ │ │ +└─────────────────────┼──────────────────────────────────────────────┘ + ▼ + VersaPad-Board (USB-CDC, VID:PID 239A:0042) +``` + +`action_dialog.py` ist ein UI-Hilfsmodul von `desktop_viewer.py` +(Bearbeiten-Dialoge für den Programmiermodus) und taucht oben nicht separat +auf. + +### Read-only-Schicht + +- **`versapad_data.py`** — decodiert JSON-Rohdaten (Keycodes, Consumer-IDs, + Modifier-Bits) zu lesbarem Text, kennt die Grid-Geometrie + (`index = spalte*5 + reihe`). Liest wahlweise: + - die klassischen `versapad_config1/2/3.json` (`CONFIG_PATHS`) — Export + der offiziellen VersaGUI, kein von diesem Tool geschriebenes Format; + - oder (über `versapad_combined.py`) die kombinierte Datei. + - `app_dir()` liefert das Basisverzeichnis für die eigene Config, siehe + „Config-Speicherort" unten. +- **`server.py`** — generiert bei jedem HTTP-Request frisch HTML aus dem + aktuellen Zustand (`vcomb.load_or_fetch()`), Auto-Reload alle 4s per + ``. Rein lesend, kein eigener Zustand zwischen + Requests, daher nie „veraltet" im Sinne von In-Memory-Staleness. + +### Binär-/Serial-Schicht + +- **`versapad_protocol.py`** — pack/unpack für `SDeviceConfig` (740B, alle + 3 Profile) und `SMacroTable` (512B, 32 Slots), plus CRC16. 1:1 aus den + Firmware-Structs übernommen, siehe [`data-model.md`](data-model.md). +- **`versapad_serial.py`** — `VersaPadLink`: öffnet bei Bedarf den COM-Port + (per VID/PID-Erkennung), spricht das 8-Byte-Paket-Protokoll, siehe + [`protocol.md`](protocol.md). **Schließt die Verbindung nicht von selbst** + nach einem Befehl — Aufrufer müssen das selbst tun, wenn sie den Port + nicht dauerhaft blockieren wollen (siehe „Nebenläufigkeit" unten). +- **`versapad_combined.py`** — das Ein-Datei-Format: alle 3 Profile + + Makro-Tabelle + lokale Profilnamen in einer JSON + (`versapad_config_all.json`). Bindeglied zwischen den JSON-Strukturen und + den Binärblobs aus `versapad_protocol.py`. Zentrale Funktionen: + - `load_or_fetch()` — bevorzugt die lokale Datei, baut sie bei Bedarf + automatisch neu auf (erst Board-Versuch, sonst leere Default-Config). + - `save_file()`/`load_file()` — reines Lesen/Schreiben der JSON. + - `fetch_from_board()`/`to_binary()` — Konvertierung zu/von den + Binärblobs für Board-Lese-/Schreibvorgänge. + - `read_profile_names()` — liest nur die Profilnamen, ohne Board-Zugriff + (für Tab-Beschriftungen im Nur-Lese-Modus). + +### UI + +- **`desktop_viewer.py`** — Tkinter-Fenster mit drei unabhängig + umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit + MCP-Doku. +- **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`, + `MacroStepsDialog`) für den Programmiermodus, auf gemeinsamer Basis + `_ModalDialog`: + - positioniert sich beim Öffnen mittig über dem aufrufenden Fenster + (Aufbau `withdraw()`n, `_center_on_parent()`, dann `deiconify()`); + - bindet ``/`` auf dem Toplevel und verteilt sie: + entweder an eine laufende Tastendruck-Aufnahme, sonst als + Enter = OK / Escape = Abbrechen; + - `_KeyCapture` schaltet ein Label in den Aufnahmemodus und schickt jeden + Tastendruck durch `versapad_data.tk_event_to_hid()` — reine + Tk-Fenster-Events, **kein globaler Tastaturhook** (siehe „Grenzen der + Tastendruck-Erkennung" unten). + +### MCP-Server + +- **`versapad_mcp_server.py`** — registriert als projektgebundener + MCP-Server "versapad" (`.mcp.json`) oder wahlweise user-scope + (`claude mcp add -s user versapad -- versapad_mcp_server.py`). + Hält einen eigenen In-Memory-Zustand (`_state["combined"]`, + unabhängig von jeder laufenden GUI), der explizit per `save_local()` / + `load_local()` mit der Datei bzw. `load_from_board()` / `write_to_board()` + mit dem Board synchronisiert wird. Board-Serial-Tools schließen die + Verbindung nach jedem Aufruf wieder (siehe unten). + +## Config-Speicherort + +`versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene +Config-Datei (`versapad_combined.DEFAULT_PATH` = +`app_dir()/versapad_config_all.json`): `%APPDATA%\VersaPadViewer` +(Roaming-AppData), unabhängig davon, ob die gebaute `.exe` oder der +Quellcode gestartet wurde (kein `sys.frozen`-Zweig). + +Bewusst **nicht** das Installationsverzeichnis (`%LOCALAPPDATA%\ +VersaPadViewer`, wo die `.exe` liegt): `build_and_deploy.ps1` räumt das +Zielverzeichnis vor jedem Deploy komplett ab, läge die Config dort, würde +jeder Rebuild sie mitlöschen (genau das ist am 2026-08-15 passiert, siehe +`AGENTS.md`). Ebenso bewusst **nicht** vom Startweg abhängig — sonst sähe +die `.exe` eine andere Datei als ein aus dem Quellcode gestarteter +`versapad_mcp_server.py`, und Änderungen aus dem einen Weg wären im +anderen unsichtbar (ebenfalls am 2026-08-15 beobachtet). + +Fehlt die Datei, legt `load_or_fetch()` sie automatisch an — zuerst per +Serial-Versuch vom Board (das ist die eigentliche Quelle der Wahrheit, die +Datei nur ein Lesecache dafür), sonst als leere Default-Config +(`default_combined()`). Das Tool ist damit auch ganz ohne vorhandene +Config oder angeschlossenes Board sofort benutzbar. + +Die klassischen `versapad_config1/2/3.json` (`versapad_data.CONFIG_PATHS`) +bleiben bewusst getrennt hartkodiert auf `~\OneDrive\Desktop` — das ist +optionale Lese-Interop mit einem JSON-Export der offiziellen VersaGUI, +kein von diesem Tool selbst gepflegtes Format, und daher nicht Teil der +„portablen Installation". + +## Modi in `desktop_viewer.py` + +Drei Checkboxen, unabhängig voneinander: + +| Modus | Zweck | Zustand | +|---|---|---| +| Nur-Lesen (Default) | Zeigt das aktuelle Profil an | Liest bei jedem Poll (alle 1,5s) frisch über `_current_profile_view()` → `vcomb.load_or_fetch()`. Kein eigener In-Memory-Snapshot, daher nie veraltet. | +| Live-Sync | Fragt per Serial das aktuell aktive Profil ab, schaltet die Ansicht mit | Hintergrund-Thread pollt `read_active_profile()`, hält dafür den COM-Port dauerhaft offen, solange die Checkbox an ist. Schließt sich mit VersaGUI/Programmiermodus/MCP-Board-Zugriff gegenseitig aus (exklusiver Port). | +| Programmiermodus | Zellen anklicken zum Bearbeiten | Lädt `self.combined` **einmalig pro Prozesslauf** beim ersten Aktivieren (bevorzugt `DEFAULT_PATH`, sonst `default_combined()`). Jede Bearbeitung speichert sofort automatisch (`_autosave_combined()`). **Achtung:** Da der Snapshot nur einmal geladen wird, sieht der Programmiermodus externe Änderungen (z.B. per MCP) erst nach einem Neustart der exe oder einem expliziten „Datei laden…“. | + +### Tastendruck-Erkennung: Position statt Zeichen + +Die Erkennung im Bearbeiten-Dialog nutzt ausschließlich Tk-Events des +fokussierten Fensters — kein globaler `SetWindowsHookEx`-Hook (siehe +`AGENTS.md`). Erste Folge: **nur was das Fenster erreicht, wird erkannt.** +Win+L, Strg+Alt+Entf und andere vom Betriebssystem abgefangene +Kombinationen kommen nie an. + +Zweite und wichtigere Folge betrifft die *Zuordnung*. HID-Keycodes +bezeichnen **physische Tastenpositionen**: das Board sendet eine Position, +erst Windows macht daraus über das aktive Layout ein Zeichen. Wer die +Zuordnung über das *Zeichen* aufbaut, dreht diese Kette falsch herum — auf +deutschem Layout landete dadurch jedes Y auf der Z-Taste des Boards und +ÄÖÜ/#/+ waren gar nicht erfassbar. `versapad_data.tk_event_to_hid()` löst +deshalb in dieser Reihenfolge auf: + +1. **Benannte Tasten über den Tk-keysym** (Enter, Escape, Pfeile, F-Tasten, + Numpad, Entf …). Layoutunabhängig eindeutig — und hier zwingend, weil + `MapVirtualKeyW` für die Pfeiltasten denselben Scan-Code liefert wie für + ihre Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B). +2. **Zeichentasten über die physische Position** — + `versapad_keylayout.hid_for_vk()`: Virtual-Key → Scan-Code + (`MapVirtualKeyW`) → HID über die layoutunabhängige Tabelle + `SCANCODE_TO_HID`. Der Virtual-Key ist unabhängig davon, ob Shift oder + AltGr mitgehalten wird. +3. **Näherung ohne WinAPI** (keysym-Zeichentabelle, dann VK-Tabelle) — nur + relevant, wenn `versapad_keylayout` nicht verfügbar ist (Nicht-Windows, + kein ctypes). Auf dieser Ebene bleibt es bei der US-Layout-Näherung + inklusive vertauschtem Y/Z. + +### Tastenbeschriftungen + +`versapad_data.hid_key_name()` fragt für Zeichentasten `GetKeyNameTextW` +und zeigt damit den Namen des aktiven Layouts (deutsch: HID 0x1C → „Z“, +0x34 → „ä“). Für alles andere bleiben die gepflegten deutschen Namen aus +`_SPECIAL_KEYS` („Enter“, „Bild↑“, „Num5“) — die lesen sich besser als das, +was Windows dafür liefert („EINGABE“, „4 (ZEHNERTASTATUR)“). + +Diese Namen sind zugleich Schlüssel (Dropdown-Einträge, +`hid_key_code_for_name()` für den MCP-Server) und müssen eindeutig bleiben. +Echte Kollisionen kommen vor: auf deutschem Layout heißt HID 0x31 schlicht +„#“, und diesen Namen trug bisher HID 0x32. Der Layoutname gewinnt, der +verdrängte US-Name wird als „# (US-Layout)“ gekennzeichnet statt verworfen. +`hid_key_code_for_name()` akzeptiert beide Schreibweisen; bei Kollision +gewinnt das Layout, damit `set_button_key(key="Z")` die Taste trifft, die +auf dieser Tastatur ein Z tippt. + +Bestehende Belegungen ändern dadurch ihre **Anzeige, nicht ihre Daten**: +eine früher über das Dropdown gesetzte „Z“ steht als 0x1D in der Config und +wird jetzt wahrheitsgemäß als „Y“ angezeigt, weil sie auf dieser Tastatur +ein Y tippt. Ein Layoutwechsel zur Laufzeit wird nicht nachgezogen. + +### Kopieren/Einfügen zwischen Tasten + +Rechtsklick auf eine Karte im Programmiermodus (bzw. `Strg+C`/`Strg+V` auf +der Karte unter dem Mauszeiger) kopiert Belegung und/oder LED-Farbe auf +andere Tasten. Die Ablage ist eine reine In-Memory-Struktur in +`desktop_viewer.VersaPadViewer._clip` (`{"action": …, "led": …}`), **nicht** +die System-Zwischenablage — dort lägen nur Textrepräsentationen, hier +werden ganze Action-Dicts übertragen. Eingefügt wird immer eine +`copy.deepcopy()`, damit zwei Tasten nicht dasselbe Dict teilen. Encoder +haben keine eigene LED; eine kopierte Farbe auf einen Encoder einzufügen ist +deshalb wirkungslos. + +Tk-Aufrufe passieren nie direkt aus dem Serial- oder Tray-Hintergrundthread +— Ergebnisse landen in einer `queue.Queue`, der Main-Thread holt sie per +`after()`-Polling ab (Absturzrisiko bei Cross-Thread-Tk-Zugriff, siehe +`AGENTS.md`). + +## Nebenläufigkeit / Prozessmodell + +Der USB-CDC-Port ist **exklusiv** — nur eine Verbindung gleichzeitig. Drei +potenzielle Halter existieren parallel und wissen nichts voneinander: + +1. Die offizielle VersaGUI (C#/.NET), läuft dauerhaft als Tray-App. +2. `desktop_viewer.py`, wenn Live-Sync an ist (hält den Port dauerhaft) oder + während eines Board-Lese-/Schreibvorgangs im Programmiermodus (hält ihn + nur kurz). +3. `versapad_mcp_server.py`, während eines Board-Tool-Aufrufs — schließt + die Verbindung danach explizit wieder (`finally: _link.close()` in + `get_board_status()`, `load_from_board()`, `write_to_board()`), damit ein + einzelner MCP-Aufruf nicht dauerhaft blockiert, was Live-Sync/VersaGUI + sonst mit „busy“ aussperren würde. + +**Mehrere MCP-Server-Prozesse:** Je nach Host-Umgebung können mehrere +unabhängige `versapad_mcp_server.py`-Prozesse gleichzeitig laufen (z.B. +durch wiederholte Tool-Ladevorgänge/Reconnects), jeder mit eigenem, +nicht geteiltem In-Memory-Zustand. Ein `write_to_board()`-Aufruf kann daher +auf einem anderen Prozess landen als vorherige `set_*`-Aufrufe und einen +veralteten/leeren Zustand schreiben, obwohl die Antwort `{"ok": true}` +meldet. Empfohlenes Muster: vor `write_to_board()` immer `load_local()` +aufrufen (liest die Datei prozessunabhängig frisch von der Platte) und +nach dem Schreiben mit `load_from_board()` + `get_profile()` gegenlesen, +statt dem ACK allein zu vertrauen. Details siehe „Bug beobachtet +2026-08-14“ in `AGENTS.md`. + +## Build/Deploy + +`build_and_deploy.ps1` installiert `requirements.txt` selbst +(`pip install -r`), baut mit PyInstaller (`--onedir --windowed`, nur +`desktop_viewer.py` wird gebündelt) und kopiert das Ergebnis nach +`%LOCALAPPDATA%\VersaPadViewer\`. `--onedir` statt `--onefile`, um +AV-Fehlalarme zu verringern. Räumt das Zielverzeichnis vor dem Kopieren +komplett ab, rettet dabei aber zuvor gefundene `versapad_config*.json` +(Altinstallationen, bei denen die Config noch im Installationsordner +liegt) über den Deploy hinweg. Läuft komplett in try/catch mit +Exit-Code-Prüfung und pausiert am Ende (Erfolg wie Fehler) auf +Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem +Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details: +[`README.md`](../README.md#als-eigenständige-exe-windows). diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..3491f11 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,216 @@ +# Datenmodell + +Alle Formate, die VersaPad Viewer liest/schreibt: die JSON-Repräsentationen +und das binäre NVM-Layout des Boards. Das binäre Layout ist 1:1 aus den +VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert +(Read → unpack → pack ist bytegenau identisch zum Original, inklusive +CRC) — siehe `versapad_protocol.py` und die „Existing-Codebase-Regel“ in +[`AGENTS.md`](../AGENTS.md), bevor hier etwas geändert wird. + +## Geometrie + +``` +index = spalte * 5 + reihe +``` + +4 Spalten (`GRID_COLS`), 5 Reihen (`GRID_ROWS`), Reihe 0 = oben, Reihe 4 = +unten. Firmware-Reihenfolge, nicht neu herleiten. Damit ergeben sich die +20 Button-Indizes so auf dem physischen Grid: + +| | Spalte 0 | Spalte 1 | Spalte 2 | Spalte 3 | +|---|---|---|---|---| +| Reihe 0 (oben) | 0 | 5 | 10 | 15 | +| Reihe 1 | 1 | 6 | 11 | 16 | +| Reihe 2 | 2 | 7 | 12 | 17 | +| Reihe 3 | 3 | 8 | 13 | 18 | +| Reihe 4 (unten) | 4 | 9 | 14 | 19 | + +## Action + +Eine `Action` beschreibt, was ein Button oder eine Encoder-Bewegung +auslöst. Sowohl in JSON als auch binär ein `{type, data}`-Paar +(binär: `SAction`, 3 Byte — 1 Byte Typ + 2 Byte `data`, little-endian). + +| `type` | Enum-Index | `data`-Bedeutung | +|---|---|---| +| `None` | 0 | ungenutzt (0) | +| `HidKey` | 1 | `data = keycode \| (modifier << 8)` — Keycode HID Usage Page 0x07 im unteren Byte, Modifier-Bitmaske im oberen Byte | +| `HidConsumer` | 2 | `data` = HID-Consumer-Usage-ID (Usage Page 0x0C), z.B. `0x00CD` = Play/Pause | +| `HostCommand` | 3 | Enum-Wert existiert in der Firmware, wird von diesem Tool aktuell nicht gesetzt/editiert (kein `set_button_hostcommand`-Äquivalent) | +| `Macro` | 4 | `data` = Makro-Slot-Index (0-31) | +| `ProfileSwitch` | 5 | `data` = Ziel-Profil (0/1/2) oder `0xFFFF`/`0x00FF` = „nächstes Profil“ (Zyklus) | + +Modifier-Bitmaske (für `HidKey`, gilt **nicht** 1:1 für Makro-Schritte, +siehe unten): + +| Bit | Modifier | +|---|---| +| `0x01` | Strg | +| `0x02` | Shift | +| `0x04` | Alt | +| `0x08` | Win | + +Jede `Action` trägt zusätzlich ein optionales `note`-Feld (freier Text, +z.B. `"Speichern in Fusion 360"`) — **rein lokal**, wie `profile_names` +(siehe unten): kein Platz dafür in `SAction` (3 Byte, komplett verplant), +`to_binary()`/`pack_config()` ignorieren das Feld beim Schreiben ans +Board, `from_binary()` liefert frisch vom Board immer `note=""`. +`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. Editierbar per Programmiermodus-Dialog +oder MCP (`set_button_note()`/`set_encoder_note()`). + +## LED + +Pro MX-Button (nicht pro Encoder — Encoder haben keine eigene LED): + +| Feld | Typ | Bedeutung | +|---|---|---| +| `r`, `g`, `b` | uint8 (0-255) | Farbe | +| `brightness` | uint8 (0-255) | Helligkeit | +| `anim` | Enum-String (JSON) / Enum-Index (binär) | `Static`, `Blink`, `Pulse`, `FadeIn`, `FadeOut`, `ColorCycle`, `ColorFade` | +| `period_ms` | uint16 (little-endian) | Animationsperiode in ms (Pulse braucht `>= 2`) | + +## Makro-Schritt + +Ein Makro-Schritt ist **kein** `Action` — Keycode und Modifier stehen in +zwei getrennten Bytes (nicht in einem gepackten 16-Bit-`data`-Feld wie bei +`HidKey`): + +| Feld | Typ | Bedeutung | +|---|---|---| +| `keycode` | uint8 | HID-Keycode. `0` beendet die Sequenz (Firmware-Konvention — keine Lücken vor dem letzten belegten Schritt lassen) | +| `modifier` | uint8 | Bitmaske, aber **nur Strg/Shift/Alt** (kein Win — passend zu `ActionDialog.cs` im Original) | + +Eine Makro-Tabelle hat 32 Slots (`MACRO_SLOTS`) mit je bis zu 8 Schritten +(`MACRO_MAX_STEPS`). Sie ist **eine einzige globale Tabelle**, nicht pro +Profil — zwei Profile, die per `Macro`-Action denselben Slot referenzieren, +spielen dieselben Schritte ab. Konvention für die Slot-Zuordnung (von den +Tools/der GUI benutzt, nicht von der Firmware erzwungen): + +- Slot `0`–`19` = MX-Button-Index (Button `i` → Slot `i`) +- Slot `20`–`31` = `20 + enc*3 + act_idx` (Encoder `enc`, `act_idx`: + 0=Druck/`sw`, 1=`cw`, 2=`ccw`) + +## JSON: kombiniertes Format (`versapad_config_all.json`) + +Das von diesem Tool selbst gepflegte Format (`versapad_combined.py`) — alle +3 Profile + Makro-Tabelle + lokale Profilnamen in einer Datei. Passt zum +Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den +kompletten 740B-Block, nie nur ein Profil. + +```jsonc +{ + "active_profile": 0, + "global_brightness": 255, + "enc_sensitivity": [1, 1, 1, 1], + "profile_names": ["Windows", "Fusion 360", "BricsCAD"], + "profiles": [ + { + "buttons": [ + { + "index": 0, + "action": { "type": "HidKey", "data": 30, "note": "Speichern in Fusion 360" }, + "led": { "r": 80, "g": 40, "b": 0, "brightness": 255, + "anim": "Static", "period_ms": 4000 } + } + // ... 20 Buttons (index 0-19) + ], + "encoders": [ + { + "index": 0, + "sw": { "type": "ProfileSwitch", "data": 65535 }, + "cw": { "type": "None", "data": 0 }, + "ccw": { "type": "None", "data": 0 } + } + // ... 4 Encoder (index 0-3) + ] + } + // ... 3 Profile + ], + "macros": [ + [{ "keycode": 30, "modifier": 0 }, { "keycode": 39, "modifier": 0 }] + // ... 32 Slots, jeweils eine Liste mit 0-8 Schritten + ] +} +``` + +Profilnamen (`profile_names`) sind **rein lokal** — 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. Dasselbe gilt für `note` in jeder `Action` +(siehe oben). + +## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`) + +Kein von diesem Tool geschriebenes Format — optionaler Export der +offiziellen VersaGUI, gelesen von `versapad_data.load_profile()`. Enthält +nur ein einzelnes Profil, keine Makro-Schritte, keinen Profilnamen: + +```jsonc +{ + "buttons": [ /* wie oben, 20 Eintraege */ ], + "encoders": [ /* wie oben, 4 Eintraege */ ] +} +``` + +## MCP-Tool-Grenzfläche (`set_macro`, `set_button_key`, …) + +Die MCP-Tools nehmen **menschenlesbare** Namen entgegen, keine Rohwerte — +`versapad_data.py` übersetzt: + +```jsonc +// set_button_key(profile=0, index=0, key="S", modifiers=["Strg"]) +// set_macro(slot=7, steps=[{"key": "1", "modifiers": []}, {"key": "0", "modifiers": []}]) +``` + +`hid_key_code_for_name()` / `consumer_id_for_name()` / `modifier_bits_for_names()` +übersetzen Namen → Rohwerte (werfen `ValueError` mit einer Liste gültiger +Namen bei Tippfehlern). Zeichentasten-Labels sind eine US-Layout-Näherung +(keine `GetKeyNameText()`-Auflösung wie im C#-Original). + +## Binäres NVM-Layout + +### `SDeviceConfig` (740 Byte, `versapad_protocol.CONFIG_SIZE`) + +| Offset | Größe | Feld | +|---|---|---| +| 0 | 4B (uint32 LE) | Magic (`0x56503203`, `NVM_CONFIG_MAGIC`) | +| 4 | 1B | Version (`3`, `NVM_CONFIG_VERSION`) | +| 5 | 2B (uint16 LE) | CRC16 über Byte 7-739 | +| 7 | 1B | `active_profile` (0-2) | +| 8 | 1B | `global_brightness` (0-255) | +| 9 | 4B | `enc_sensitivity` (4× uint8, einer je Encoder) | +| 13 | 19B | reserviert/ungenutzt (Padding) | +| 32 | 236B | Profil 0 (`SDeviceProfile`) | +| 268 | 236B | Profil 1 | +| 504 | 236B | Profil 2 | + +### `SDeviceProfile` (236 Byte) + +| Offset (relativ) | Größe | Feld | +|---|---|---| +| 0 | 60B (20× 3B `SAction`) | MX-Button-Actions, Index 0-19 | +| 60 | 36B (4× 3× 3B `SAction`) | Encoder-Actions: je Encoder `sw`, `cw`, `ccw` | +| 96 | 20B | LED `r` je Button | +| 116 | 20B | LED `g` je Button | +| 136 | 20B | LED `b` je Button | +| 156 | 20B | LED `brightness` je Button | +| 176 | 20B | LED `anim` (Enum-Index) je Button | +| 196 | 40B (20× uint16 LE) | LED `period_ms` je Button | + +Die LED-Felder liegen **spaltenweise** (struct-of-arrays: alle 20 +`r`-Werte, dann alle 20 `g`-Werte, …), nicht verschachtelt pro Button — +`pack_profile()`/`unpack_profile()` bauen das entsprechend um. + +### `SMacroTable` (512 Byte, `versapad_protocol.MACRO_SIZE`) + +32 Slots × 8 Schritte × 2 Byte (`keycode`, `modifier`) = 512 Byte, flach +hintereinander: `offset = (slot_idx * 8 + step_idx) * 2`. + +### CRC16 + +`crc16()` in `versapad_protocol.py`: CRC-CCITT, Polynom `0x1021`, Init +`0xFFFF`, MSB-first, kein XOR-Out — exakt `nvm_config_crc()` aus der +Firmware (`nvm_config.cpp`). Wird über Byte 7-739 der Config berechnet +(alles nach Magic/Version/CRC-Header selbst). diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..73f76e7 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,136 @@ +# Serial-Wire-Protokoll + +Beschreibt, wie `versapad_serial.VersaPadLink` mit dem Board spricht. 1:1 +aus `VersaGUI/src/Protocol.cs` und `VersaMCU/doc/07_serial_protocol.md` +übernommen (siehe „Existing-Codebase-Regel“ in +[`AGENTS.md`](../AGENTS.md) — bei Änderungen gegen die Firmware-/ +C#-Quellen abgleichen, nicht aus dem Gedächtnis rekonstruieren). Für die +Bedeutung der übertragenen Bytes (Config/Makro-Layout) siehe +[`data-model.md`](data-model.md). + +## Transport + +- USB-CDC (virtueller COM-Port), 115200 Baud. +- Board-Erkennung über USB VID:PID `239A:0042` (`versapad_serial.find_port()` + durchsucht `serial.tools.list_ports.comports()`). +- Der Port ist **exklusiv** — siehe „Nebenläufigkeit“ in + [`architecture.md`](architecture.md). +- Jedes Paket ist exakt **8 Byte**: + + | Byte | Bedeutung | + |---|---| + | `[0]` | Command-/Event-ID | + | `[1]` | je nach Befehl: Chunk-Index, Chunk-Anzahl, oder ungenutzt | + | `[2..7]` | Payload, 6 Byte (`PAYLOAD_SIZE`) | + +## Befehle (Host → Board) + +| Konstante | Wert | Zweck | +|---|---|---| +| `CMD_READ_STATUS` | `0x06` | Leichtgewichtiger Status-Poll (aktives Profil) | +| `CMD_CONFIG_BEGIN` | `0x10` | Start eines Config-Schreibvorgangs | +| `CMD_CONFIG_DATA` | `0x11` | Ein Config-Datenpaket | +| `CMD_CONFIG_COMMIT` | `0x12` | Config committen (NVM-Schreiben nach Validierung) | +| `CMD_CONFIG_READ` | `0x13` | Komplette Config vom Board anfordern | +| `CMD_MACRO_BEGIN` | `0x20` | Start eines Makro-Schreibvorgangs | +| `CMD_MACRO_DATA` | `0x21` | Ein Makro-Datenpaket | +| `CMD_MACRO_COMMIT` | `0x22` | Makros committen | +| `CMD_MACRO_READ` | `0x23` | Komplette Makro-Tabelle vom Board anfordern | + +## Events (Board → Host) + +| Konstante | Wert | Zweck | +|---|---|---| +| `EVT_STATUS` | `0x86` | Antwort auf `CMD_READ_STATUS` | +| `EVT_CONFIG_ACK` | `0x90` | Config-Commit erfolgreich | +| `EVT_CONFIG_NACK` | `0x91` | Config-Commit abgelehnt (Validierung fehlgeschlagen) | +| `EVT_CONFIG_BEGIN` | `0x92` | Board beginnt, Config-Dump zu senden | +| `EVT_CONFIG_DATA` | `0x93` | Ein Config-Datenpaket (Antwort auf `CMD_CONFIG_READ`) | +| `EVT_CONFIG_END` | `0x94` | Config-Dump vollständig | +| `EVT_MACRO_ACK` | `0x95` | Makro-Commit erfolgreich | +| `EVT_MACRO_BEGIN` | `0x96` | Board beginnt, Makro-Dump zu senden | +| `EVT_MACRO_DATA` | `0x97` | Ein Makro-Datenpaket | +| `EVT_MACRO_END` | `0x98` | Makro-Dump vollständig | +| `EVT_MACRO_NACK` | `0x99` | Makro-Commit abgelehnt | + +## Ablauf: Lesen (`CONFIG_READ` / `MACRO_READ`) + +Genutzt von `read_full_config()` (740B, ⌈740/6⌉ = 124 Datenpakete) und +`read_macros()` (512B, ⌈512/6⌉ = 86 Datenpakete). Deadline 3s. + +``` +Host → [CMD_CONFIG_READ, 0, 0,0,0,0,0,0] +Board → [EVT_CONFIG_BEGIN, ...] +Board → [EVT_CONFIG_DATA, chunk_idx=0, <=6B Payload] +Board → [EVT_CONFIG_DATA, chunk_idx=1, <=6B Payload] + ... (124 Pakete insgesamt für Config, 86 für Makros) +Board → [EVT_CONFIG_END, ...] +``` + +`chunk_idx` (Byte `[1]`) bestimmt den Ziel-Offset im Empfangspuffer: +`offset = chunk_idx * 6`. Kommt `EVT_END`, ohne dass zuvor `EVT_BEGIN` +gesehen wurde, gilt das als Timeout (unvollständige/verpasste Antwort). + +## Ablauf: Schreiben (`CONFIG_BEGIN/DATA/COMMIT` bzw. `MACRO_*`) + +Genutzt von `write_full_config()`/`write_macros()`. Deadline 5s. Anzahl +Chunks maximal 255 (ein Byte) — bei größeren Blobs schlägt der Aufruf mit +`"too_large"` fehl, bevor überhaupt gesendet wird. + +``` +Host → [CMD_CONFIG_BEGIN, chunk_count, 0,0,0,0,0,0] +Host → [CMD_CONFIG_DATA, 0, <=6B Payload (zero-padded)] +Host → [CMD_CONFIG_DATA, 1, <=6B Payload] + ... (ein Paket je Chunk) +Host → [CMD_CONFIG_COMMIT, 0, 0,0,0,0,0,0] +Board → [EVT_CONFIG_ACK, ...] -- oder EVT_CONFIG_NACK bei fehlgeschlagener Validierung +``` + +**Sicherheitsnetz:** Die Firmware prüft Magic/Version/CRC (Config) bzw. +Keycode-Bereich (Makros) **vor** jedem NVM-Schreiben und antwortet sonst +nur mit NACK — ein fehlerhafter Schreibversuch kann das Board laut +Firmware-Design nicht in einen inkonsistenten Zustand bringen, siehe +`nvm_config_validate()` in den Firmware-Quellen. + +## Ablauf: Leichtgewichtiger Status-Poll (`READ_STATUS`) + +Genutzt von `read_active_profile()` für kontinuierliches Live-Sync-Polling +(alle 1,5s). Deadline 1s. + +``` +Host → [CMD_READ_STATUS, 0, 0,0,0,0,0,0] +Board → [EVT_STATUS, active_profile (0-2), ...] +``` + +Ein einzelnes 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. Ältere Firmware +ohne `CMD_READ_STATUS` antwortet einfach gar nicht → sauberer Timeout, +kein Absturz (siehe `AGENTS.md`, Eintrag „4215323“ in der Historie). + +## Verbindungsaufbau (`VersaPadLink._ensure_open()`) + +1. Board per VID/PID finden (`find_port()`). +2. `serial.Serial(port, 115200, timeout=0.5)` öffnen. +3. DTR auf `True` setzen, 0,2s warten (Board braucht kurz, bis es nach dem + Öffnen/DTR-Toggle wieder reagiert). +4. Input-Buffer leeren. + +**Wichtig:** `VersaPadLink` schließt die Verbindung nicht automatisch nach +einem Befehl — nur bei `serial.SerialException` (Fehlerfall) oder +explizitem `.close()`-Aufruf. Aufrufer, die den exklusiven Port nicht +dauerhaft blockieren wollen, müssen selbst schließen (siehe +`versapad_mcp_server.py`, das nach jedem Board-Tool-Aufruf `_link.close()` +in einem `finally`-Block aufruft). + +## Fehlerzustände (`VersaPadLink.last_error`) + +| Wert | Bedeutung | +|---|---| +| `None` | kein Fehler | +| `"no_pyserial"` | Paket `pyserial` nicht installiert | +| `"not_found"` | kein Gerät mit passender VID/PID gefunden | +| `"busy"` | Port gefunden, aber Öffnen fehlgeschlagen (von woanders gehalten — VersaGUI, Live-Sync, ein anderer Prozess) | +| `"timeout"` | keine (gültige) Antwort innerhalb der Deadline | +| `"nack"` | Board hat den Commit explizit abgelehnt (Validierung fehlgeschlagen) | +| `"too_large"` | zu sendender Blob braucht mehr als 255 Chunks | diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed0027c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pyserial +pystray +pillow +mcp +pyinstaller diff --git a/server.py b/server.py index 1090911..7e1b181 100644 --- a/server.py +++ b/server.py @@ -1,7 +1,10 @@ """ VersaPad Viewer -- Browser-Variante. -Liest bei jedem Request live die 3 Config-JSONs vom Desktop und rendert -die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. +Liest bei jedem Request die kombinierte Config-JSON vom Desktop und +rendert die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. Fehlt +die JSON (z.B. geloescht), wird sie automatisch per Serial vom Board neu +aufgebaut und als neuer Cache gespeichert -- das Board ist die eigentliche +Quelle der Wahrheit, siehe versapad_combined.load_or_fetch(). Kein Build-Schritt, kein externes Framework -- nur stdlib. Start: python server.py [--port 8765] @@ -12,6 +15,7 @@ import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs +import versapad_combined as vcomb import versapad_data as vp PAGE_CSS = """ @@ -34,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } .grid { display: grid; grid-template-columns: repeat(4, 140px); - grid-template-rows: repeat(5, 76px); + grid-template-rows: repeat(5, 104px); grid-auto-flow: column; gap: 10px; margin-bottom: 36px; @@ -49,15 +53,17 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } } .cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; } .cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; } +.cell .note { font-size: 11px; color: #8a8d98; margin-top: 4px; word-break: break-word; } .cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; } .cell.empty .label { color: #4a4d58; font-weight: 400; } h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; } .encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; } .enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; } .enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; } -.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; } +.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0 0; } .enc .row .k { color: #8a8d98; } .enc .row .v { font-weight: 500; text-align: right; } +.enc .note { font-size: 11px; color: #6a6d78; padding-bottom: 6px; word-break: break-word; } footer { margin-top: 40px; color: #6a6d78; font-size: 12px; } """ @@ -69,28 +75,44 @@ def render_cell(btn): grid_col, grid_row = btn["col"] + 1, btn["row"] + 1 style = f"grid-column:{grid_col}; grid-row:{grid_row};" label = html.escape(btn["label"]) if btn["label"] else "—" + note = html.escape(btn["note"]) if btn.get("note") else "" return f"""
#{btn['index']}
{label}
+
{note}
{"" if empty else anim}
""" +def _encoder_row(key, label, note): + label = html.escape(label) or "—" + note_html = f'
{html.escape(note)}
' if note else "" + return f"""
{key}{label}
{note_html}""" + + def render_encoder(enc): + rows = ( + _encoder_row("Druck", enc["sw_label"], enc["sw_note"]) + + _encoder_row("CW", enc["cw_label"], enc["cw_note"]) + + _encoder_row("CCW", enc["ccw_label"], enc["ccw_note"]) + ) return f"""
Encoder {enc['index']}
-
Druck{html.escape(enc['sw_label']) or '—'}
-
CW{html.escape(enc['cw_label']) or '—'}
-
CCW{html.escape(enc['ccw_label']) or '—'}
+ {rows}
""" def render_page(profile): - cfg = vp.load_profile(profile) + combined = vcomb.load_or_fetch() + raw = combined["profiles"][profile] + cfg = vp.annotate_profile({ + "buttons": [dict(b) for b in raw["buttons"]], + "encoders": [dict(e) for e in raw["encoders"]], + }, combined.get("macros")) tabs = "".join( - f'{html.escape(vp.PROFILE_NAMES[p])}' - for p in sorted(vp.PROFILE_NAMES) + f'{html.escape(combined["profile_names"][p])}' + for p in range(vp.NUM_PROFILES) ) cells = "".join(render_cell(b) for b in cfg["buttons"]) encoders = "".join(render_encoder(e) for e in cfg["encoders"]) @@ -107,7 +129,7 @@ def render_page(profile):
{cells}

Encoder

{encoders}
-
Quelle: {html.escape(vp.CONFIG_PATHS[profile])}
+ """ @@ -121,7 +143,7 @@ class Handler(BaseHTTPRequestHandler): profile = int(query.get("profile", ["0"])[0]) except ValueError: profile = 0 - if profile not in vp.PROFILE_NAMES: + if not (0 <= profile < vp.NUM_PROFILES): profile = 0 try: body = render_page(profile).encode("utf-8") @@ -130,10 +152,10 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) - except FileNotFoundError as e: + except (FileNotFoundError, RuntimeError) as e: self.send_response(500) self.end_headers() - self.wfile.write(f"Config-Datei fehlt: {e}".encode("utf-8")) + self.wfile.write(f"Config nicht verfuegbar: {e}".encode("utf-8")) def main(): diff --git a/versapad_combined.py b/versapad_combined.py index 5423196..da1f145 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -17,17 +17,18 @@ import os import versapad_data as vp import versapad_protocol as proto +import versapad_serial as vs -DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json") +DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json") DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] def _empty_profile(): - buttons = [{"index": i, "action": {"type": "None", "data": 0}, + buttons = [{"index": i, "action": {"type": "None", "data": 0, "note": ""}, "led": {"r": 80, "g": 40, "b": 0, "brightness": 255, "anim": "Static", "period_ms": 4000}} for i in range(20)] - none = {"type": "None", "data": 0} + none = {"type": "None", "data": 0, "note": ""} encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)] return {"buttons": buttons, "encoders": encoders} @@ -60,17 +61,54 @@ def default_combined(): def from_binary(config_dict, macro_slots, profile_names=None): """config_dict: Ergebnis von versapad_protocol.unpack_config(). - macro_slots: Ergebnis von versapad_protocol.unpack_macros().""" + macro_slots: Ergebnis von versapad_protocol.unpack_macros(). Actions + kommen frisch vom Board ohne "note" (die Firmware kennt keine Notizen, + siehe merge_notes()) -- hier nur mit leerem Default versehen, damit das + Feld ueberall verlaesslich existiert.""" + profiles = config_dict["profiles"] + for profile in profiles: + for b in profile["buttons"]: + b["action"].setdefault("note", "") + for e in profile["encoders"]: + for field in ("sw", "cw", "ccw"): + e[field].setdefault("note", "") return { "active_profile": config_dict["active_profile"], "global_brightness": config_dict["global_brightness"], "enc_sensitivity": config_dict["enc_sensitivity"], "profile_names": profile_names or list(DEFAULT_NAMES), - "profiles": config_dict["profiles"], + "profiles": profiles, "macros": macro_slots, } +def merge_notes(combined, previous): + """Kopiert Notizen (Button/Encoder-Aktion) aus einem vorherigen State in + einen frisch vom Board gelesenen State -- wie profile_names sind Notizen + rein lokal und wuerden bei jedem load_from_board()/fetch_from_board() + sonst verloren gehen, weil die Firmware sie nicht kennt. previous=None + (z.B. allererstes Laden) -> nichts zu tun, combined unveraendert + zurueckgegeben. Aendert combined in-place und gibt es zurueck.""" + if not previous: + return combined + for p_idx, profile in enumerate(combined["profiles"]): + if p_idx >= len(previous["profiles"]): + continue + prev_profile = previous["profiles"][p_idx] + prev_buttons = {b["index"]: b for b in prev_profile.get("buttons", [])} + for b in profile["buttons"]: + prev = prev_buttons.get(b["index"]) + if prev: + b["action"]["note"] = prev["action"].get("note", "") + prev_encoders = {e["index"]: e for e in prev_profile.get("encoders", [])} + for e in profile["encoders"]: + prev = prev_encoders.get(e["index"]) + if prev: + for field in ("sw", "cw", "ccw"): + e[field]["note"] = prev[field].get("note", "") + return combined + + def to_binary(combined): """-> (config_bytes[740], macro_bytes[512])""" config_bytes = proto.pack_config({ @@ -84,6 +122,7 @@ def to_binary(combined): def save_file(combined, path=DEFAULT_PATH): + os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(combined, f, indent=2, ensure_ascii=False) return path @@ -92,3 +131,70 @@ def save_file(combined, path=DEFAULT_PATH): def load_file(path=DEFAULT_PATH): with open(path, "r", encoding="utf-8") as f: return json.load(f) + + +def fetch_from_board(link=None, profile_names=None): + """Liest Config+Makros direkt vom Board per Serial (~1-2s), das Board + ist die eigentliche Quelle der Wahrheit (write_to_board() speichert + dauerhaft im NVM -- die JSON-Dateien hier sind nur ein Lesecache). + link: bestehender VersaPadLink wiederverwenden (z.B. desktop_viewer's + self._link, damit nicht zwei Verbindungen um denselben COM-Port + konkurrieren) -- sonst wird eine eigene geoeffnet und wieder + geschlossen. Wirft RuntimeError mit Klartext-Ursache (last_error), + z.B. wenn der Port gerade von VersaGUI/einem anderen Viewer belegt ist.""" + owns_link = link is None + if owns_link: + link = vs.VersaPadLink() + try: + raw_cfg = link.read_full_config() + if raw_cfg is None: + raise RuntimeError(f"Config vom Board laden fehlgeschlagen: {link.last_error}") + raw_macros = link.read_macros() + if raw_macros is None: + raise RuntimeError(f"Makros vom Board laden fehlgeschlagen: {link.last_error}") + cfg_dict = proto.unpack_config(raw_cfg) + if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): + raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") + macro_slots = proto.unpack_macros(raw_macros) + return from_binary(cfg_dict, macro_slots, profile_names=profile_names) + finally: + if owns_link: + link.close() + + +def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None): + """Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich + geloescht, oder frische Installation ohne jede Config), wird sie + automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das + behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache + dafuer), und falls auch das Board nicht erreichbar ist (nicht + verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite) + als leere Default-Config (vgl. default_combined()) -- damit ist das + Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit + einem Fehler zu blockieren.""" + if os.path.exists(path): + return load_file(path) + try: + combined = fetch_from_board(link=link, profile_names=profile_names) + except RuntimeError: + combined = default_combined() + if profile_names: + combined["profile_names"] = profile_names + try: + save_file(combined, path) + except OSError: + pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden + return combined + + +def read_profile_names(path=DEFAULT_PATH): + """Nur die (lokalen) Profilnamen lesen, ohne die volle Config zu + brauchen -- fuer Tab-Beschriftungen im Nur-Lese-Modus. Greift bewusst + nicht aufs Board zu (kein COM-Port-Konflikt mit Live-Sync), faellt bei + fehlender/kaputter Datei auf DEFAULT_NAMES zurueck.""" + if os.path.exists(path): + try: + return load_file(path).get("profile_names", list(DEFAULT_NAMES)) + except (OSError, ValueError): + pass + return list(DEFAULT_NAMES) diff --git a/versapad_data.py b/versapad_data.py index f4abcc0..46705ec 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -8,18 +8,54 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. import json import os +import versapad_keylayout as kl + +NUM_PROFILES = 3 + +# Groesse der globalen Makrotabelle (SMacroTable, siehe versapad_protocol. +# MACRO_SLOTS) -- hier nochmal, damit dieses Modul importfrei bleibt. +MACRO_SLOTS = 32 + +APP_NAME = "VersaPadViewer" + + +def app_dir(): + """Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json, + siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung + abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und + unter jedem Benutzer. + + Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die + gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen + dieselbe Datei sehen, sonst laufen zwei Configs auseinander und + Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist + am 2026-08-15 passiert -- .exe zeigte ein anderes Profil 0 als der + MCP-Server). + + Bewusst auch NICHT das Installationsverzeichnis (%LOCALAPPDATA%\\ + VersaPadViewer, wo die .exe liegt): `build_and_deploy.ps1` raeumt das + Zielverzeichnis vor jedem Deploy komplett ab (`Remove-Item -Recurse`) -- + laege die Config dort, wuerde JEDER Rebuild die Nutzerdaten mitloeschen + (am 2026-08-15 genau so passiert, alle Notizen weg). Programm- und + Datenverzeichnis bleiben deshalb getrennt: Roaming-AppData fuer die + Config.""" + base = (os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") + or os.path.join(os.path.expanduser("~"), ".local", "share")) + return os.path.join(base, APP_NAME) + + +# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind +# keine von diesem Tool geschriebenen Dateien, sondern ein Export der +# offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese- +# Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche, +# von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH +# (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop). CONFIG_PATHS = { 0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"), 1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"), 2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"), } -PROFILE_NAMES = { - 0: "Profil 0 – Windows", - 1: "Profil 1 – Fusion 360", - 2: "Profil 2 – BricsCAD", -} - # index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge) GRID_COLS = 4 GRID_ROWS = 5 @@ -78,6 +114,164 @@ _CONSUMER_NAMES = { 0x00B0: "Aufnahme", } +# ── Tastendruck-Erkennung (Tk-Events -> HID) ────────────────────────────── +# +# Bewusst OHNE WinAPI-Hook: die Zuordnung arbeitet nur mit dem, was Tk beim +# Fokus auf dem Bearbeiten-Dialog ohnehin liefert (keysym, keycode, state). +# Damit bleibt es ein normales Fenster-Tastaturereignis -- kein globaler +# Low-Level-Hook, der (wie die Fensterverstecktricks in anderen Projekten) +# AV-Fehlalarme provozieren koennte. Preis: erkannt wird nur, was das +# fokussierte Fenster ueberhaupt erreicht (Win+L, Strg+Alt+Entf und +# aehnliche vom System abgefangene Kombinationen also nicht). + +# Modifier-Tasten selbst sind nie das "Ziel" eines Captures, sie setzen nur +# Bits -- Win ist hier mit dabei (Einzeltaste erlaubt Win), Makro-Schritte +# filtern es spaeter selbst wieder raus (Firmware kennt dort kein Win). +TK_MODIFIER_KEYSYMS = { + "Control_L": 0x01, "Control_R": 0x01, + "Shift_L": 0x02, "Shift_R": 0x02, + "Alt_L": 0x04, "Alt_R": 0x04, "ISO_Level3_Shift": 0x04, + "Super_L": 0x08, "Super_R": 0x08, "Win_L": 0x08, "Win_R": 0x08, +} + +# event.state-Bits unter Windows-Tk (Fallback, falls ein KeyRelease der +# Modifier-Taste verloren ging -- z.B. weil der Dialog waehrenddessen den +# Fokus hatte/verlor). +TK_STATE_MODIFIER_BITS = [(0x0001, 0x02), (0x0004, 0x01), (0x20000, 0x04)] + +# Benannte Tasten: keysym ist layoutunabhaengig, deshalb erste Wahl. +_TK_NAMED_KEYSYMS = { + "Return": 0x28, "Escape": 0x29, "BackSpace": 0x2A, "Tab": 0x2B, + "ISO_Left_Tab": 0x2B, "space": 0x2C, "Caps_Lock": 0x39, + "Print": 0x46, "Scroll_Lock": 0x47, "Pause": 0x48, "Cancel": 0x48, + "Insert": 0x49, "Home": 0x4A, "Prior": 0x4B, "Delete": 0x4C, + "End": 0x4D, "Next": 0x4E, + "Right": 0x4F, "Left": 0x50, "Down": 0x51, "Up": 0x52, + "Num_Lock": 0x53, "KP_Divide": 0x54, "KP_Multiply": 0x55, + "KP_Subtract": 0x56, "KP_Add": 0x57, "KP_Enter": 0x58, + "KP_1": 0x59, "KP_End": 0x59, "KP_2": 0x5A, "KP_Down": 0x5A, + "KP_3": 0x5B, "KP_Next": 0x5B, "KP_4": 0x5C, "KP_Left": 0x5C, + "KP_5": 0x5D, "KP_Begin": 0x5D, "KP_6": 0x5E, "KP_Right": 0x5E, + "KP_7": 0x5F, "KP_Home": 0x5F, "KP_8": 0x60, "KP_Up": 0x60, + "KP_9": 0x61, "KP_Prior": 0x61, "KP_0": 0x62, "KP_Insert": 0x62, + "KP_Decimal": 0x63, "KP_Delete": 0x63, + "Menu": 0x65, "App": 0x65, +} +for _i in range(12): + _TK_NAMED_KEYSYMS[f"F{_i + 1}"] = 0x3A + _i + +# Zeichentasten: HID-Keycodes sind US-Positionen, die keysyms kommen aber vom +# aktiven Windows-Layout. Beide Belegungen (US + Deutsch) stehen deshalb +# nebeneinander. Einzige echte Kollision ist "minus" (US-Layout: Taste neben +# der 0 = 0x2D, deutsches Layout: Taste neben dem Punkt = 0x38) -- dort +# gewinnt die US-Position, damit die Erkennung dieselbe Naeherung liefert wie +# die Dropdown-Beschriftung (siehe Layout-Vorbehalt bei tk_event_to_hid). +_TK_CHAR_KEYSYMS = { + # US-Layout + "minus": 0x2D, "equal": 0x2E, "bracketleft": 0x2F, "bracketright": 0x30, + "backslash": 0x31, "semicolon": 0x33, "apostrophe": 0x34, + "quoteright": 0x34, "grave": 0x35, "quoteleft": 0x35, + "comma": 0x36, "period": 0x37, "slash": 0x38, + # Deutsches Layout -- gleiche physische Tasten, andere Zeichen + "ssharp": 0x2D, "acute": 0x2E, "dead_acute": 0x2E, + "udiaeresis": 0x2F, "plus": 0x30, "numbersign": 0x32, + "odiaeresis": 0x33, "adiaeresis": 0x34, + "asciicircum": 0x35, "dead_circumflex": 0x35, + "less": 0x64, "greater": 0x64, "bar": 0x64, +} + +# Windows-Virtual-Key-Codes (event.keycode) als Rueckfallebene, wenn der +# keysym nichts hergibt -- z.B. wenn Shift/AltGr das Zeichen veraendert +# ("exclam" statt "1"). Nur die Bereiche, die layoutstabil sind. +_WIN_VK_TO_HID = { + 0x08: 0x2A, 0x09: 0x2B, 0x0D: 0x28, 0x13: 0x48, 0x14: 0x39, 0x1B: 0x29, + 0x20: 0x2C, 0x21: 0x4B, 0x22: 0x4E, 0x23: 0x4D, 0x24: 0x4A, + 0x25: 0x50, 0x26: 0x52, 0x27: 0x4F, 0x28: 0x51, + 0x2C: 0x46, 0x2D: 0x49, 0x2E: 0x4C, + 0x30: 0x27, 0x6A: 0x55, 0x6B: 0x57, 0x6D: 0x56, 0x6E: 0x63, 0x6F: 0x54, + 0x90: 0x53, 0x91: 0x47, 0x5D: 0x65, +} +for _i in range(9): + _WIN_VK_TO_HID[0x31 + _i] = 0x1E + _i # '1'-'9' +for _i in range(26): + _WIN_VK_TO_HID[0x41 + _i] = 0x04 + _i # 'A'-'Z' +for _i in range(12): + _WIN_VK_TO_HID[0x70 + _i] = 0x3A + _i # F1-F12 +_WIN_VK_TO_HID[0x60] = 0x62 # Numpad 0 +for _i in range(9): + _WIN_VK_TO_HID[0x61 + _i] = 0x59 + _i # Numpad 1-9 + + +def tk_keysym_to_hid(keysym): + """Tk-keysym -> HID-Keycode, oder None wenn nicht zuordenbar. + Modifier-Tasten liefern bewusst None (sie sind kein Capture-Ziel).""" + if keysym in TK_MODIFIER_KEYSYMS: + return None + if len(keysym) == 1: + upper = keysym.upper() + if "A" <= upper <= "Z": + return 0x04 + (ord(upper) - ord("A")) + if "1" <= keysym <= "9": + return 0x1E + (ord(keysym) - ord("1")) + if keysym == "0": + return 0x27 + if keysym in _TK_NAMED_KEYSYMS: + return _TK_NAMED_KEYSYMS[keysym] + return _TK_CHAR_KEYSYMS.get(keysym) + + +def tk_event_to_hid(keysym, keycode, state, held_modifier=0): + """Ein Tk-KeyPress -> (hid_keycode, modifier_bits) oder None, wenn die + Taste sich nicht auf einen HID-Keycode abbilden laesst (dann im Dialog + einfach weiter warten statt Muell zu speichern). + + keysym/keycode/state kommen direkt aus dem Tk-Event, held_modifier ist + das vom Dialog selbst mitgefuehrte Bitfeld der aktuell gedrueckten + Modifier (siehe TK_MODIFIER_KEYSYMS) -- beides wird verodert, damit ein + verlorenes KeyRelease die Erkennung nicht verfaelscht. + + Aufloesungsreihenfolge, und warum genau so: + + 1. **Benannte Tasten ueber den keysym** (Enter, Pfeile, F-Tasten, + Numpad, Entf ...). Die sind layoutunabhaengig eindeutig -- und der + Weg ueber den Scan-Code waere hier sogar gefaehrlich: Windows liefert + fuer die Pfeiltasten denselben Scan-Code wie fuer ihre + Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B). + 2. **Zeichentasten ueber die physische Position** + (`versapad_keylayout.hid_for_vk()`, Virtual-Key -> Scan-Code -> HID). + HID-Keycodes SIND Positionen; alles, was ueber das erzeugte Zeichen + geht, ist auf nicht-US-Layouts falsch. Genau hier lag der Fehler, der + auf deutschem Layout Y und Z vertauscht hat und AeOeUe/#/+ gar nicht + erfassbar machte. Der Virtual-Key ist ausserdem unabhaengig davon, ob + Shift oder AltGr mitgehalten wird. + 3. **Naeherung ohne WinAPI** (keysym-Zeichentabelle, dann VK-Tabelle) -- + nur relevant, wenn `versapad_keylayout` nicht verfuegbar ist + (Nicht-Windows, kein ctypes). Auf dieser Ebene bleibt es bei der + US-Layout-Naeherung inklusive vertauschtem Y/Z; bei gehaltenem Shift + zaehlt dort zuerst der VK-Code, weil der keysym dann das verschobene + Zeichen ist (deutsch: Shift+7 -> "slash"). + """ + if keysym in TK_MODIFIER_KEYSYMS: + return None # Modifier sind nie das Ziel, sie setzen nur Bits + + code = _TK_NAMED_KEYSYMS.get(keysym) + if code is None: + code = kl.hid_for_vk(keycode) + if code is None: + if state & 0x0001 or held_modifier & 0x02: + code = _WIN_VK_TO_HID.get(keycode) or tk_keysym_to_hid(keysym) + else: + code = tk_keysym_to_hid(keysym) or _WIN_VK_TO_HID.get(keycode) + if code is None: + return None + + modifier = held_modifier + for bit, mod in TK_STATE_MODIFIER_BITS: + if state & bit: + modifier |= mod + return code, modifier + + ANIM_LABELS = { "Static": "● statisch", "Blink": "◎ blinkend", @@ -89,9 +283,55 @@ ANIM_LABELS = { } +_display_name_cache = None + + +def _display_key_names(): + """{hid_code: Anzeigename} -- Zeichentasten mit dem Namen des aktiven + Windows-Layouts, alles andere mit der gepflegten deutschen Bezeichnung + aus _SPECIAL_KEYS ("Enter", "Bild↑", "Num5"); die liest sich besser als + das, was Windows liefert ("EINGABE", "4 (ZEHNERTASTATUR)"). + + Einmal ermittelt und behalten -- ein Layoutwechsel zur Laufzeit wird + bewusst nicht nachgezogen (siehe versapad_keylayout.key_name()). + + Die Namen sind gleichzeitig Schluessel im Dropdown und in + hid_key_code_for_name(), muessen also eindeutig bleiben. Kollisionen + entstehen real: auf deutschem Layout heisst HID 0x31 (US-Backslash- + Position) schlicht "#" -- und diesen Namen trug bisher HID 0x32 + (Non-US-#). Der Layoutname gewinnt, der verdraengte US-Name wird + gekennzeichnet statt verworfen, damit die Taste ansprechbar bleibt.""" + global _display_name_cache + if _display_name_cache is None: + names = dict(_SPECIAL_KEYS) + layout = {} + for code in sorted(_SPECIAL_KEYS): + if code not in kl.CHARACTER_HID_CODES: + continue + name = kl.key_name(code) + if name: + layout[code] = name + names.update(layout) + claimed = set(layout.values()) + for code, name in list(names.items()): + if code not in layout and name in claimed: + names[code] = f"{name} (US-Layout)" + elif code not in layout: + claimed.add(name) + _display_name_cache = names + return _display_name_cache + + +def hid_key_name(keycode): + """Anzeigename einer Taste, layoutrichtig wo es darauf ankommt + (deutsch: 0x1C -> "Z", 0x34 -> "ä"). Ohne verfuegbare Layout-Abfrage + (Nicht-Windows) bleibt es bei der US-Naeherung aus _SPECIAL_KEYS.""" + return _display_key_names().get(keycode, f"0x{keycode:02X}") + + def hid_key_choices(): """Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren.""" - return sorted(_SPECIAL_KEYS.items()) + return [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)] def consumer_choices(): @@ -99,15 +339,28 @@ def consumer_choices(): return sorted(_CONSUMER_NAMES.items()) -_KEY_CODE_BY_NAME = {name: code for code, name in _SPECIAL_KEYS.items()} _CONSUMER_ID_BY_NAME = {name: cid for cid, name in _CONSUMER_NAMES.items()} +def _key_code_by_name(): + """Namen -> Keycode, Layoutnamen haben Vorrang vor den US-Namen. + + Beide Schreibweisen bleiben gueltig, damit aeltere Aufrufe nicht brechen. + Bei einer echten Kollision (deutsch: "Z" ist US-Position 0x1D *und* + Layoutname von 0x1C) gewinnt bewusst das Layout: wer "Z" sagt, will die + Taste, die auf dieser Tastatur ein Z tippt -- alles andere waere genau + der Fehler, der hier gerade behoben wurde.""" + by_name = {name: code for code, name in _SPECIAL_KEYS.items()} + by_name.update({name: code for code, name in _display_key_names().items()}) + return by_name + + def hid_key_code_for_name(name): """z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen.""" - if name not in _KEY_CODE_BY_NAME: - raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(_KEY_CODE_BY_NAME)}") - return _KEY_CODE_BY_NAME[name] + by_name = _key_code_by_name() + if name not in by_name: + raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(by_name)}") + return by_name[name] def consumer_id_for_name(name): @@ -132,7 +385,7 @@ def macro_step_label(step): """step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante von hid_key_label() (dort steckt beides in einem 16-Bit data-Feld, hier getrennt).""" mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit] - key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}") + key = hid_key_name(step["keycode"]) return "+".join(mods + [key]) if mods else key @@ -142,12 +395,25 @@ def macro_slot_label(steps): return " → ".join(macro_step_label(s) for s in steps) +def macro_slot_choices(macros): + """["Slot 0 — Strg+C → Strg+V", ...] fuer die Slot-Auswahl im + Bearbeiten-Dialog: alle 32 Slots samt Inhalt auf einen Blick, statt sich + per Spinbox durch die Tabelle zu klicken.""" + return [f"Slot {slot} — {macro_slot_label(macros[slot] if slot < len(macros) else [])}" + for slot in range(MACRO_SLOTS)] + + +def macro_slot_from_choice(choice): + """Umkehrung von macro_slot_choices(): "Slot 7 — ..." -> 7.""" + return int(choice.split("—")[0].strip().split()[-1]) + + def hid_key_label(data): """data = keycode | (modifier << 8) -> z.B. 'Strg+S'""" keycode = data & 0xFF modifier = (data >> 8) & 0xFF mods = [name for bit, name in MODIFIER_BITS if modifier & bit] - key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}") + key = hid_key_name(keycode) return "+".join(mods + [key]) if mods else key @@ -155,8 +421,15 @@ def consumer_label(data): return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}") -def action_label(action): - """Menschenlesbarer Text für eine DeviceAction {type, data}.""" +def action_label(action, macros=None): + """Menschenlesbarer Text für eine DeviceAction {type, data}. + + macros: optional die globale 32-Slot-Makrotabelle (siehe + versapad_combined). Ist sie da, zeigt ein Makro die tatsaechliche + Tastenfolge statt nur der Slot-Nummer -- ohne die sagt "Makro (Slot 7)" + im Hauptfenster nichts darueber aus, was die Taste eigentlich tut. + Ohne macros (z.B. bei den Einzel-JSONs aus CONFIG_PATHS, die gar keine + Makro-Schritte enthalten) bleibt es beim Slot-Text.""" t = action.get("type") d = action.get("data", 0) if t == "None": @@ -166,6 +439,8 @@ def action_label(action): if t == "HidConsumer": return consumer_label(d) if t == "Macro": + if macros is not None and 0 <= d < len(macros): + return f"Makro {d}: {macro_slot_label(macros[d])}" return f"Makro (Slot {d})" if t == "ProfileSwitch": if d in (0xFFFF, 0x00FF): @@ -189,16 +464,24 @@ def button_grid_position(index): return col, row -def annotate_profile(cfg): - """Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus - einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt.""" +def annotate_profile(cfg, macros=None): + """Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob + cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck) + oder aus dem kombinierten Programmiermodus-State kommt. + + macros wird an action_label() durchgereicht, damit Makro-Belegungen + ihre echte Tastenfolge zeigen statt nur der Slot-Nummer.""" for b in cfg["buttons"]: - b["label"] = action_label(b["action"]) + b["label"] = action_label(b["action"], macros) + b["note"] = b["action"].get("note", "") b["col"], b["row"] = button_grid_position(b["index"]) for e in cfg["encoders"]: - e["sw_label"] = action_label(e["sw"]) - e["cw_label"] = action_label(e["cw"]) - e["ccw_label"] = action_label(e["ccw"]) + e["sw_label"] = action_label(e["sw"], macros) + e["sw_note"] = e["sw"].get("note", "") + e["cw_label"] = action_label(e["cw"], macros) + e["cw_note"] = e["cw"].get("note", "") + e["ccw_label"] = action_label(e["ccw"], macros) + e["ccw_note"] = e["ccw"].get("note", "") return cfg diff --git a/versapad_keylayout.py b/versapad_keylayout.py new file mode 100644 index 0000000..f3ef70e --- /dev/null +++ b/versapad_keylayout.py @@ -0,0 +1,152 @@ +""" +Abfragen ans AKTIVE Windows-Tastaturlayout: physische Tastenposition +(Scan-Code) und der Name, den diese Taste auf dem aktuellen Layout traegt. + +Warum ueberhaupt WinAPI, wo dieses Projekt sonst konsequent darauf +verzichtet: HID-Keycodes bezeichnen **physische Tastenpositionen** (das +Board sendet eine Position, erst Windows macht daraus ein Zeichen). Aus +einem Tk-Event kommen aber nur `keysym` und Virtual-Key-Code -- beides ist +bereits durch das Layout gefiltert. Auf deutschem Layout landete deshalb +jedes Y auf der Z-Taste des Boards und umgekehrt, und AeOeUe/#/+ waren gar +nicht erfassbar. Die Position ist ohne `MapVirtualKeyW` schlicht nicht zu +bekommen. + +Abgrenzung zu den Domaenenregeln in AGENTS.md: verboten sind dort +**Fenstermanipulation** (`SetWindowLongW`/`ShowWindow` aufs eigene Fenster) +und **globale Tastaturhooks** (`SetWindowsHookEx`) -- genau die Aufrufe, die +in einem anderen Projekt AV-Fehlalarme ausgeloest haben. Hier passiert +nichts davon: `MapVirtualKeyW` und `GetKeyNameTextW` sind passive, +lesende Layout-Abfragen ohne Fenster-, Prozess- oder Eingabezugriff. Die +offizielle VersaGUI (C#) benutzt `GetKeyNameText()` fuer denselben Zweck. + +Alles hier ist optional: auf Nicht-Windows oder wenn `user32` nicht laedt, +bleibt AVAILABLE False und alle Funktionen liefern None -- `versapad_data` +faellt dann auf seine US-Layout-Naeherung zurueck. +""" +import sys + +# Scan-Code (PS/2 Set 1, wie MapVirtualKeyW ihn liefert) -> HID Usage Page +# 0x07. Diese Tabelle ist layoutunabhaengig und damit der eigentliche Kern: +# eine physische Taste hat immer denselben Scan-Code und denselben HID-Code, +# egal welches Zeichen das Layout daraufschreibt. +SCANCODE_TO_HID = { + 0x01: 0x29, # Esc + 0x02: 0x1E, 0x03: 0x1F, 0x04: 0x20, 0x05: 0x21, 0x06: 0x22, + 0x07: 0x23, 0x08: 0x24, 0x09: 0x25, 0x0A: 0x26, 0x0B: 0x27, # 1-9, 0 + 0x0C: 0x2D, 0x0D: 0x2E, # US -/=, deutsch ss/Akut + 0x0E: 0x2A, 0x0F: 0x2B, # Backspace, Tab + 0x10: 0x14, 0x11: 0x1A, 0x12: 0x08, 0x13: 0x15, 0x14: 0x17, # Q W E R T + 0x15: 0x1C, 0x16: 0x18, 0x17: 0x0C, 0x18: 0x12, 0x19: 0x13, # US Y U I O P + 0x1A: 0x2F, 0x1B: 0x30, # US [/], deutsch Ue/+ + 0x1C: 0x28, # Enter + 0x1E: 0x04, 0x1F: 0x16, 0x20: 0x07, 0x21: 0x09, 0x22: 0x0A, # A S D F G + 0x23: 0x0B, 0x24: 0x0D, 0x25: 0x0E, 0x26: 0x0F, # H J K L + 0x27: 0x33, 0x28: 0x34, # US ;/', deutsch Oe/Ae + 0x29: 0x35, # US Backtick, deutsch Zirkumflex + 0x2B: 0x31, # US Backslash, deutsch # + 0x2C: 0x1D, 0x2D: 0x1B, 0x2E: 0x06, 0x2F: 0x19, 0x30: 0x05, # US Z X C V B + 0x31: 0x11, 0x32: 0x10, # N M + 0x33: 0x36, 0x34: 0x37, # Komma, Punkt (in beiden Layouts gleich) + 0x35: 0x38, # US /, deutsch - + 0x37: 0x55, # Num * + 0x39: 0x2C, # Leertaste + 0x3A: 0x39, # Caps + 0x45: 0x53, 0x46: 0x47, # NumLock, Rollen + 0x47: 0x5F, 0x48: 0x60, 0x49: 0x61, 0x4A: 0x56, # Num 7 8 9 - + 0x4B: 0x5C, 0x4C: 0x5D, 0x4D: 0x5E, 0x4E: 0x57, # Num 4 5 6 + + 0x4F: 0x59, 0x50: 0x5A, 0x51: 0x5B, # Num 1 2 3 + 0x52: 0x62, 0x53: 0x63, # Num 0 . + 0x56: 0x64, # ISO-Zusatztaste (deutsch <>|) + 0x57: 0x44, 0x58: 0x45, # F11, F12 +} +for _i in range(10): + SCANCODE_TO_HID[0x3B + _i] = 0x3A + _i # F1-F10 + +HID_TO_SCANCODE = {hid: scan for scan, hid in SCANCODE_TO_HID.items()} + +# Nur fuer diese HID-Codes lohnt der Layout-Name: es sind die Tasten, deren +# Beschriftung sich zwischen Layouts unterscheidet (Buchstaben, Ziffern, +# Satzzeichen). Fuer Enter/F5/Entf/Numpad bleiben die gepflegten deutschen +# Namen aus versapad_data._SPECIAL_KEYS besser als das, was Windows liefert +# ("EINGABE", "4 (ZEHNERTASTATUR)"). +CHARACTER_HID_CODES = ( + frozenset(range(0x04, 0x1E)) # A-Z (US-Positionen) + | frozenset(range(0x1E, 0x28)) # 1-9, 0 + | frozenset(range(0x2D, 0x39)) # Satzzeichen + | frozenset({0x64}) # ISO-Zusatztaste +) + +MAPVK_VK_TO_VSC_EX = 4 + +AVAILABLE = False +_user32 = None + +if sys.platform == "win32": + try: + import ctypes + from ctypes import wintypes + + _user32 = ctypes.WinDLL("user32", use_last_error=True) + _user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] + _user32.MapVirtualKeyW.restype = wintypes.UINT + _user32.GetKeyNameTextW.argtypes = [wintypes.LONG, wintypes.LPWSTR, ctypes.c_int] + _user32.GetKeyNameTextW.restype = ctypes.c_int + AVAILABLE = True + except (ImportError, OSError, AttributeError): + _user32 = None # z.B. exotische Python-Builds ohne ctypes + + +def scancode_for_vk(vk): + """Virtual-Key-Code -> Scan-Code der physischen Taste, oder None. + + MAPVK_VK_TO_VSC_EX setzt fuer manche Tasten ein 0xE0-Praefix ins High-Byte + (erweiterte Tasten), fuer die Pfeiltasten hier aber gemessen NICHT -- die + liefern denselben Scan-Code wie ihre Numpad-Zwillinge. Genau deshalb + laeuft die Aufloesung in versapad_data zuerst ueber die eindeutigen + Tk-keysyms und benutzt diesen Weg nur fuer Zeichentasten.""" + if not AVAILABLE: + return None + scan = _user32.MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX) + return scan or None + + +def hid_for_vk(vk): + """Virtual-Key-Code -> HID-Keycode ueber die physische Tastenposition. + Das ist der eigentliche Fix gegen vertauschtes Y/Z und nicht erfassbares + AeOeUe/#/+: der Umweg ueber das Zeichen entfaellt komplett.""" + scan = scancode_for_vk(vk) + if scan is None: + return None + if (scan >> 8) == 0xE0: + return None # erweiterte Taste -- kommt hier nicht vor, siehe oben + return SCANCODE_TO_HID.get(scan & 0xFF) + + +def key_name(hid_code): + """HID-Keycode -> Beschriftung dieser Taste auf dem aktiven Layout + (deutsch: HID 0x1C -> "Z", 0x34 -> "ä"), oder None wenn nicht ermittelbar. + + Achtung: das Ergebnis gilt fuer das Layout, das beim Aufruf aktiv ist. + Ein Layoutwechsel zur Laufzeit wird nicht bemerkt -- fuer ein Tool, das + eine Tastatur konfiguriert, ist das vertretbar (der Cache in + versapad_data haelt entsprechend auch nur eine Fassung).""" + if not AVAILABLE: + return None + scan = HID_TO_SCANCODE.get(hid_code) + if scan is None: + return None + buffer = ctypes.create_unicode_buffer(64) + # lParam-Layout von GetKeyNameTextW: Bits 16-23 Scan-Code, + # Bit 24 "erweiterte Taste". + written = _user32.GetKeyNameTextW((scan & 0xFF) << 16, buffer, 64) + if not written: + return None + name = buffer.value.strip() + if not name: + return None + # Windows liefert Mehrzeichen-Namen in Grossbuchstaben ("AKUT", + # "ZIRKUMFLEX") -- als Dropdown-Eintrag zwischen "A" und "ä" liest sich + # Titelschreibung deutlich ruhiger. + if len(name) > 1 and name == name.upper(): + return name.capitalize() + return name diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 3149cdb..c47ecc6 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -61,7 +61,21 @@ def _profile_switch_data(target): def _describe_action(action): - return {"type": action["type"], "data": action["data"], "label": vp.action_label(action)} + """label zeigt bei Makros die echte Tastenfolge statt nur der + Slot-Nummer (gleiche Darstellung wie im Hauptfenster) -- die Makrotabelle + liegt im selben State, also kein Grund, hier weniger zu verraten.""" + return {"type": action["type"], "data": action["data"], + "label": vp.action_label(action, _cfg().get("macros")), + "note": action.get("note", "")} + + +def _replace_action(old_action, new_type, new_data): + """Baut eine neue Action mit neuem Typ/Daten, behaelt aber die Notiz vom + vorherigen Stand bei (rein lokal, unabhaengig davon was die Aktion tut -- + ein Tastenwechsel soll die Beschreibung 'was der Button macht' nicht + loeschen). Zum Loeschen explizit set_button_note()/set_encoder_note() + mit leerem String.""" + return {"type": new_type, "data": new_data, "note": old_action.get("note", "")} # ── Lesen ──────────────────────────────────────────────────────────────────── @@ -116,9 +130,15 @@ def get_macro(slot: int) -> dict: def get_board_status() -> dict: """Prueft per Serial, ob das Board erreichbar ist und welches Profil dort gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der - Tkinter-Viewer den COM-Port gerade halten.""" - profile = _link.read_active_profile() - return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} + Tkinter-Viewer den COM-Port gerade halten. Gibt den COM-Port danach + sofort wieder frei (kein dauerhaft offen gehaltener Serial-Handle -- + sonst blockiert dieser Prozess andere Tools/Viewer mit "busy", bis er + beendet wird).""" + try: + profile = _link.read_active_profile() + return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} + finally: + _link.close() # ── Buttons (20 pro Profil, MX-Matrix) ─────────────────────────────────────── @@ -132,7 +152,7 @@ def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = [] btn = _find(_profile(profile)["buttons"], index) keycode = vp.hid_key_code_for_name(key) mod_bits = vp.modifier_bits_for_names(modifiers) - btn["action"] = {"type": "HidKey", "data": (mod_bits << 8) | keycode} + btn["action"] = _replace_action(btn["action"], "HidKey", (mod_bits << 8) | keycode) return _describe_action(btn["action"]) @@ -142,7 +162,7 @@ def set_button_consumer(profile: int, index: int, consumer: str) -> dict: 'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'.""" btn = _find(_profile(profile)["buttons"], index) cid = vp.consumer_id_for_name(consumer) - btn["action"] = {"type": "HidConsumer", "data": cid} + btn["action"] = _replace_action(btn["action"], "HidConsumer", cid) return _describe_action(btn["action"]) @@ -151,7 +171,7 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict: """Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst mit set_macro() befuellen.""" btn = _find(_profile(profile)["buttons"], index) - btn["action"] = {"type": "Macro", "data": slot} + btn["action"] = _replace_action(btn["action"], "Macro", slot) return _describe_action(btn["action"]) @@ -159,15 +179,27 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict: def set_button_profile_switch(profile: int, index: int, target) -> dict: """Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2.""" btn = _find(_profile(profile)["buttons"], index) - btn["action"] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)} + btn["action"] = _replace_action(btn["action"], "ProfileSwitch", _profile_switch_data(target)) return _describe_action(btn["action"]) @mcp.tool() def set_button_none(profile: int, index: int) -> dict: - """Entfernt die Belegung eines MX-Buttons (Action = None).""" + """Entfernt die Belegung eines MX-Buttons (Action = None). Notiz bleibt + erhalten -- zum Loeschen set_button_note(profile, index, "").""" btn = _find(_profile(profile)["buttons"], index) - btn["action"] = {"type": "None", "data": 0} + btn["action"] = _replace_action(btn["action"], "None", 0) + return _describe_action(btn["action"]) + + +@mcp.tool() +def set_button_note(profile: int, index: int, note: str) -> dict: + """Setzt/aendert die freie Notiz eines MX-Buttons -- was der Button tut, + unabhaengig von der technischen Aktion (z.B. 'Speichern in Fusion 360'). + Rein lokal, landet nie aufs Board (wie Profilnamen). Leerer String + loescht die Notiz.""" + btn = _find(_profile(profile)["buttons"], index) + btn["action"]["note"] = note return _describe_action(btn["action"]) @@ -199,7 +231,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l enc, f = _encoder_field(profile, index, field) keycode = vp.hid_key_code_for_name(key) mod_bits = vp.modifier_bits_for_names(modifiers) - enc[f] = {"type": "HidKey", "data": (mod_bits << 8) | keycode} + enc[f] = _replace_action(enc[f], "HidKey", (mod_bits << 8) | keycode) return _describe_action(enc[f]) @@ -207,7 +239,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict: """Belegt eine Encoder-Aktion mit einer Medientaste.""" enc, f = _encoder_field(profile, index, field) - enc[f] = {"type": "HidConsumer", "data": vp.consumer_id_for_name(consumer)} + enc[f] = _replace_action(enc[f], "HidConsumer", vp.consumer_id_for_name(consumer)) return _describe_action(enc[f]) @@ -215,7 +247,7 @@ def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict: """Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31).""" enc, f = _encoder_field(profile, index, field) - enc[f] = {"type": "Macro", "data": slot} + enc[f] = _replace_action(enc[f], "Macro", slot) return _describe_action(enc[f]) @@ -225,15 +257,26 @@ def set_encoder_profile_switch(profile: int, index: int, field: str, target) -> Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern.""" enc, f = _encoder_field(profile, index, field) - enc[f] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)} + enc[f] = _replace_action(enc[f], "ProfileSwitch", _profile_switch_data(target)) return _describe_action(enc[f]) @mcp.tool() def set_encoder_none(profile: int, index: int, field: str) -> dict: - """Entfernt eine Encoder-Belegung (Action = None).""" + """Entfernt eine Encoder-Belegung (Action = None). Notiz bleibt erhalten + -- zum Loeschen set_encoder_note(profile, index, field, "").""" enc, f = _encoder_field(profile, index, field) - enc[f] = {"type": "None", "data": 0} + enc[f] = _replace_action(enc[f], "None", 0) + return _describe_action(enc[f]) + + +@mcp.tool() +def set_encoder_note(profile: int, index: int, field: str, note: str) -> dict: + """Setzt/aendert die freie Notiz einer Encoder-Aktion (sw/cw/ccw) -- was + sie tut, unabhaengig von der technischen Aktion. Rein lokal, landet nie + aufs Board. Leerer String loescht die Notiz.""" + enc, f = _encoder_field(profile, index, field) + enc[f]["note"] = note return _describe_action(enc[f]) @@ -297,24 +340,30 @@ def load_local(path: str = None) -> dict: @mcp.tool() def load_from_board() -> dict: """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und - ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die - kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade - von VersaGUI/dem Tkinter-Viewer gehalten wird.""" - raw_cfg = _link.read_full_config() - if raw_cfg is None: - raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") - raw_macros = _link.read_macros() - if raw_macros is None: - raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}") + ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben + erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der + COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den + COM-Port danach sofort wieder frei (siehe get_board_status()).""" + try: + raw_cfg = _link.read_full_config() + if raw_cfg is None: + raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") + raw_macros = _link.read_macros() + if raw_macros is None: + raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}") - cfg_dict = vproto.unpack_config(raw_cfg) - if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): - raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") - macro_slots = vproto.unpack_macros(raw_macros) + cfg_dict = vproto.unpack_config(raw_cfg) + if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): + raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") + macro_slots = vproto.unpack_macros(raw_macros) - names = _cfg()["profile_names"] - _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) - return list_profiles() + names = _cfg()["profile_names"] + previous = _state["combined"] + _state["combined"] = vcomb.merge_notes( + vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous) + return list_profiles() + finally: + _link.close() @mcp.tool() @@ -322,13 +371,17 @@ def write_to_board() -> dict: """Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit - NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.""" - cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) - if not _link.write_full_config(cfg_bytes): - raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") - if not _link.write_macros(macro_bytes): - raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") - return {"ok": True} + NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port. + Gibt den COM-Port danach sofort wieder frei (siehe get_board_status()).""" + try: + cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) + if not _link.write_full_config(cfg_bytes): + raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") + if not _link.write_macros(macro_bytes): + raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") + return {"ok": True} + finally: + _link.close() if __name__ == "__main__":