diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 2b7f17d..0000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 aab3b1b..4773d92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,11 +16,6 @@ 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. @@ -59,56 +54,27 @@ Nutzerorientierte Einführung: [`README.md`](README.md). `read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz). -- `versapad_keylayout.py` (seit 2026-08-29) — passive Abfragen ans aktive - Windows-Tastaturlayout: `hid_for_vk()` (Virtual-Key → Scan-Code → HID über - `MapVirtualKeyW`) und `key_name()` (Beschriftung über `GetKeyNameTextW`). - Optional: auf Nicht-Windows/ohne ctypes bleibt `AVAILABLE` False und - `versapad_data` faellt auf seine US-Naeherung zurueck. Zur Abgrenzung - gegen die WinAPI-Verbote siehe Domaenenregeln. - **UI:** - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi (Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln), Tray-Icon statt Taskleisten-Minimierung, Info-Button mit MCP-Doku. - `action_dialog.py` — Bearbeiten-Dialoge (`ActionEditDialog`, - `MacroStepsDialog`) für den Programmiermodus. Gemeinsame Basis - `_ModalDialog` (Positionierung über dem Elternfenster, Enter/Escape, - Verteilung der Tastenevents) und `_KeyCapture` (Tastendruck-Erkennung). + `MacroStepsDialog`) für den Programmiermodus. **MCP-Server:** -- `versapad_mcp_server.py` — registriert als projektgebundener MCP-Server - "versapad" über `.mcp.json` im Projektordner (Alternative: - `claude mcp add -s user versapad -- versapad_mcp_server.py`). +- `versapad_mcp_server.py` — registriert als User-Scope-MCP-Server + "versapad" (`claude mcp add -s user versapad -- versapad_mcp_server.py`). Nutzt MCP-SDK v2 (Paket `mcp`, Klasse `mcp.server.mcpserver.MCPServer` — **nicht** `FastMCP` aus `mcp.server.fastmcp`, das existiert in dieser SDK-Version nicht mehr, wurde umbenannt). `@mcp.tool()`-dekorierte Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei älteren FastMCP-Versionen). Tool-Liste: siehe README oder `MCP_INFO_TEXT` in `desktop_viewer.py`. -- **Board-Serial-Tools schliessen den Link nach jedem Aufruf** (`get_board_status`, - `load_from_board`, `write_to_board` — `finally: _link.close()`). Grund: - `VersaPadLink` schliesst nie von selbst, ein einzelner Aufruf hätte sonst - den exklusiven COM-Port dauerhaft für den Rest des MCP-Serverprozesses - blockiert und Live-Sync/VersaGUI/den nächsten Aufruf mit "busy" ausgesperrt - (am 2026-08-14 live so aufgetreten, siehe unten). -- **Bug beobachtet 2026-08-14:** In diesem Agenten-Environment (Claude-Code- - VSCode-Extension) können mehrere unabhängige `versapad_mcp_server.py`- - Prozesse gleichzeitig laufen (bis zu 8 beobachtet, vermutlich durch - wiederholte Tool-Ladevorgänge/Reconnects innerhalb einer Session) — jeder - mit eigenem, nicht geteiltem In-Memory-State (`_state["combined"]`). - Konkret beobachtet: `set_button_*`/`set_macro` + `save_local()` liefen - korrekt auf einem Prozess, ein späterer `write_to_board()`-Aufruf landete - aber auf einem anderen (frischen, leeren) Prozess und schrieb versehentlich - eine leere Default-Config aufs Board, trotz `{"ok": true}`-Antwort. Fix: - vor `write_to_board()` immer erst `load_local()` (liest die Datei frisch - von der Platte, unabhängig davon welcher Prozess antwortet), und nach - jedem Schreibvorgang mit `load_from_board()` + `get_profile()` gegenlesen - statt dem ACK allein zu vertrauen — genau dieses Verify-Pattern hat den - Fehler hier live aufgedeckt. Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code -nachschlagen. Menschenlesbare Referenzdoku (Architektur, Datenmodell, -Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. +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). ## Kritische Domänenregeln @@ -188,7 +154,7 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. (`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 + 2026-08-15 überholt und war aktiv schädlich, siehe unten "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 @@ -204,18 +170,24 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. 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. +- **Herkunft der drei Punkte oben + Tab-Umbenennen-Fix:** per Cherry-Pick aus + Julian Appels eigenem `dev/jappel`-Branch (`git.jappel.io/jappel/ + VersaGUI-py`) übernommen, der sich zeitgleich mit unserem eigenen + COM-Port-Fix entwickelt hat (kein Fork-Sync-Automatismus -- manuell + gegengelesen und übernommen, siehe Sessionlog). **Eine Abweichung vom + Original:** dort landet der Build jetzt in `$projectDir\dist\ + VersaPadViewer` statt `%LOCALAPPDATA%\VersaPadViewer` -- hier bewusst + NICHT übernommen, weil das die tatsächlich installierte/genutzte Instanz + wäre und bestehende Verknüpfungen sonst ins Leere zeigen würden. + `build_and_deploy.ps1` hier weiterhin `%LOCALAPPDATA%\VersaPadViewer`. + Bestehende `versapad_config_all.json` vom alten Desktop-Pfad wurde einmalig + nach `%LOCALAPPDATA%\VersaPadViewer\` migriert (kopiert, Original bleibt). + Nicht übernommen: die `.mcp.json`-Registrierung (hartkodierter Pfad auf + Julians Maschine, `C:\Users\Julian\...`) -- dafür stattdessen ein Issue in + seinem Repo (git.jappel.io/jappel/VersaGUI-py) angelegt, damit relative/ + portable Pfadauflösung dort nachgezogen werden kann; die drei + `docs/*.md`-Referenzdateien (unser eigener Dokumentationsstandard hält für + dieses Tool bewusst bei README + AGENTS.md ohne vollen `docs/`-Baum). - **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 @@ -242,135 +214,30 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. `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. +- **Randloses Fenster (seit 2026-08-15):** Die Titelleiste ist per reinem Tk + `overrideredirect(True)` ausgeblendet -- **niemals** per ctypes/WinAPI + nachhelfen (kein `SetWindowLongW`/`ShowWindow` auf das eigene Fenster), + siehe Speicher-Notiz "keine Selbstversteck-Fenstertricks": genau diese + Kombination hat in einem anderen Projekt Bitdefender-Fehlalarme + ausgelöst. Konsequenzen, die mitgebaut werden müssen: kein + Taskleisten-Eintrag (Rückweg nur über das Tray-Icon), kein Ziehen am + Rahmen (Kopfzeile ist deshalb per `` verschiebbar), keine + System-Buttons (eigene `✕`/`—` in der Kopfzeile, beide legen ins Tray) + und **keine Resize-Ränder**. +- **Der Größen-Anfasser hängt per `place()` an der Fensterecke, nicht + gepackt am Ende des Inhalts.** Gepackt verschwindet er, sobald das Fenster + kleiner als der Inhalt ist -- also exakt dann, wenn man ihn zum + Vergrößern bräuchte. `MIN_W/MIN_H` begrenzen das Verkleinern. - Fensterhöhe muss zum Karteninhalt passen: seit die Karten eine Notizzeile haben (`CARD_H` 84 → 114) braucht das Fenster ~960px, sonst liegen Encoder-Bereich und Fußzeile unterhalb des Rands. Wer `CARD_H` ändert, muss `geometry()` mit anpassen. -- **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). +- 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). - 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 @@ -449,10 +316,14 @@ 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 @@ -465,32 +336,18 @@ 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, 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. + 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. 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 diff --git a/README.md b/README.md index 0c84f10..4ca6982 100644 --- a/README.md +++ b/README.md @@ -30,32 +30,9 @@ falls gewünscht. 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. 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 + oder als Datei speichern - **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte - 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 + Makro-Tabelle vom Board - **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, @@ -64,13 +41,11 @@ falls gewünscht. 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. +- **Randloses Fenster** — ohne Windows-Titelleiste, dafür kompakter Kopf + (Modus-Checkboxen direkt neben dem Titel). Verschieben durch Ziehen an + der Kopfzeile, Größe ändern am Anfasser unten rechts, `✕`/`—` legen ins + Tray. Einen Taskleisten-Eintrag gibt es dadurch nicht — das Fenster kommt + über das Tray-Icon zurück. ## Voraussetzungen @@ -127,8 +102,7 @@ automatisch zuerst nach `%TEMP%` und baut nur dort. | Datei | Zweck | |---|---| -| `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_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text | | `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 | @@ -167,12 +141,9 @@ OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in `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). 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`. +Claude Desktop, andere). In der MCP-Server-Liste der jeweiligen Anwendung +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`/ @@ -189,33 +160,18 @@ 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) -- 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 +- 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 - 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 -Agentenseitige Notizen (Domänenregeln, bekannte Bugs und ihre Fixes, -Implementierungsdisziplin, Design-Entscheidungen) stehen in -[`AGENTS.md`](AGENTS.md). +Tiefere technische Notizen (Protokoll-Details, bekannte Stolpersteine beim +Bauen, Design-Entscheidungen) stehen in [`AGENTS.md`](AGENTS.md). ## Lizenz / Herkunft diff --git a/action_dialog.py b/action_dialog.py index 511c091..6a5ccf2 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -1,24 +1,12 @@ """ Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu -VersaGUI/src/ActionDialog.cs, in Tkinter. +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()). 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 @@ -30,7 +18,6 @@ BG2 = "#14161b" TEXT = "#e8e8ec" TEXT_DIM = "#8a8d98" ACCENT = "#3a6ff0" -CAPTURE_BG = "#c04a2a" TYPE_CHOICES = [ ("None", "Keine"), @@ -47,100 +34,8 @@ 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) @@ -148,116 +43,18 @@ 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.""" @@ -271,81 +68,43 @@ class MacroStepsDialog(_ModalDialog): self._code_by_name = {name: code for code, name in key_choices} names = ["(leer)"] + [name for _, name in key_choices] - 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) + 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)) self._key_vars = [] self._mod_vars = [] for i in range(8): - r = i + 2 + r = i + 1 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)") - ttk.Combobox(self, textvariable=key_var, values=names, width=16, - state="readonly").grid(row=r, column=1, padx=4, pady=2) + combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly") + combo.grid(row=r, column=1, padx=4, pady=2) self._key_vars.append(key_var) mods = {} - for j, (label, _bit) in enumerate(MACRO_MOD_CHECKBOXES): + for j, label in enumerate(("Strg", "Shift", "Alt")): 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) - 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) - for i, step in enumerate(steps[:8]): - self._apply_step(i, step["keycode"], step["modifier"]) + 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._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() + 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) def _on_ok(self): steps = [] @@ -353,8 +112,11 @@ class MacroStepsDialog(_ModalDialog): name = self._key_vars[i].get() if name == "(leer)": break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren - modifier = sum(bit for label, bit in MACRO_MOD_CHECKBOXES - if self._mod_vars[i][label].get()) + 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) + ) steps.append({"keycode": self._code_by_name[name], "modifier": modifier}) self.steps = steps self._finish(False) @@ -378,8 +140,8 @@ class ActionEditDialog(_ModalDialog): 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.Entry(note_row, textvariable=self._note_var, width=36, 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=(4, 4)); row += 1 @@ -389,15 +151,13 @@ class ActionEditDialog(_ModalDialog): 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)) - # 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) + 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) row += 1 # HidKey-Panel @@ -414,16 +174,17 @@ class ActionEditDialog(_ModalDialog): self._consumer_var = tk.StringVar() # Macro-Panel - self._macro_slot = action["data"] if action["type"] == "Macro" else 0 - self._macro_choice_var = tk.StringVar() + self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0) + self._macro_preview_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 = (action["data"] >> 8) & 0xFF + self._hidkey_mods_init = modifier else: self._hidkey_key_var.set("(leer)") self._hidkey_mods_init = 0 @@ -445,67 +206,45 @@ 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: - row = self._build_led_panel(row) + 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) - 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", parent=self) + result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe") if result and result[0]: - self._set_color(tuple(int(c) for c in result[0])) - - # ── Action-Panels ───────────────────────────────────────────────── + r, g, b = (int(c) for c in result[0]) + self._led_color = (r, g, b) + self._swatch.configure(bg=self._hex()) def _on_type_change(self): for w in self._panel.winfo_children(): @@ -513,105 +252,80 @@ class ActionEditDialog(_ModalDialog): t = self._type_var.get() if t == "HidKey": - self._build_hidkey_panel() + 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) + 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") - ttk.Combobox(row1, textvariable=self._consumer_var, - values=[n for _, n in vp.consumer_choices()], - 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") + 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) + elif t == "Macro": - self._build_macro_panel() + 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() + 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 _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 _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 _edit_macro_steps(self): - slot = self._macro_slot + slot = self._macro_slot_var.get() 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_choices() - - # ── Ergebnis ────────────────────────────────────────────────────── + self._refresh_macro_preview() def _on_ok(self): t = self._type_var.get() - if t == "HidKey": - keycode = self._key_code_by_name.get(self._hidkey_key_var.get(), 0) + 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) 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} + action = {"type": "Macro", "data": self._macro_slot_var.get()} elif t == "ProfileSwitch": name = self._profile_switch_var.get() - action = {"type": "ProfileSwitch", - "data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)} + data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name) + action = {"type": "ProfileSwitch", "data": data} else: action = {"type": "None", "data": 0} diff --git a/desktop_viewer.py b/desktop_viewer.py index 6909ed5..a27e191 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -19,7 +19,6 @@ automatisch ausgeschaltet, damit sich beide nicht um den Port streiten). Start: python desktop_viewer.py """ -import copy import os import queue import sys @@ -56,9 +55,8 @@ WARN_RED = "#e0895a" POLL_MS = 1500 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. +# Untergrenze beim Ziehen am Anfasser -- 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 @@ -131,8 +129,8 @@ class VersaPadViewer(tk.Tk): self.configure(bg=BG) # 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. + # reichten die alten 760px nicht mehr: Encoder und der Groessen- + # Anfasser lagen unterhalb des Fensterrands und waren unerreichbar. self.geometry("790x960") self.profile = 0 self._mtimes = {} @@ -140,12 +138,6 @@ class VersaPadViewer(tk.Tk): 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() @@ -166,23 +158,32 @@ class VersaPadViewer(tk.Tk): ) self._tray_icon.run_detached() - # 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) + # Titelleiste ausgeblendet (reines Tk `overrideredirect`, KEINE + # ctypes/WinAPI-Fenstertricks -- siehe Speicher-Notiz "keine + # Selbstversteck-Fenstertricks": das Nachruesten eines Taskleisten- + # Icons per SetWindowLongW hat frueher AV-Fehlalarme ausgeloest). + # Ersatz fuer die fehlende Systemleiste: Kopfzeile ist ziehbar, und + # die Buttons rechts uebernehmen Minimieren/Schliessen (beides ins + # Tray, wie vorher schon das X der echten Titelleiste). + self.overrideredirect(True) 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)) + title = tk.Label(header, text="VersaPad", bg=BG, fg=TEXT, + font=("Segoe UI", 11, "bold")) + title.pack(side="left", anchor="w", padx=(0, 16)) + + for widget in (header, title): + widget.bind("", self._start_move) + widget.bind("", self._on_move) + + for text in ("✕", "—"): + btn = tk.Label(header, text=text, bg=BG, fg=TEXT_DIM, + font=("Segoe UI", 11), cursor="hand2", padx=6) + btn.pack(side="right") + btn.bind("", lambda e: self._hide_to_tray()) + btn.bind("", lambda e, b=btn: b.configure(fg=TEXT)) + btn.bind("", lambda e, b=btn: b.configure(fg=TEXT_DIM)) info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12), cursor="hand2", padx=6) @@ -229,10 +230,6 @@ class VersaPadViewer(tk.Tk): 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 @@ -256,19 +253,29 @@ class VersaPadViewer(tk.Tk): self.enc_frame = tk.Frame(self, bg=BG) self.enc_frame.pack(fill="x", padx=20) + # Fusszeile + Anfasser zum Groessenaendern: mit ausgeblendeter + # Titelleiste (overrideredirect) entfernt Windows auch die + # Fensterraender, an denen man sonst zieht -- ohne diesen Griff + # liesse sich das Fenster gar nicht mehr skalieren. 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. + # Der Griff haengt per place() an der FENSTER-Ecke, nicht am Ende des + # gepackten Inhalts: sonst wandert er mit dem Inhalt aus dem Bild, + # sobald das Fenster kleiner als der Inhalt ist -- also genau dann, + # wenn man ihn zum Vergroessern braucht. + grip = tk.Label(self, text="◢", bg=BG, fg=TEXT_DIM, + font=("Segoe UI", 11), cursor="sizing") + grip.place(relx=1.0, rely=1.0, anchor="se", x=-3, y=-1) + grip.bind("", self._start_resize) + grip.bind("", self._on_resize) + grip.bind("", lambda e: grip.configure(fg=TEXT)) + grip.bind("", lambda e: grip.configure(fg=TEXT_DIM)) + self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) - self._bind_shortcuts() + self.bind("", self._on_unmap) self._serial_thread.start() self.set_profile(0) @@ -369,6 +376,25 @@ class VersaPadViewer(tk.Tk): def _on_toggle_topmost(self): self.attributes("-topmost", self.always_on_top.get()) + # ── Fenster ziehen (Ersatz fuer die ausgeblendete Titelleiste) ── + + def _start_move(self, event): + self._drag_origin = (event.x_root - self.winfo_x(), event.y_root - self.winfo_y()) + + def _on_move(self, event): + dx, dy = self._drag_origin + self.geometry(f"+{event.x_root - dx}+{event.y_root - dy}") + + def _start_resize(self, event): + self._resize_origin = (event.x_root, event.y_root, + self.winfo_width(), self.winfo_height()) + + def _on_resize(self, event): + x0, y0, w0, h0 = self._resize_origin + width = max(MIN_W, w0 + (event.x_root - x0)) + height = max(MIN_H, h0 + (event.y_root - y0)) + self.geometry(f"{width}x{height}") + # ── Live-Sync mit dem Board ──────────────────────────────────── def _on_toggle_sync(self): @@ -414,7 +440,13 @@ class VersaPadViewer(tk.Tk): text = SERIAL_STATUS_TEXT.get(error, error or "Fehler") self.sync_status.configure(text=text, fg=WARN_RED) - # ── Tray-Icon (Schliessen beendet nicht, wie bei der VersaGUI) ── + # ── 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() def _hide_to_tray(self): self.withdraw() @@ -585,183 +617,6 @@ 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): @@ -786,7 +641,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"]], - }, data.get("macros")) + }) return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" except (KeyError, IndexError, ValueError): pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen @@ -812,7 +667,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: @@ -868,9 +723,11 @@ class VersaPadViewer(tk.Tk): x=10, y=CARD_H - 16, width=CARD_W - 20, height=13) if editable: - target = {"kind": "button", "index": btn["index"], "card": card, - "label": "Button #{}".format(btn["index"])} - self._bind_cell_interaction(card, [card] + list(card.winfo_children()), target) + 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) def _render_encoder(self, enc, editable=False): card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER, @@ -898,13 +755,14 @@ class VersaPadViewer(tk.Tk): wraplength=150, justify="left", anchor="w") note_label.pack(fill="x") if editable: - 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()) + row.configure(cursor="hand2") + handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl) + row.bind("", handler) + top.bind("", handler) if note_label is not None: - widgets.append(note_label) - self._bind_cell_interaction(row, widgets, target) + note_label.bind("", handler) + for child in top.winfo_children(): + child.bind("", handler) if __name__ == "__main__": diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 66424cd..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,267 +0,0 @@ -# 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 deleted file mode 100644 index 3491f11..0000000 --- a/docs/data-model.md +++ /dev/null @@ -1,216 +0,0 @@ -# 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 deleted file mode 100644 index 73f76e7..0000000 --- a/docs/protocol.md +++ /dev/null @@ -1,136 +0,0 @@ -# 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/server.py b/server.py index 7e1b181..238d13c 100644 --- a/server.py +++ b/server.py @@ -109,7 +109,7 @@ def render_page(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(combined["profile_names"][p])}' for p in range(vp.NUM_PROFILES) diff --git a/versapad_data.py b/versapad_data.py index 46705ec..58c122f 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -7,15 +7,10 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. """ import json import os - -import versapad_keylayout as kl +import sys 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" @@ -114,164 +109,6 @@ _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", @@ -283,55 +120,9 @@ 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 [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)] + return sorted(_SPECIAL_KEYS.items()) def consumer_choices(): @@ -339,28 +130,15 @@ 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.""" - 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] + 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] def consumer_id_for_name(name): @@ -385,7 +163,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 = hid_key_name(step["keycode"]) + key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}") return "+".join(mods + [key]) if mods else key @@ -395,25 +173,12 @@ 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 = hid_key_name(keycode) + key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}") return "+".join(mods + [key]) if mods else key @@ -421,15 +186,8 @@ def consumer_label(data): return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}") -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.""" +def action_label(action): + """Menschenlesbarer Text für eine DeviceAction {type, data}.""" t = action.get("type") d = action.get("data", 0) if t == "None": @@ -439,8 +197,6 @@ def action_label(action, macros=None): 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): @@ -464,23 +220,20 @@ def button_grid_position(index): return col, row -def annotate_profile(cfg, macros=None): +def annotate_profile(cfg): """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.""" + oder aus dem kombinierten Programmiermodus-State kommt.""" for b in cfg["buttons"]: - b["label"] = action_label(b["action"], macros) + b["label"] = action_label(b["action"]) 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"], macros) + e["sw_label"] = action_label(e["sw"]) e["sw_note"] = e["sw"].get("note", "") - e["cw_label"] = action_label(e["cw"], macros) + e["cw_label"] = action_label(e["cw"]) e["cw_note"] = e["cw"].get("note", "") - e["ccw_label"] = action_label(e["ccw"], macros) + e["ccw_label"] = action_label(e["ccw"]) e["ccw_note"] = e["ccw"].get("note", "") return cfg diff --git a/versapad_keylayout.py b/versapad_keylayout.py deleted file mode 100644 index f3ef70e..0000000 --- a/versapad_keylayout.py +++ /dev/null @@ -1,152 +0,0 @@ -""" -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 c47ecc6..5a262c7 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -61,11 +61,7 @@ def _profile_switch_data(target): def _describe_action(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")), + return {"type": action["type"], "data": action["data"], "label": vp.action_label(action), "note": action.get("note", "")}