Compare commits
28 commits
fix/live-s
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d9d7d90654 | |||
| 78e9640dec | |||
| e1e3d0e466 | |||
| 50868ab081 | |||
| 42897d553c | |||
| 8a00b3ae36 | |||
| 21f9cbc2ed | |||
| aba81463e5 | |||
| f6f4edbdde | |||
| 73c1135655 | |||
| 517f12c960 | |||
| 189ec01e68 | |||
|
|
9464cafaab | ||
|
|
91bac353b4 | ||
|
|
4b5da69174 | ||
|
|
9f0ae12794 | ||
| 02d6c0c5e9 | |||
| 9e21360393 | |||
| b4ad7698ed | |||
| 83429363c1 | |||
| 7d40fdaa60 | |||
| 09fbd6ad96 | |||
| 82c3fd0056 | |||
| 5d14bdd826 | |||
| 13c3745479 | |||
|
|
8ba524c07b | ||
|
|
540ce5b1eb | ||
|
|
8a2a73c68d |
17 changed files with 2665 additions and 361 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -3,3 +3,7 @@ __pycache__/
|
||||||
*.log
|
*.log
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Nutzer-Config -- landet neben der Installation (versapad_data.app_dir()),
|
||||||
|
# beim Start aus dem Quellcode also direkt hier im Projektordner
|
||||||
|
versapad_config_all.json
|
||||||
|
|
|
||||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"versapad": {
|
||||||
|
"command": "py",
|
||||||
|
"args": ["C:\\Users\\Julian\\Documents\\__CODE\\VersaGUI-py\\versapad_mcp_server.py"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
373
AGENTS.md
373
AGENTS.md
|
|
@ -16,6 +16,17 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
|
||||||
- `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/
|
- `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/
|
||||||
Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie
|
Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie
|
||||||
(`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`.
|
(`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`.
|
||||||
|
Seit 2026-08-28 zusätzlich `tk_event_to_hid()` (Tk-Tastendruck →
|
||||||
|
HID-Keycode+Modifier, siehe Tastendruck-Erkennung unten) und
|
||||||
|
`macro_slot_choices()`/`macro_slot_from_choice()` für die Slot-Auswahl.
|
||||||
|
`action_label()`/`annotate_profile()` nehmen die Makrotabelle optional
|
||||||
|
entgegen und zeigen dann statt „Makro (Slot 7)" die echte Tastenfolge.
|
||||||
|
`app_dir()` liefert das Verzeichnis für die eigene Config
|
||||||
|
(`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations-
|
||||||
|
ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten.
|
||||||
|
`CONFIG_PATHS` bleibt bewusst auf dem OneDrive-Desktop hartkodiert (Lese-
|
||||||
|
Interop mit einem JSON-Export der offiziellen VersaGUI, kein von diesem
|
||||||
|
Tool geschriebenes Format, siehe dort).
|
||||||
- `server.py` — stdlib `http.server`, generiert HTML pro Request neu,
|
- `server.py` — stdlib `http.server`, generiert HTML pro Request neu,
|
||||||
Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend,
|
Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend,
|
||||||
kein Programmiermodus (bewusst einfach gehalten).
|
kein Programmiermodus (bewusst einfach gehalten).
|
||||||
|
|
@ -29,33 +40,75 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
|
||||||
Makros per 8-Byte-Paket-Protokoll, Board-Identifikation per VID/PID
|
Makros per 8-Byte-Paket-Protokoll, Board-Identifikation per VID/PID
|
||||||
`239A:0042`. Schreiben ist sicher im Sinne von "kann NVM nicht zerlegen"
|
`239A:0042`. Schreiben ist sicher im Sinne von "kann NVM nicht zerlegen"
|
||||||
— Firmware prüft Magic/CRC/Keycode-Bereich vor jedem Save, antwortet
|
— Firmware prüft Magic/CRC/Keycode-Bereich vor jedem Save, antwortet
|
||||||
sonst nur mit NACK.
|
sonst nur mit NACK. `read_active_profile()` (für Live-Sync-Polling)
|
||||||
|
nutzt seit 2026-08-07 `CMD_READ_STATUS`/`EVT_STATUS` (0x06/0x86, ein
|
||||||
|
Antwortpaket) statt eines vollen `CONFIG_READ`-Dumps (124 Pakete) —
|
||||||
|
Letzterer blockiert die Firmware in `poll_vendor()` lang genug, dass
|
||||||
|
laufende LED-Pulse-Animationen sichtbar stottern (Root Cause + Fix in
|
||||||
|
VersaMCU-Commit "Add lightweight READ_STATUS command..."). **Braucht
|
||||||
|
entsprechend neue Firmware auf dem Board** — mit altem `versapad`-Firmwarestand
|
||||||
|
liefert `READ_STATUS` schlicht Timeout, kein Absturz.
|
||||||
- `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros +
|
- `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros +
|
||||||
**nur lokal gespeicherte** Profilnamen), Default-Pfad
|
**nur lokal gespeicherte** Profilnamen), Default-Pfad
|
||||||
`~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll:
|
`versapad_data.app_dir()\versapad_config_all.json` (neben der
|
||||||
`CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den kompletten 740B-Block.
|
Installation, nicht mehr hartkodiert auf einer bestimmten Maschine).
|
||||||
|
Passt zum Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer
|
||||||
|
den kompletten 740B-Block. `load_or_fetch()` legt bei fehlender Datei
|
||||||
|
automatisch eine neue an — erst Versuch per Serial vom Board, sonst als
|
||||||
|
leere `default_combined()` (kein Board noetig fuer die Erstbenutzung).
|
||||||
|
`read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer
|
||||||
|
Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz).
|
||||||
|
|
||||||
|
- `versapad_keylayout.py` (seit 2026-08-29) — passive Abfragen ans aktive
|
||||||
|
Windows-Tastaturlayout: `hid_for_vk()` (Virtual-Key → Scan-Code → HID über
|
||||||
|
`MapVirtualKeyW`) und `key_name()` (Beschriftung über `GetKeyNameTextW`).
|
||||||
|
Optional: auf Nicht-Windows/ohne ctypes bleibt `AVAILABLE` False und
|
||||||
|
`versapad_data` faellt auf seine US-Naeherung zurueck. Zur Abgrenzung
|
||||||
|
gegen die WinAPI-Verbote siehe Domaenenregeln.
|
||||||
|
|
||||||
**UI:**
|
**UI:**
|
||||||
- `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi
|
- `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi
|
||||||
(Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln),
|
(Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln),
|
||||||
Tray-Icon statt Taskleisten-Minimierung, Info-Button mit MCP-Doku.
|
Tray-Icon statt Taskleisten-Minimierung, Info-Button mit MCP-Doku.
|
||||||
- `action_dialog.py` — Bearbeiten-Dialoge (`ActionEditDialog`,
|
- `action_dialog.py` — Bearbeiten-Dialoge (`ActionEditDialog`,
|
||||||
`MacroStepsDialog`) für den Programmiermodus.
|
`MacroStepsDialog`) für den Programmiermodus. Gemeinsame Basis
|
||||||
|
`_ModalDialog` (Positionierung über dem Elternfenster, Enter/Escape,
|
||||||
|
Verteilung der Tastenevents) und `_KeyCapture` (Tastendruck-Erkennung).
|
||||||
|
|
||||||
**MCP-Server:**
|
**MCP-Server:**
|
||||||
- `versapad_mcp_server.py` — registriert als User-Scope-MCP-Server
|
- `versapad_mcp_server.py` — registriert als projektgebundener MCP-Server
|
||||||
"versapad" (`claude mcp add -s user versapad -- <python> versapad_mcp_server.py`).
|
"versapad" über `.mcp.json` im Projektordner (Alternative:
|
||||||
|
`claude mcp add -s user versapad -- <python> versapad_mcp_server.py`).
|
||||||
Nutzt MCP-SDK v2 (Paket `mcp`, Klasse `mcp.server.mcpserver.MCPServer` —
|
Nutzt MCP-SDK v2 (Paket `mcp`, Klasse `mcp.server.mcpserver.MCPServer` —
|
||||||
**nicht** `FastMCP` aus `mcp.server.fastmcp`, das existiert in dieser
|
**nicht** `FastMCP` aus `mcp.server.fastmcp`, das existiert in dieser
|
||||||
SDK-Version nicht mehr, wurde umbenannt). `@mcp.tool()`-dekorierte
|
SDK-Version nicht mehr, wurde umbenannt). `@mcp.tool()`-dekorierte
|
||||||
Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei
|
Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei
|
||||||
älteren FastMCP-Versionen). Tool-Liste: siehe README oder
|
älteren FastMCP-Versionen). Tool-Liste: siehe README oder
|
||||||
`MCP_INFO_TEXT` in `desktop_viewer.py`.
|
`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
|
Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code
|
||||||
nachschlagen — die Dateien sind klein genug, dass eine separate
|
nachschlagen. Menschenlesbare Referenzdoku (Architektur, Datenmodell,
|
||||||
`docs/current-architecture.md` hier keinen Mehrwert hätte (siehe
|
Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten.
|
||||||
Dokumentation und Verifikation unten für die Größeneinschätzung).
|
|
||||||
|
|
||||||
## Kritische Domänenregeln
|
## Kritische Domänenregeln
|
||||||
|
|
||||||
|
|
@ -74,6 +127,18 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
|
||||||
(`profile_names`) — die Firmware-Structs haben keinen Platz für einen
|
(`profile_names`) — die Firmware-Structs haben keinen Platz für einen
|
||||||
String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie
|
String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie
|
||||||
landen nie aufs Board, egal welcher Schreibpfad benutzt wird.
|
landen nie aufs Board, egal welcher Schreibpfad benutzt wird.
|
||||||
|
- **Notizen** (freier Text je Button/Encoder-Aktion, seit 2026-08-15) sind
|
||||||
|
aus demselben Grund rein lokal: leben im `"note"`-Feld JEDER Action
|
||||||
|
(`{"type","data","note"}`), nicht in einer separaten Struktur. `to_binary`/
|
||||||
|
`pack_config` ignorieren das Feld beim Schreiben (liest nur type/data),
|
||||||
|
`from_binary` liefert frisch vom Board immer `note=""` (Board kennt keine
|
||||||
|
Notizen) — `versapad_combined.merge_notes(neu, alt)` kopiert bestehende
|
||||||
|
Notizen nach jedem `load_from_board()`/`fetch_from_board()` zurück, sonst
|
||||||
|
gingen sie bei jedem Board-Refresh verloren. Action-Typ wechseln
|
||||||
|
(`set_button_key` etc.) darf die Notiz NICHT loeschen (baut die neue
|
||||||
|
Action ueber `_replace_action()`/`dlg.action["note"]` mit der alten Notiz),
|
||||||
|
nur `set_button_note()`/`set_encoder_note()`/das Notiz-Feld im
|
||||||
|
Programmiermodus-Dialog aendern sie gezielt.
|
||||||
- Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich
|
- Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich
|
||||||
gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten
|
gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten
|
||||||
gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im
|
gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im
|
||||||
|
|
@ -86,12 +151,226 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
|
||||||
vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json`
|
vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json`
|
||||||
zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine
|
zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine
|
||||||
Bearbeitung "verschwunden".
|
Bearbeitung "verschwunden".
|
||||||
- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein
|
- **Config-Pfad darf NIE im Installationsverzeichnis liegen (2026-08-15,
|
||||||
Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm
|
Datenverlust-Bug):** `build_and_deploy.ps1` räumt sein Zielverzeichnis vor
|
||||||
wie bei den Fensterverstecktricks in anderen Projekten zu riskieren).
|
jedem Deploy komplett ab (`Remove-Item $localDir -Recurse -Force`). Nachdem
|
||||||
- Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich
|
`app_dir()` am selben Tag auf genau dieses Verzeichnis gezeigt hatte, hat
|
||||||
aktive Windows-Tastaturlayout (die echte VersaGUI löst das über
|
**jeder Rebuild die Nutzer-Config mitgelöscht**; die App hat sie danach
|
||||||
`GetKeyNameText()`, das bilden wir ohne WinAPI-Call nicht nach).
|
kommentarlos leer vom Board neu aufgebaut (`load_or_fetch()` →
|
||||||
|
`fetch_from_board()`), wodurch alle Notizen weg waren. Besonders tückisch:
|
||||||
|
Board-Bindings und LEDs sahen danach völlig normal aus, nur die rein
|
||||||
|
lokalen Felder (Notizen, Profilnamen) fehlten -- der Schaden ist also
|
||||||
|
unsichtbar, wenn man nur aufs Grid schaut. Fix: `app_dir()` liefert jetzt
|
||||||
|
`%APPDATA%\VersaPadViewer` (Roaming), getrennt vom Installationsordner in
|
||||||
|
`%LOCALAPPDATA%`; zusätzlich rettet `build_and_deploy.ps1` vorgefundene
|
||||||
|
`versapad_config*.json` aus dem Zielverzeichnis über den Deploy hinweg
|
||||||
|
(für Altinstallationen). **Regel für künftige Änderungen: Programm- und
|
||||||
|
Datenverzeichnis nie zusammenlegen, egal wie praktisch "alles an einem
|
||||||
|
Ort" klingt.**
|
||||||
|
- **Nur EIN Config-Pfad, unabhängig vom Startweg (2026-08-15):** `app_dir()`
|
||||||
|
hat bewusst KEINEN `sys.frozen`-Zweig mehr. Vorher sah die gebaute `.exe`
|
||||||
|
eine andere Datei als der aus dem Quellcode gestartete MCP-Server, was zu
|
||||||
|
zwei auseinanderlaufenden Configs führte (die `.exe` zeigte ein anderes
|
||||||
|
Profil 0 als der MCP-Server meldete). Wer hier wieder nach Startweg
|
||||||
|
unterscheidet, baut denselben Bug erneut ein.
|
||||||
|
- **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim
|
||||||
|
Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei
|
||||||
|
fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei
|
||||||
|
Doppelklick im Explorer, weil das Fenster sich sofort schließt. Fix:
|
||||||
|
`requirements.txt` (alle Pakete an einer Stelle, README und Skript nutzen
|
||||||
|
dieselbe Datei), Skript installiert sie selbst per `pip install -r`,
|
||||||
|
läuft komplett in try/catch, prüft `$LASTEXITCODE` nach jedem nativen
|
||||||
|
Aufruf, und pausiert am Ende (Erfolg wie Fehler) auf Tastendruck --
|
||||||
|
`-NoPause` für CI/Automation. (2) `versapad_combined.DEFAULT_PATH` war
|
||||||
|
hartkodiert auf `~\OneDrive\Desktop` einer bestimmten Maschine -- für
|
||||||
|
andere Nutzer unbrauchbar. Fix: `versapad_data.app_dir()` (neue
|
||||||
|
Funktion) liefert bei der gebauten `.exe` deren Installationsordner
|
||||||
|
(`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`-
|
||||||
|
Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben
|
||||||
|
der laufenden Installation. **Diese app_dir()-Variante ist seit
|
||||||
|
2026-08-15 überholt und war aktiv schädlich, siehe oben "Config-Pfad
|
||||||
|
darf nie im Installationsverzeichnis liegen".**
|
||||||
|
`versapad_data.CONFIG_PATHS` (Lese-Interop
|
||||||
|
mit der C#-VersaGUI) bleibt bewusst auf dem Desktop, siehe oben. (3) Fehlte
|
||||||
|
die Config UND war kein Board erreichbar, blockierte das Tool mit einer
|
||||||
|
Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei
|
||||||
|
Board-Fehlschlag eine leere `default_combined()` an statt `RuntimeError`
|
||||||
|
zu werfen -- Erstbenutzung ganz ohne vorhandene Config oder Board
|
||||||
|
funktioniert jetzt. (4) Profilnamen waren zusätzlich hartkodiert in
|
||||||
|
`versapad_data.PROFILE_NAMES` (Dict, jetzt entfernt, ersetzt durch
|
||||||
|
`NUM_PROFILES = 3`) und liefen der JSON-`profile_names` parallel --
|
||||||
|
Programmiermodus zeigte umbenannte Profile, Lesemodus/Browser-Ansicht
|
||||||
|
weiterhin die alten Namen. Fix: `server.py` und `desktop_viewer.py` lesen
|
||||||
|
Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]`
|
||||||
|
bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der
|
||||||
|
Wahrheit für alle Frontends.
|
||||||
|
- **Herkunft der drei Punkte oben + Tab-Umbenennen-Fix:** ursprünglich per
|
||||||
|
Cherry-Pick aus Julian Appels `dev/jappel`-Branch auf `main` übernommen
|
||||||
|
(der sich zeitgleich mit dem eigenen COM-Port-Fix entwickelt hatte, kein
|
||||||
|
Fork-Sync-Automatismus -- manuell gegengelesen). Auf `main` bewusst NICHT
|
||||||
|
übernommen wurde damals der `dist/VersaPadViewer`-Build-Zielpfad (statt
|
||||||
|
`%LOCALAPPDATA%\VersaPadViewer`) sowie `.mcp.json` und die `docs/*.md`-
|
||||||
|
Referenzdateien. **2026-08-15: `main` wurde zurück in `dev/jappel`
|
||||||
|
gemerged**, damit landen beide Linien wieder in einem Branch -- inklusive
|
||||||
|
`.mcp.json`, `docs/*.md` und dem `%LOCALAPPDATA%`-Zielpfad (der hat sich
|
||||||
|
im Merge durchgesetzt, siehe Config-Pfad-Bullets oben). Der Notes-Feature-
|
||||||
|
Teil dieser Historie ist der Grund, warum `docs/data-model.md` das
|
||||||
|
`"note"`-Feld nachträglich braucht.
|
||||||
|
- **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und
|
||||||
|
`desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus
|
||||||
|
hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es
|
||||||
|
eine ungefangene `FileNotFoundError` bzw. "Config-Datei fehlt"-Anzeige,
|
||||||
|
obwohl das Board die Config laengst dauerhaft im NVM haelt. Fix: neue
|
||||||
|
`versapad_combined.fetch_from_board()`/`load_or_fetch()` -- fehlt die
|
||||||
|
Kombi-JSON, wird sie automatisch per Serial vom Board neu aufgebaut und
|
||||||
|
als Cache gespeichert (self-healing), nur bei unerreichbarem Board (Port
|
||||||
|
belegt/kein Board) bleibt der Fallback auf die alten Einzel-JSONs bzw.
|
||||||
|
eine Klartext-Fehlermeldung. In `desktop_viewer.py` nur versucht, wenn
|
||||||
|
Live-Sync aus ist (sonst haelt der Serial-Hintergrundthread den
|
||||||
|
COM-Port -- zwei gleichzeitige Zugriffe auf denselben `self.ser` waeren
|
||||||
|
eine Race Condition). Die Desktop-JSONs sind damit reiner Lesecache, kein
|
||||||
|
Pflegeaufwand mehr fuers Board-Backup.
|
||||||
|
- **Bug behoben 2026-08-07:** `_on_toggle_editing()` initialisierte
|
||||||
|
`self.combined` beim ersten Aktivieren des Programmiermodus mit
|
||||||
|
`vcomb.default_combined()` — das seedet ALLE 3 Profile aus den alten
|
||||||
|
Einzel-JSONs `versapad_config{1,2,3}.json`, nicht aus der aktuellen
|
||||||
|
`versapad_config_all.json` oder vom Board. Ein Klick auf "Zum Board
|
||||||
|
übertragen" hat dadurch beim Testen alle 3 Profile auf einen veralteten
|
||||||
|
Stand zurückgesetzt, obwohl nur ein Profil-Tab sichtbar bearbeitet wurde —
|
||||||
|
der Schaden an den anderen beiden Profilen blieb unbemerkt, bis explizit
|
||||||
|
jedes Profil einzeln gegengelesen wurde. Fix: lädt jetzt zuerst
|
||||||
|
`vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf
|
||||||
|
`default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer
|
||||||
|
ALLE 3 Profile prüfen, nicht nur das gemeldete.
|
||||||
|
- **WinAPI: was verboten bleibt und was nicht.** Verboten sind
|
||||||
|
**Fenstermanipulation am eigenen Fenster** (`SetWindowLongW`,
|
||||||
|
`ShowWindow`) und **globale Eingabehooks** (`SetWindowsHookEx`) — genau
|
||||||
|
diese Kombination hat laut Speicher-Notiz „keine Selbstversteck-
|
||||||
|
Fenstertricks" in einem anderen Projekt Bitdefender-Fehlalarme ausgeloest.
|
||||||
|
Erlaubt und seit 2026-08-29 in Benutzung sind **passive Layout-Abfragen**
|
||||||
|
(`MapVirtualKeyW`, `GetKeyNameTextW` in `versapad_keylayout.py`): sie
|
||||||
|
lesen nur die Tastaturbelegung, fassen weder Fenster noch Prozesse noch
|
||||||
|
den Eingabestrom an; die offizielle VersaGUI (C#) benutzt
|
||||||
|
`GetKeyNameText()` fuer denselben Zweck. Die Grenze verlaeuft also nicht
|
||||||
|
bei „ctypes", sondern bei „greift ins System ein".
|
||||||
|
- **Normales Fenster mit Titelleiste (seit 2026-08-29).** Von 2026-08-15
|
||||||
|
bis dahin lief das Fenster randlos (`overrideredirect(True)`) — das kostet
|
||||||
|
unter Windows zwingend den Taskleisten-Eintrag. Als der ausdruecklich
|
||||||
|
gebraucht wurde, gab es nur drei Wege: Titelleiste zurueck,
|
||||||
|
`SetWindowLongW`+WS_EX_APPWINDOW (siehe Verbot oben) oder ein
|
||||||
|
unsichtbares Proxy-Fenster. Der User hat sich fuer die Titelleiste
|
||||||
|
entschieden. Damit sind ersatzlos entfallen: ziehbare Kopfzeile
|
||||||
|
(`_start_move`/`_on_move`), Groessen-Anfasser unten rechts
|
||||||
|
(`_start_resize`/`_on_resize`, ersetzt durch `self.minsize()`), die
|
||||||
|
eigenen `✕`/`—`-Knoepfe und der `<Unmap>`-Handler, der Minimieren ins Tray
|
||||||
|
umgeleitet hat. **Wer das Fenster wieder randlos machen will, nimmt dem
|
||||||
|
User den Taskleisten-Eintrag weg** — nicht ohne Ruecksprache.
|
||||||
|
- Schliessen (`✕`) legt weiterhin ins Tray statt zu beenden (wie die
|
||||||
|
offizielle VersaGUI, das Programm laeuft im Hintergrund weiter),
|
||||||
|
Minimieren geht jetzt aber ganz normal in die Taskleiste. `Escape`
|
||||||
|
minimiert ebenfalls, statt wie frueher ins Tray zu legen — mit
|
||||||
|
Taskleisten-Eintrag waere „verschwindet spurlos" die unangenehmere
|
||||||
|
Ueberraschung.
|
||||||
|
- Fensterhöhe muss zum Karteninhalt passen: seit die Karten eine Notizzeile
|
||||||
|
haben (`CARD_H` 84 → 114) braucht das Fenster ~960px, sonst liegen
|
||||||
|
Encoder-Bereich und Fußzeile unterhalb des Rands. Wer `CARD_H` ändert,
|
||||||
|
muss `geometry()` mit anpassen.
|
||||||
|
- **Dialogposition wird gegen das ELTERNFENSTER begrenzt, nie gegen
|
||||||
|
`winfo_screenwidth()` (2026-08-29):** Tk meldet dort nur den
|
||||||
|
Hauptbildschirm. Die erste Fassung hat damit geklemmt — lag das
|
||||||
|
Hauptfenster auf einem zweiten Monitor, zog genau diese Begrenzung den
|
||||||
|
Dialog zurueck an den Rand des ersten. Passt der Dialog nicht ins
|
||||||
|
Elternfenster (der Makro-Schritte-Dialog ist breiter als der
|
||||||
|
Action-Dialog), wird er an dessen linker oberer Ecke ausgerichtet statt
|
||||||
|
zentriert.
|
||||||
|
- **Dialoge öffnen über dem Hauptfenster, nicht in der Bildschirmecke
|
||||||
|
(2026-08-28):** `_ModalDialog` baut sich `withdraw()`n auf, positioniert
|
||||||
|
sich in `run()` per `_center_on_parent()` und wird erst dann
|
||||||
|
`deiconify()`t. Ohne das legt Tk jeden Toplevel bei `+0+0` an — bei 20
|
||||||
|
Tasten hintereinander wandert der Blick jedes Mal in die linke obere Ecke.
|
||||||
|
Das `withdraw()` gehört zwingend dazu, sonst blitzt der Dialog dort auf,
|
||||||
|
bevor er springt.
|
||||||
|
- **Verschachtelte Dialoge geben den Grab zurück:** `MacroStepsDialog` läuft
|
||||||
|
im `ActionEditDialog`. `_finish()` ruft `grab_release()`, was den Grab des
|
||||||
|
*aufrufenden* Dialogs mit wegnimmt — `run()` setzt ihn deshalb am Ende
|
||||||
|
wieder, wenn der Parent ein `_ModalDialog` ist.
|
||||||
|
- **Panel-Höhe im `ActionEditDialog` ist fix** (`grid_propagate(False)`):
|
||||||
|
sonst springt die Fenstergröße bei jedem Typwechsel (Keine/Taste/Makro/…)
|
||||||
|
und OK/Abbrechen wandern unter dem Mauszeiger weg.
|
||||||
|
- **Kopieren/Einfügen zwischen Tasten** (Rechtsklickmenü bzw. Strg+C/Strg+V
|
||||||
|
auf der Karte unter dem Mauszeiger) benutzt eine reine In-Memory-Ablage
|
||||||
|
(`self._clip`), **nicht** die System-Zwischenablage — dort liegen
|
||||||
|
Action-Dicts, kein Text. Immer `copy.deepcopy()`, sonst teilen sich zwei
|
||||||
|
Tasten dasselbe Dict und eine spätere Bearbeitung ändert beide. „Leeren"
|
||||||
|
behält die Notiz (gleiche Regel wie beim Typwechsel im Dialog, siehe
|
||||||
|
Notizen-Bullet oben). Eine kopierte LED-Farbe auf einen Encoder
|
||||||
|
einzufügen ist wirkungslos (Encoder haben keine eigene LED) — das ist
|
||||||
|
Absicht, kein Fehler.
|
||||||
|
- Die Toolbar des Programmiermodus ist bei der Standardbreite (790px) fast
|
||||||
|
voll — zusätzliche Hinweistexte dort kurz halten, sonst werden sie rechts
|
||||||
|
abgeschnitten.
|
||||||
|
- **Tastendruck-Erkennung (seit 2026-08-28, auf expliziten User-Wunsch):**
|
||||||
|
Tasten lassen sich im Bearbeiten-Dialog per „⌨ Taste drücken" erfassen,
|
||||||
|
Makro-Schritte zusätzlich als Folge am Stück („⏺ Folge aufnehmen").
|
||||||
|
Umgesetzt **ausschließlich über Tk-Fenster-Events** (`<KeyPress>`/
|
||||||
|
`<KeyRelease>` auf dem Dialog-Toplevel, ausgewertet in
|
||||||
|
`versapad_data.tk_event_to_hid()`) — **weiterhin kein WinAPI-Hook**
|
||||||
|
(`SetWindowsHookEx` o.ä.), aus demselben AV-Fehlalarm-Grund wie bei den
|
||||||
|
Fensterverstecktricks. Wer hier auf einen globalen Hook „aufrüstet",
|
||||||
|
baut genau dieses Risiko ein. Konsequenzen, die so bleiben müssen:
|
||||||
|
- Erkannt wird nur, was das fokussierte Fenster erreicht — Win+L,
|
||||||
|
Strg+Alt+Entf und andere vom System abgefangene Kombinationen nicht.
|
||||||
|
- **Welche Taste gemeint ist, wird ueber die PHYSISCHE POSITION
|
||||||
|
aufgeloest, nie ueber das erzeugte Zeichen** (2026-08-29, nach
|
||||||
|
Fehlermeldung aus der Praxis). HID-Keycodes *sind* Positionen: das Board
|
||||||
|
sendet eine Position, erst Windows macht daraus ein Zeichen. Die erste
|
||||||
|
Fassung ging ueber keysym/Zeichen und war auf deutschem Layout
|
||||||
|
entsprechend kaputt — Y und Z landeten vertauscht auf dem Board, ÄÖÜ
|
||||||
|
und #/+ waren gar nicht erfassbar. Reihenfolge jetzt: (1) benannte
|
||||||
|
Tasten ueber den keysym, (2) Zeichentasten ueber
|
||||||
|
`versapad_keylayout.hid_for_vk()`, (3) die alte Naeherung nur noch als
|
||||||
|
Fallback ohne WinAPI. **Nicht auf "Zeichen auswerten" zurueckbauen** —
|
||||||
|
das ist genau der Fehler, der hier behoben wurde.
|
||||||
|
- Schritt (1) ist keine Bequemlichkeit, sondern noetig: `MapVirtualKeyW`
|
||||||
|
liefert fuer die Pfeiltasten denselben Scan-Code wie fuer ihre
|
||||||
|
Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B, das
|
||||||
|
E0-Praefix von `MAPVK_VK_TO_VSC_EX` bleibt dort aus). Ueber den
|
||||||
|
Scan-Code allein waeren Pfeiltasten nicht von Numpad-Tasten zu
|
||||||
|
unterscheiden.
|
||||||
|
- Das Dropdown bleibt daneben stehen (Korrekturmöglichkeit), es ersetzt
|
||||||
|
die Erkennung nicht und wird von ihr nicht ersetzt.
|
||||||
|
- Modifier werden doppelt ermittelt (selbst mitgeführte Press/Release-Bits
|
||||||
|
*plus* `event.state`), weil ein KeyRelease bei Fokuswechsel verloren
|
||||||
|
gehen kann.
|
||||||
|
- `_KeyCapture` hängt an einem `tk.Label`, nicht an einem `tk.Button`:
|
||||||
|
Buttons reagieren per Klassen-Binding selbst auf Leertaste/Enter und
|
||||||
|
würden die laufende Aufnahme mit ihrem eigenen Klick beantworten.
|
||||||
|
- Makro-Schritte filtern das Win-Bit weg (`allow_win=False`), passend zur
|
||||||
|
Firmware-Regel oben.
|
||||||
|
- **Tastenbeschriftungen kommen vom aktiven Layout** (seit 2026-08-29).
|
||||||
|
`versapad_data.hid_key_name()` fragt fuer Zeichentasten
|
||||||
|
`GetKeyNameTextW` (deutsch: HID 0x1C → „Z", 0x34 → „ä"); fuer alles
|
||||||
|
andere bleiben die gepflegten deutschen Namen aus `_SPECIAL_KEYS`
|
||||||
|
(„Enter", „Bild↑", „Num5") — die lesen sich besser als das, was Windows
|
||||||
|
liefert („EINGABE", „4 (ZEHNERTASTATUR)"). Ohne Layout-Abfrage
|
||||||
|
(Nicht-Windows) faellt alles auf die alte US-Naeherung zurueck.
|
||||||
|
Konsequenzen, die man kennen muss:
|
||||||
|
- Bestehende Belegungen aendern ihre **Anzeige**, nicht ihre Daten. Wer
|
||||||
|
frueher im Dropdown „Z" gewaehlt hat, bekam 0x1D — das steht so in der
|
||||||
|
Config und zeigt jetzt wahrheitsgemaess „Y", weil es auf dieser Tastatur
|
||||||
|
ein Y tippt. Das ist keine Regression, sondern der sichtbar gewordene
|
||||||
|
Altfehler.
|
||||||
|
- Namen sind Schluessel (Dropdown, `hid_key_code_for_name()`) und muessen
|
||||||
|
eindeutig bleiben. Es gibt echte Kollisionen: auf deutschem Layout heisst
|
||||||
|
HID 0x31 schlicht „#" — den Namen trug bisher HID 0x32. Der Layoutname
|
||||||
|
gewinnt, der verdraengte US-Name wird als „# (US-Layout)" gekennzeichnet
|
||||||
|
statt verworfen, damit die Taste ansprechbar bleibt.
|
||||||
|
- `hid_key_code_for_name()` akzeptiert weiterhin beide Schreibweisen, bei
|
||||||
|
Kollision gewinnt das Layout. Fuer den MCP-Server heisst das:
|
||||||
|
`set_button_key(key="Z")` trifft die Taste, die auf dieser Tastatur ein
|
||||||
|
Z tippt (0x1C), nicht mehr die US-Position 0x1D.
|
||||||
|
- Ein Layoutwechsel zur Laufzeit wird nicht bemerkt (Namen werden einmal
|
||||||
|
ermittelt und behalten).
|
||||||
- Tk-Aufrufe (`self.after()`, Widget-Konfiguration) NIE direkt aus einem
|
- Tk-Aufrufe (`self.after()`, Widget-Konfiguration) NIE direkt aus einem
|
||||||
Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren
|
Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren
|
||||||
Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in
|
Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in
|
||||||
|
|
@ -136,6 +415,28 @@ Byte-Identität), nicht nur gegen Beispieldaten. Vor jedem Schreibvorgang
|
||||||
aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor
|
aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor
|
||||||
echte Änderungen geschrieben werden.
|
echte Änderungen geschrieben werden.
|
||||||
|
|
||||||
|
**Änderungen an der Config immer aus der Sicht prüfen, die die laufende App
|
||||||
|
hat (2026-08-15 gelernt):** Claudes Bash- und PowerShell-Werkzeuge sehen
|
||||||
|
unter `C:\Users\chris\AppData\...` teilweise *unterschiedliche* Dateien —
|
||||||
|
derselbe Pfad lieferte gleichzeitig 29826 Bytes (Bash/MCP-Server) und
|
||||||
|
28455 Bytes (PowerShell/`.exe`). Konkret heißt das: **Config-Schreibvorgänge
|
||||||
|
über den versapad-MCP-Server (`save_local()`) erreichen die installierte
|
||||||
|
`.exe` nicht zuverlässig.** Das hat eine Fehlersuche über viele Runden
|
||||||
|
verschleppt, weil "Notizen sind in der Datei" (Bash) und "App zeigt keine
|
||||||
|
Notizen" gleichzeitig stimmten. Vorgehen:
|
||||||
|
- Config-Dateien, die die installierte App lesen soll, über **PowerShell**
|
||||||
|
schreiben/prüfen (`Get-Item`, dort ausgeführtes `python`), nicht über Bash.
|
||||||
|
- Bei "Änderung wirkt nicht"-Symptomen als Erstes Dateigröße/mtime aus
|
||||||
|
*beiden* Sichten vergleichen, bevor Code-Ursachen gesucht werden.
|
||||||
|
- `Z:\Git\...` (SMB-Share) ist von diesem Effekt nicht betroffen — Quellcode
|
||||||
|
und Build verhalten sich normal.
|
||||||
|
|
||||||
|
Verlässlich zum Ziel führt bei solchen Widersprüchen ein temporäres
|
||||||
|
Diagnose-Modul, das im gebauten Bundle mitläuft und `app_dir()`,
|
||||||
|
`DEFAULT_PATH`, `os.stat()` und den tatsächlich geladenen JSON-Inhalt in eine
|
||||||
|
Datei schreibt — damit war die Ursache in einem Durchlauf sichtbar, nachdem
|
||||||
|
Vermutungen mehrfach danebenlagen.
|
||||||
|
|
||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`,
|
Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`,
|
||||||
|
|
@ -148,14 +449,10 @@ selbst vorgegeben (`SAction.data`), dort beibehalten statt umzubenennen.
|
||||||
|
|
||||||
## Deferred Work
|
## 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 —
|
- Board-seitiges Umschalten des aktiven Profils per Button in der GUI —
|
||||||
explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only
|
explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only
|
||||||
- Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im
|
- Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im
|
||||||
Firmware-Struct), bleibt lokal
|
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
|
- Hintergrund-Thread für "Vom Board laden"/"Zum Board übertragen" — laufen
|
||||||
aktuell synchron im UI-Thread (kurzzeitiges Einfrieren möglich)
|
aktuell synchron im UI-Thread (kurzzeitiges Einfrieren möglich)
|
||||||
- Vorgefertigte `.exe` im Repo/als Release-Asset — bewusst nicht committet
|
- Vorgefertigte `.exe` im Repo/als Release-Asset — bewusst nicht committet
|
||||||
|
|
@ -168,18 +465,32 @@ Nicht an diesen Punkten arbeiten, ohne dass der User es explizit anfragt.
|
||||||
- `README.md` ist der Einstiegspunkt (Installation, Nutzung, Architektur-
|
- `README.md` ist der Einstiegspunkt (Installation, Nutzung, Architektur-
|
||||||
Überblick).
|
Überblick).
|
||||||
- `AGENTS.md` (diese Datei) ist die agentenseitige Quelle der Wahrheit für
|
- `AGENTS.md` (diese Datei) ist die agentenseitige Quelle der Wahrheit für
|
||||||
Domänenregeln und Architekturgrenzen — jede Session aktualisieren, die
|
Domänenregeln, Architekturgrenzen und Bug-Historie — jede Session
|
||||||
daran etwas ändert oder etwas Wichtiges lernt.
|
aktualisieren, die daran etwas ändert oder etwas Wichtiges lernt.
|
||||||
- Größeneinschätzung nach Projekt-Dokumentationsstandard: kleines/mittleres
|
- `docs/` (seit 2026-08-14, auf expliziten User-Wunsch) enthält die
|
||||||
Tool ohne eigene Datenbank und ohne persistenten API-Dienst (der
|
menschenlesbare Referenzdoku: `architecture.md` (Schichten, Prozess-/
|
||||||
Browser-Server ist ein einfacher lokaler Lese-Viewer, kein
|
Nebenläufigkeitsmodell, Config-Speicherort), `data-model.md` (JSON-
|
||||||
Mehrbenutzer-Backend) → `README.md` + `AGENTS.md` sind Pflicht und
|
Formate, binäres NVM-Layout, Geometrie, Enums), `protocol.md`
|
||||||
vorhanden, ein voller `docs/`-Baum ist nicht angemessen.
|
(Serial-Wire-Protokoll). Bug-Historie/Domänenregeln bleiben bewusst nur
|
||||||
|
in `AGENTS.md`, nicht dupliziert in `docs/`. Bei Änderungen am
|
||||||
|
Binärformat/Protokoll/Datenmodell `docs/data-model.md` bzw.
|
||||||
|
`docs/protocol.md` mitpflegen.
|
||||||
|
|
||||||
Prüfungen vor einem Commit an Binärformat/Protokoll:
|
Prüfungen vor einem Commit an Binärformat/Protokoll:
|
||||||
```bash
|
```bash
|
||||||
python -m py_compile *.py
|
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
|
Danach ein Live-Testskript gegen ein angeschlossenes Board laufen lassen
|
||||||
(read → unpack → pack → Bytevergleich, siehe Existing-Codebase-Regel) —
|
(read → unpack → pack → Bytevergleich, siehe Existing-Codebase-Regel) —
|
||||||
es gibt keine automatisierten Unit-Tests dafür, die Verifikation läuft
|
es gibt keine automatisierten Unit-Tests dafür, die Verifikation läuft
|
||||||
|
|
@ -189,3 +500,13 @@ Committen: prägnante Commit-Message je abgeschlossenem, verifiziertem
|
||||||
Arbeitspaket. Nach jedem Push: alle bekannten Remotes prüfen (`origin` auf
|
Arbeitspaket. Nach jedem Push: alle bekannten Remotes prüfen (`origin` auf
|
||||||
GitHub, `jappel` auf git.jappel.io) — beide müssen synchron bleiben, siehe
|
GitHub, `jappel` auf git.jappel.io) — beide müssen synchron bleiben, siehe
|
||||||
Speicher-Notiz "Multi-Remote-Repos synchron halten".
|
Speicher-Notiz "Multi-Remote-Repos synchron halten".
|
||||||
|
|
||||||
|
**PR-Erstellung auf git.jappel.io per API/curl mit Access-Token wird vom
|
||||||
|
Bash-Classifier geblockt** (Auto-Mode, gilt auch für `git credential fill`),
|
||||||
|
siehe Speicher-Notiz "Bash-Klassifikator blockt Credential/Auth-Schreibzugriffe".
|
||||||
|
Branch pushen geht (nutzt den Git-eigenen Credential-Helper, kein Token im
|
||||||
|
Klartext im Bash-Aufruf), den fertigen PR muss der User über den von Forgejo
|
||||||
|
nach dem Push ausgegebenen Compare-Link selbst anlegen (oder Claude einen
|
||||||
|
Token geben, der dann NICHT wiederverwendbar im Bash-Aufruf landen darf,
|
||||||
|
sondern nur für den einen `curl`-Call — auch das kann der Classifier trotzdem
|
||||||
|
blocken, dann bleibt nur der manuelle Link).
|
||||||
|
|
|
||||||
139
README.md
139
README.md
|
|
@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein
|
||||||
echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
|
echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
|
||||||
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
|
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
|
||||||
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
|
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
|
||||||
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die
|
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene
|
||||||
Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte
|
Config-Datei (`versapad_config_all.json`) liegt automatisch unter
|
||||||
Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`,
|
`%APPDATA%\VersaPadViewer\` (siehe „Aufbau" unten) und wird bei Bedarf
|
||||||
`versapad_combined.DEFAULT_PATH`) — für einen anderen Rechner dort anpassen.
|
automatisch neu angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale*
|
||||||
|
Lese-Interop mit den JSON-Exports der offiziellen VersaGUI
|
||||||
|
(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine
|
||||||
|
hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen,
|
||||||
|
falls gewünscht.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -26,26 +30,63 @@ Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`,
|
||||||
und schaltet die Ansicht automatisch mit
|
und schaltet die Ansicht automatisch mit
|
||||||
- **Programmiermodus** — Zellen anklicken und bearbeiten (Taste, Medientaste,
|
- **Programmiermodus** — Zellen anklicken und bearbeiten (Taste, Medientaste,
|
||||||
Makro, Profilwechsel, LED-Farbe/Animation), direkt aufs Board schreiben
|
Makro, Profilwechsel, LED-Farbe/Animation), direkt aufs Board schreiben
|
||||||
oder als Datei speichern
|
oder als Datei speichern. Der Dialog öffnet über dem Hauptfenster,
|
||||||
|
Enter bestätigt, Escape bricht ab
|
||||||
|
- **Tastendruck-Erkennung** — statt die Taste im Dropdown zu suchen,
|
||||||
|
„⌨ Taste drücken" klicken und die gewünschte Kombination einfach
|
||||||
|
drücken (Modifier inklusive). Erkannt wird die *physische* Taste, nicht
|
||||||
|
das Zeichen — Y/Z, ÄÖÜ, `#`, `+` und `ß` landen also richtig auf dem
|
||||||
|
Board, auch auf deutschem Layout. Läuft über das Dialogfenster, nicht
|
||||||
|
über einen System-Hook: vom System abgefangene Kombinationen (Win+L,
|
||||||
|
Strg+Alt+Entf) kommen nicht an
|
||||||
|
- **Layoutrichtige Tastennamen** — Beschriftungen kommen vom aktiven
|
||||||
|
Windows-Layout (`Strg+Z` heißt auf deutscher Tastatur auch `Strg+Z`, und
|
||||||
|
`ä`/`ö`/`ü` heißen so). Bestehende Belegungen ändern dadurch ihre
|
||||||
|
Anzeige, nicht ihre Funktion
|
||||||
- **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte
|
- **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte
|
||||||
Makro-Tabelle vom Board
|
Makro-Tabelle vom Board. Schritte einzeln erfassen oder die ganze Folge
|
||||||
|
am Stück aufnehmen („⏺ Folge aufnehmen"). Die Slot-Auswahl listet alle 32
|
||||||
|
Slots samt Inhalt, statt sie einzeln durchklicken zu müssen
|
||||||
|
- **Makros im Grid lesbar** — eine Makro-Belegung zeigt die tatsächliche
|
||||||
|
Tastenfolge (`Makro 3: Strg+C → Strg+V`) statt nur der Slot-Nummer; das
|
||||||
|
gilt auch in der Browser-Ansicht und in den MCP-Antworten
|
||||||
|
- **Farb-Schnellwahl** — zwölf Grundfarben direkt in der LED-Zeile des
|
||||||
|
Dialogs, der System-Farbdialog nur noch für den Rest („mehr…")
|
||||||
|
- **Kopieren/Einfügen zwischen Tasten** — Rechtsklick auf eine Karte im
|
||||||
|
Programmiermodus: Belegung und/oder Farbe kopieren und auf andere Tasten
|
||||||
|
anwenden, oder die Belegung leeren. Strg+C/Strg+V wirken auf die Karte
|
||||||
|
unter dem Mauszeiger
|
||||||
|
- **Notizen** — freier Text pro Button/Encoder-Aktion, was sie tatsächlich
|
||||||
|
tut (z.B. "Speichern in Fusion 360"), zusätzlich zur automatischen
|
||||||
|
Beschriftung ("Strg+S"). Rein lokal wie Profilnamen, geht nie aufs Board,
|
||||||
|
bleibt beim Tastenwechsel und beim "Vom Board laden" erhalten
|
||||||
- **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per
|
- **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per
|
||||||
Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten)
|
Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten)
|
||||||
- **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie
|
- **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie
|
||||||
die offizielle VersaGUI
|
die offizielle VersaGUI
|
||||||
|
- **Normales Fenster mit Taskleisten-Eintrag** — Titelleiste, Alt+Tab,
|
||||||
|
Aero-Snap und Größe ändern am Rahmen funktionieren nativ. `✕` beendet
|
||||||
|
nicht, sondern legt ins Tray (wie die offizielle VersaGUI — das Programm
|
||||||
|
läuft im Hintergrund weiter); Minimieren geht normal in die Taskleiste.
|
||||||
|
- **Tastenkürzel im Hauptfenster** — `Strg+1/2/3` Profil wechseln, `F2`
|
||||||
|
Profil umbenennen, `Strg+E` Programmiermodus an/aus, `Strg+C`/`Strg+V`
|
||||||
|
Taste unter dem Mauszeiger kopieren/einfügen, `Esc` minimieren.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Python 3.11 oder neuer
|
- Python 3.11 oder neuer
|
||||||
- Pakete: `pyserial` (Board-Kommunikation), `pystray` + `pillow`
|
- Alle Pakete stehen in `requirements.txt` (`pyserial` fürs Board,
|
||||||
(Tray-Icon/Desktop-App), optional `mcp` (nur für den MCP-Server)
|
`pystray` + `pillow` fürs Tray-Icon/Desktop-Fenster, `mcp` für den
|
||||||
|
MCP-Server, `pyinstaller` fürs `.exe`-Bauen):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pyserial pystray pillow mcp
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek —
|
Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek —
|
||||||
keine zusätzlichen Pakete nötig.
|
keine zusätzlichen Pakete nötig. `build_and_deploy.ps1` installiert
|
||||||
|
`requirements.txt` beim Bauen automatisch selbst — ein manuelles
|
||||||
|
`pip install` vorher ist dafür nicht nötig.
|
||||||
|
|
||||||
## Starten
|
## Starten
|
||||||
|
|
||||||
|
|
@ -65,8 +106,15 @@ Maschine/Python-Version unterschiedlich). Selbst bauen:
|
||||||
.\build_and_deploy.ps1
|
.\build_and_deploy.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Skript baut mit PyInstaller (`--onedir --windowed`, eigenes Icon) und
|
Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus
|
||||||
kopiert das Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`.
|
`requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann
|
||||||
|
mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das
|
||||||
|
Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`.
|
||||||
|
Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das
|
||||||
|
Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich
|
||||||
|
bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause`
|
||||||
|
unterdrückt das für automatisierte Aufrufe/CI).
|
||||||
|
|
||||||
**Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen
|
**Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen
|
||||||
Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert
|
Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert
|
||||||
PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und
|
PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und
|
||||||
|
|
@ -75,13 +123,12 @@ das Nachladen der Bundle-DLLs von einem Netzwerkpfad, ohne jede
|
||||||
Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb
|
Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb
|
||||||
automatisch zuerst nach `%TEMP%` und baut nur dort.
|
automatisch zuerst nach `%TEMP%` und baut nur dort.
|
||||||
|
|
||||||
Voraussetzung: `pip install pyinstaller` zusätzlich zu den obigen Paketen.
|
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
| Datei | Zweck |
|
| Datei | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text |
|
| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text, Tastendruck → HID-Keycode |
|
||||||
|
| `versapad_keylayout.py` | Abfragen ans aktive Windows-Tastaturlayout: physische Tastenposition und Tastenname (optional, nur Windows) |
|
||||||
| `versapad_protocol.py` | Binäres NVM-Layout des Boards (740B Config + 512B Makros), CRC16 — pack/unpack |
|
| `versapad_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_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 |
|
| `versapad_combined.py` | Ein-Datei-Format für alle 3 Profile + Makros + lokale Profilnamen |
|
||||||
|
|
@ -94,19 +141,38 @@ Das Binärformat (`versapad_protocol.py`) ist 1:1 aus den
|
||||||
VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
|
VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
|
||||||
(Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC).
|
(Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC).
|
||||||
|
|
||||||
Config-Dateien liegen standardmäßig auf dem Desktop
|
Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile +
|
||||||
(`versapad_config1/2/3.json` für den reinen Lesemodus,
|
Makros + lokale Profilnamen + Notizen, siehe `versapad_combined.py`) liegt
|
||||||
`versapad_config_all.json` für den Programmiermodus — Pfade sind aktuell
|
unter `%APPDATA%\VersaPadViewer\` (`versapad_data.app_dir()`), also
|
||||||
hartkodiert für eine bestimmte Windows-Maschine, siehe `CONFIG_PATHS` in
|
**getrennt vom Installationsordner** und unabhängig davon, ob die `.exe`
|
||||||
`versapad_data.py` bzw. `DEFAULT_PATH` in `versapad_combined.py`).
|
oder der Quellcode gestartet wurde. Beides ist Absicht: das Build-Skript
|
||||||
|
räumt sein Zielverzeichnis vor jedem Deploy komplett ab (läge die Config
|
||||||
|
dort, würde jeder Rebuild sie löschen), und ein vom Startweg abhängiger
|
||||||
|
Pfad hatte zu zwei auseinanderlaufenden Configs geführt. Fehlt die Datei
|
||||||
|
(z.B. frische Installation), wird sie automatisch angelegt — per Serial vom
|
||||||
|
Board, falls eins angeschlossen ist, sonst als leere Default-Config.
|
||||||
|
Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich
|
||||||
|
per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder
|
||||||
|
Programmiermodus) oder `rename_profile()` (MCP) ändern. Notizen ebenso —
|
||||||
|
im Bearbeiten-Dialog des Programmiermodus oder per
|
||||||
|
`set_button_note()`/`set_encoder_note()` (MCP).
|
||||||
|
|
||||||
|
Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool
|
||||||
|
geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export
|
||||||
|
der offiziellen VersaGUI (C#/.NET) — Pfad aktuell hartkodiert auf den
|
||||||
|
OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in
|
||||||
|
`versapad_data.py`.
|
||||||
|
|
||||||
## MCP-Server
|
## MCP-Server
|
||||||
|
|
||||||
`versapad_mcp_server.py` macht die Config per Tool-Aufruf statt Hand-JSON
|
`versapad_mcp_server.py` macht die Config per Tool-Aufruf statt Hand-JSON
|
||||||
programmierbar — nutzbar von jeder MCP-fähigen KI-Anwendung (Claude Code,
|
programmierbar — nutzbar von jeder MCP-fähigen KI-Anwendung (Claude Code,
|
||||||
Claude Desktop, andere). In der MCP-Server-Liste der jeweiligen Anwendung
|
Claude Desktop, andere). Für Claude Code liegt bereits eine projektgebundene
|
||||||
eintragen: Kommando `python`/`py`, Argument der Pfad zu
|
[`.mcp.json`](.mcp.json) im Repo (Server "versapad", Kommando `py
|
||||||
`versapad_mcp_server.py`.
|
versapad_mcp_server.py`) — beim Öffnen des Projekts wird sie automatisch
|
||||||
|
zum Verbinden angeboten. Für andere Anwendungen oder user-scope-Registrierung
|
||||||
|
in der jeweiligen MCP-Server-Liste eintragen: Kommando `python`/`py`,
|
||||||
|
Argument der Pfad zu `versapad_mcp_server.py`.
|
||||||
|
|
||||||
Werkzeuge (Auszug): `list_profiles`, `get_profile`, `get_macro`,
|
Werkzeuge (Auszug): `list_profiles`, `get_profile`, `get_macro`,
|
||||||
`get_board_status` (lesen) · `set_button_key`/`_consumer`/`_macro`/
|
`get_board_status` (lesen) · `set_button_key`/`_consumer`/`_macro`/
|
||||||
|
|
@ -123,18 +189,33 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`.
|
||||||
gleichzeitig mit der offiziellen VersaGUI laufen (die läuft dauerhaft als
|
gleichzeitig mit der offiziellen VersaGUI laufen (die läuft dauerhaft als
|
||||||
Tray-App weiter, auch wenn nur ihr Konfigurationsfenster geschlossen wird
|
Tray-App weiter, auch wenn nur ihr Konfigurationsfenster geschlossen wird
|
||||||
— für Parallelbetrieb muss sie über ihr Tray-Menü beendet werden)
|
— für Parallelbetrieb muss sie über ihr Tray-Menü beendet werden)
|
||||||
- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein
|
- Die Tastendruck-Erkennung läuft bewusst über das Dialogfenster statt über
|
||||||
Tastendruck-Capture (bewusst, um keinen WinAPI-Hook zu brauchen)
|
einen globalen WinAPI-Hook — vom System abgefangene Kombinationen (Win+L,
|
||||||
- Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich
|
Strg+Alt+Entf) erreichen das Fenster nie und lassen sich so nicht erfassen
|
||||||
aktive Tastatur-Layout
|
- Ein Wechsel des Tastaturlayouts im laufenden Programm wird nicht bemerkt
|
||||||
|
(die Tastennamen werden einmal beim ersten Zugriff ermittelt) — Neustart
|
||||||
|
hilft. Unter Nicht-Windows fällt die Beschriftung auf eine
|
||||||
|
US-Layout-Näherung zurück
|
||||||
- Unsignierte `.exe` — kann von Antivirus/Smart App Control blockiert
|
- Unsignierte `.exe` — kann von Antivirus/Smart App Control blockiert
|
||||||
werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert
|
werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert
|
||||||
es aber nicht
|
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
|
## Weiterentwicklung
|
||||||
|
|
||||||
Tiefere technische Notizen (Protokoll-Details, bekannte Stolpersteine beim
|
Agentenseitige Notizen (Domänenregeln, bekannte Bugs und ihre Fixes,
|
||||||
Bauen, Design-Entscheidungen) stehen in [`AGENTS.md`](AGENTS.md).
|
Implementierungsdisziplin, Design-Entscheidungen) stehen in
|
||||||
|
[`AGENTS.md`](AGENTS.md).
|
||||||
|
|
||||||
## Lizenz / Herkunft
|
## Lizenz / Herkunft
|
||||||
|
|
||||||
|
|
|
||||||
BIN
VP-Board Enclosure.f3d
Normal file
BIN
VP-Board Enclosure.f3d
Normal file
Binary file not shown.
494
action_dialog.py
494
action_dialog.py
|
|
@ -1,12 +1,24 @@
|
||||||
"""
|
"""
|
||||||
Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu
|
Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu
|
||||||
VersaGUI/src/ActionDialog.cs, aber in Tkinter und ohne Tastendruck-Capture
|
VersaGUI/src/ActionDialog.cs, in Tkinter.
|
||||||
(kein WinAPI-Hook -- stattdessen Tasten-Auswahl per Dropdown, siehe
|
|
||||||
versapad_data.hid_key_choices()).
|
|
||||||
|
|
||||||
ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons).
|
ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons).
|
||||||
MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur
|
MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur
|
||||||
echten GUI ("8 Step-Buttons + je Strg/Shift/Alt-Checkboxen", kein Win).
|
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
|
import tkinter as tk
|
||||||
from tkinter import colorchooser, ttk
|
from tkinter import colorchooser, ttk
|
||||||
|
|
@ -18,6 +30,7 @@ BG2 = "#14161b"
|
||||||
TEXT = "#e8e8ec"
|
TEXT = "#e8e8ec"
|
||||||
TEXT_DIM = "#8a8d98"
|
TEXT_DIM = "#8a8d98"
|
||||||
ACCENT = "#3a6ff0"
|
ACCENT = "#3a6ff0"
|
||||||
|
CAPTURE_BG = "#c04a2a"
|
||||||
|
|
||||||
TYPE_CHOICES = [
|
TYPE_CHOICES = [
|
||||||
("None", "Keine"),
|
("None", "Keine"),
|
||||||
|
|
@ -34,8 +47,100 @@ PROFILE_SWITCH_CHOICES = [
|
||||||
("Profil 3", 2),
|
("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("<Button-1>", 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):
|
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):
|
def __init__(self, parent, title):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.title(title)
|
self.title(title)
|
||||||
|
|
@ -43,18 +148,116 @@ class _ModalDialog(tk.Toplevel):
|
||||||
self.transient(parent)
|
self.transient(parent)
|
||||||
self.resizable(False, False)
|
self.resizable(False, False)
|
||||||
self.cancelled = True
|
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("<KeyPress>", self._on_key_press)
|
||||||
|
self.bind("<KeyRelease>", 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):
|
def _finish(self, cancelled):
|
||||||
|
if self._active_capture is not None:
|
||||||
|
self._active_capture.stop()
|
||||||
self.cancelled = cancelled
|
self.cancelled = cancelled
|
||||||
self.grab_release()
|
self.grab_release()
|
||||||
self.destroy()
|
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):
|
def run(self):
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
|
self._center_on_parent()
|
||||||
|
self.deiconify()
|
||||||
self.grab_set()
|
self.grab_set()
|
||||||
|
self.focus_force()
|
||||||
self.wait_window(self)
|
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
|
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):
|
class MacroStepsDialog(_ModalDialog):
|
||||||
"""Ergebnis in self.steps nach run()==True."""
|
"""Ergebnis in self.steps nach run()==True."""
|
||||||
|
|
@ -68,43 +271,81 @@ class MacroStepsDialog(_ModalDialog):
|
||||||
self._code_by_name = {name: code for code, name in key_choices}
|
self._code_by_name = {name: code for code, name in key_choices}
|
||||||
names = ["(leer)"] + [name for _, name in key_choices]
|
names = ["(leer)"] + [name for _, name in key_choices]
|
||||||
|
|
||||||
tk.Label(self, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.",
|
head = tk.Frame(self, bg=BG2)
|
||||||
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).grid(
|
head.grid(row=0, column=0, columnspan=6, sticky="w", padx=12, pady=(12, 6))
|
||||||
row=0, column=0, columnspan=5, sticky="w", padx=12, pady=(12, 6))
|
tk.Label(head, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.",
|
||||||
|
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w")
|
||||||
|
tk.Label(head, text="Einzeln erfassen mit ⌨ je Zeile, oder die ganze Folge am Stück "
|
||||||
|
"aufnehmen. Win ist in Makros nicht möglich (Firmware).",
|
||||||
|
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w")
|
||||||
|
|
||||||
|
tools = tk.Frame(self, bg=BG2)
|
||||||
|
tools.grid(row=1, column=0, columnspan=6, sticky="w", padx=12, pady=(0, 6))
|
||||||
|
self._record_label = tk.Label(tools, bg=BG, fg=TEXT, font=("Segoe UI", 9),
|
||||||
|
padx=10, pady=3)
|
||||||
|
self._record = _KeyCapture(self, self._record_label, self._on_record_step,
|
||||||
|
"⏺ Folge aufnehmen", allow_win=False, repeat=True)
|
||||||
|
self._record_label.pack(side="left")
|
||||||
|
tk.Button(tools, text="Alle leeren", command=self._clear_all, bg=BG, fg=TEXT,
|
||||||
|
activebackground=BG, relief="flat", padx=10).pack(side="left", padx=6)
|
||||||
|
self._record_hint = tk.Label(tools, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||||||
|
self._record_hint.pack(side="left", padx=6)
|
||||||
|
|
||||||
self._key_vars = []
|
self._key_vars = []
|
||||||
self._mod_vars = []
|
self._mod_vars = []
|
||||||
for i in range(8):
|
for i in range(8):
|
||||||
r = i + 1
|
r = i + 2
|
||||||
tk.Label(self, text=f"{i + 1}.", bg=BG2, fg=TEXT_DIM,
|
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)")
|
key_var = tk.StringVar(value="(leer)")
|
||||||
combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly")
|
ttk.Combobox(self, textvariable=key_var, values=names, width=16,
|
||||||
combo.grid(row=r, column=1, padx=4, pady=2)
|
state="readonly").grid(row=r, column=1, padx=4, pady=2)
|
||||||
self._key_vars.append(key_var)
|
self._key_vars.append(key_var)
|
||||||
|
|
||||||
mods = {}
|
mods = {}
|
||||||
for j, label in enumerate(("Strg", "Shift", "Alt")):
|
for j, (label, _bit) in enumerate(MACRO_MOD_CHECKBOXES):
|
||||||
v = tk.BooleanVar(value=False)
|
v = tk.BooleanVar(value=False)
|
||||||
tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT,
|
tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT,
|
||||||
selectcolor=BG, activebackground=BG2, activeforeground=TEXT,
|
selectcolor=BG, activebackground=BG2, activeforeground=TEXT,
|
||||||
font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, sticky="w")
|
font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2,
|
||||||
|
sticky="w")
|
||||||
mods[label] = v
|
mods[label] = v
|
||||||
self._mod_vars.append(mods)
|
self._mod_vars.append(mods)
|
||||||
|
|
||||||
for i, step in enumerate(steps[:8]):
|
self._capture_label(
|
||||||
self._key_vars[i].set(self._name_by_code.get(step["keycode"], "(leer)"))
|
self, lambda code, mod, idx=i: self._apply_step(idx, code, mod),
|
||||||
self._mod_vars[i]["Strg"].set(bool(step["modifier"] & 0x01))
|
idle_text="⌨", allow_win=False,
|
||||||
self._mod_vars[i]["Shift"].set(bool(step["modifier"] & 0x02))
|
).grid(row=r, column=5, padx=(6, 12), pady=2)
|
||||||
self._mod_vars[i]["Alt"].set(bool(step["modifier"] & 0x04))
|
|
||||||
|
|
||||||
btns = tk.Frame(self, bg=BG2)
|
for i, step in enumerate(steps[:8]):
|
||||||
btns.grid(row=9, column=0, columnspan=5, pady=12)
|
self._apply_step(i, step["keycode"], step["modifier"])
|
||||||
tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff",
|
|
||||||
activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4)
|
self._button_row(row=10, columnspan=6)
|
||||||
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 _apply_step(self, index, keycode, modifier):
|
||||||
|
self._key_vars[index].set(self._name_by_code.get(keycode, "(leer)"))
|
||||||
|
for label, bit in MACRO_MOD_CHECKBOXES:
|
||||||
|
self._mod_vars[index][label].set(bool(modifier & bit))
|
||||||
|
|
||||||
|
def _clear_all(self):
|
||||||
|
for i in range(8):
|
||||||
|
self._apply_step(i, 0, 0)
|
||||||
|
self._record_hint.configure(text="")
|
||||||
|
|
||||||
|
def _on_record_step(self, keycode, modifier):
|
||||||
|
"""Aufnahmemodus: jeder Tastendruck fuellt den naechsten freien
|
||||||
|
Schritt. Nach dem 8. stoppt die Aufnahme selbst (mehr Schritte
|
||||||
|
kennt SMacroTable nicht)."""
|
||||||
|
free = next((i for i in range(8) if self._key_vars[i].get() == "(leer)"), None)
|
||||||
|
if free is None:
|
||||||
|
self._record.stop()
|
||||||
|
self._record_hint.configure(text="alle 8 Schritte belegt")
|
||||||
|
return
|
||||||
|
self._apply_step(free, keycode, modifier)
|
||||||
|
self._record_hint.configure(text=f"Schritt {free + 1} aufgenommen")
|
||||||
|
if free == 7:
|
||||||
|
self._record.stop()
|
||||||
|
|
||||||
def _on_ok(self):
|
def _on_ok(self):
|
||||||
steps = []
|
steps = []
|
||||||
|
|
@ -112,11 +353,8 @@ class MacroStepsDialog(_ModalDialog):
|
||||||
name = self._key_vars[i].get()
|
name = self._key_vars[i].get()
|
||||||
if name == "(leer)":
|
if name == "(leer)":
|
||||||
break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren
|
break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren
|
||||||
modifier = (
|
modifier = sum(bit for label, bit in MACRO_MOD_CHECKBOXES
|
||||||
(0x01 if self._mod_vars[i]["Strg"].get() else 0) |
|
if self._mod_vars[i][label].get())
|
||||||
(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})
|
steps.append({"keycode": self._code_by_name[name], "modifier": modifier})
|
||||||
self.steps = steps
|
self.steps = steps
|
||||||
self._finish(False)
|
self._finish(False)
|
||||||
|
|
@ -124,7 +362,9 @@ class MacroStepsDialog(_ModalDialog):
|
||||||
|
|
||||||
class ActionEditDialog(_ModalDialog):
|
class ActionEditDialog(_ModalDialog):
|
||||||
"""Ergebnis in self.action / self.led nach run()==True. led bleibt None
|
"""Ergebnis in self.action / self.led nach run()==True. led bleibt None
|
||||||
wenn led_in None war (Encoder -- keine eigene Farbe)."""
|
wenn led_in None war (Encoder -- keine eigene Farbe). self.action enthaelt
|
||||||
|
immer ein "note"-Feld (freie Notiz, was die Aktion tut -- rein lokal wie
|
||||||
|
Profilnamen, geht nie aufs Board)."""
|
||||||
|
|
||||||
def __init__(self, parent, title, action, led, macros):
|
def __init__(self, parent, title, action, led, macros):
|
||||||
super().__init__(parent, title)
|
super().__init__(parent, title)
|
||||||
|
|
@ -134,8 +374,15 @@ class ActionEditDialog(_ModalDialog):
|
||||||
self.led = None
|
self.led = None
|
||||||
|
|
||||||
row = 0
|
row = 0
|
||||||
|
note_row = tk.Frame(self, bg=BG2)
|
||||||
|
note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1
|
||||||
|
tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
|
||||||
|
self._note_var = tk.StringVar(value=action.get("note", ""))
|
||||||
|
tk.Entry(note_row, textvariable=self._note_var, width=52, bg=BG, fg=TEXT,
|
||||||
|
insertbackground=TEXT, relief="flat").pack(side="left", padx=6)
|
||||||
|
|
||||||
tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid(
|
tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid(
|
||||||
row=row, column=0, sticky="w", padx=12, pady=(12, 4)); row += 1
|
row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1
|
||||||
|
|
||||||
self._type_var = tk.StringVar(value=action["type"])
|
self._type_var = tk.StringVar(value=action["type"])
|
||||||
type_frame = tk.Frame(self, bg=BG2)
|
type_frame = tk.Frame(self, bg=BG2)
|
||||||
|
|
@ -146,9 +393,11 @@ class ActionEditDialog(_ModalDialog):
|
||||||
activebackground=BG2, activeforeground=TEXT,
|
activebackground=BG2, activeforeground=TEXT,
|
||||||
font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
|
font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
|
||||||
|
|
||||||
self._panel_row = row
|
# Feste Groesse: sonst springt die Fensterhoehe (und damit die
|
||||||
self._panel = tk.Frame(self, bg=BG2)
|
# Position von OK/Abbrechen) bei jedem Typwechsel.
|
||||||
self._panel.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=8)
|
self._panel = tk.Frame(self, bg=BG2, width=600, height=90)
|
||||||
|
self._panel.grid(row=row, column=0, columnspan=3, sticky="ew", padx=12, pady=8)
|
||||||
|
self._panel.grid_propagate(False)
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
# HidKey-Panel
|
# HidKey-Panel
|
||||||
|
|
@ -165,17 +414,16 @@ class ActionEditDialog(_ModalDialog):
|
||||||
self._consumer_var = tk.StringVar()
|
self._consumer_var = tk.StringVar()
|
||||||
|
|
||||||
# Macro-Panel
|
# Macro-Panel
|
||||||
self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0)
|
self._macro_slot = action["data"] if action["type"] == "Macro" else 0
|
||||||
self._macro_preview_var = tk.StringVar()
|
self._macro_choice_var = tk.StringVar()
|
||||||
|
|
||||||
# ProfileSwitch-Panel
|
# ProfileSwitch-Panel
|
||||||
self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0])
|
self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0])
|
||||||
|
|
||||||
if action["type"] == "HidKey":
|
if action["type"] == "HidKey":
|
||||||
keycode = action["data"] & 0xFF
|
keycode = action["data"] & 0xFF
|
||||||
modifier = (action["data"] >> 8) & 0xFF
|
|
||||||
self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)"))
|
self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)"))
|
||||||
self._hidkey_mods_init = modifier
|
self._hidkey_mods_init = (action["data"] >> 8) & 0xFF
|
||||||
else:
|
else:
|
||||||
self._hidkey_key_var.set("(leer)")
|
self._hidkey_key_var.set("(leer)")
|
||||||
self._hidkey_mods_init = 0
|
self._hidkey_mods_init = 0
|
||||||
|
|
@ -197,45 +445,67 @@ class ActionEditDialog(_ModalDialog):
|
||||||
self._led_color = (self._led["r"], self._led["g"], self._led["b"]) if self._led else (80, 40, 0)
|
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:
|
if self._led is not None:
|
||||||
|
row = self._build_led_panel(row)
|
||||||
|
|
||||||
|
self._button_row(row=row, columnspan=3)
|
||||||
|
self._on_type_change()
|
||||||
|
|
||||||
|
# ── LED ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_led_panel(self, row):
|
||||||
led_frame = tk.Frame(self, bg=BG2)
|
led_frame = tk.Frame(self, bg=BG2)
|
||||||
led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)); row += 1
|
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")
|
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 = tk.Frame(led_frame, bg=BG2)
|
||||||
color_row.pack(anchor="w", pady=4)
|
color_row.pack(anchor="w", pady=4)
|
||||||
self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4)
|
self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4)
|
||||||
self._swatch.pack(side="left")
|
self._swatch.pack(side="left")
|
||||||
tk.Button(color_row, text="Farbe wählen...", command=self._pick_color, bg=BG,
|
self._hex_label = tk.Label(color_row, text=self._hex(), bg=BG2, fg=TEXT_DIM,
|
||||||
fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8)
|
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("<Button-1>", lambda e, c=rgb: self._set_color(c))
|
||||||
|
# Tk kennt keine Tooltips -- der Farbname landet in der Zeile darunter.
|
||||||
|
chip.bind("<Enter>", lambda e, n=name: self._color_hint.configure(text=n))
|
||||||
|
chip.bind("<Leave>", 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 = tk.Frame(led_frame, bg=BG2)
|
||||||
anim_row.pack(anchor="w", pady=4)
|
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")
|
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()),
|
ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()),
|
||||||
width=12, state="readonly").pack(side="left", padx=6)
|
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.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,
|
tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var,
|
||||||
width=7).pack(side="left", padx=6)
|
width=7).pack(side="left", padx=6)
|
||||||
|
return row + 1
|
||||||
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._on_type_change()
|
|
||||||
|
|
||||||
def _hex(self):
|
def _hex(self):
|
||||||
r, g, b = self._led_color
|
r, g, b = self._led_color
|
||||||
return f"#{r:02x}{g:02x}{b:02x}"
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
def _pick_color(self):
|
def _set_color(self, rgb):
|
||||||
result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe")
|
self._led_color = tuple(rgb)
|
||||||
if result and result[0]:
|
|
||||||
r, g, b = (int(c) for c in result[0])
|
|
||||||
self._led_color = (r, g, b)
|
|
||||||
self._swatch.configure(bg=self._hex())
|
self._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)
|
||||||
|
if result and result[0]:
|
||||||
|
self._set_color(tuple(int(c) for c in result[0]))
|
||||||
|
|
||||||
|
# ── Action-Panels ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_type_change(self):
|
def _on_type_change(self):
|
||||||
for w in self._panel.winfo_children():
|
for w in self._panel.winfo_children():
|
||||||
|
|
@ -243,39 +513,16 @@ class ActionEditDialog(_ModalDialog):
|
||||||
t = self._type_var.get()
|
t = self._type_var.get()
|
||||||
|
|
||||||
if t == "HidKey":
|
if t == "HidKey":
|
||||||
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
|
self._build_hidkey_panel()
|
||||||
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":
|
elif t == "HidConsumer":
|
||||||
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
|
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")
|
tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM,
|
||||||
names = [n for _, n in vp.consumer_choices()]
|
font=("Segoe UI", 9)).pack(side="left")
|
||||||
ttk.Combobox(row1, textvariable=self._consumer_var, values=names,
|
ttk.Combobox(row1, textvariable=self._consumer_var,
|
||||||
|
values=[n for _, n in vp.consumer_choices()],
|
||||||
width=20, state="readonly").pack(side="left", padx=6)
|
width=20, state="readonly").pack(side="left", padx=6)
|
||||||
|
|
||||||
elif t == "Macro":
|
elif t == "Macro":
|
||||||
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
|
self._build_macro_panel()
|
||||||
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":
|
elif t == "ProfileSwitch":
|
||||||
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
|
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")
|
tk.Label(row1, text="Ziel:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
|
||||||
|
|
@ -285,41 +532,90 @@ class ActionEditDialog(_ModalDialog):
|
||||||
|
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
|
|
||||||
def _refresh_macro_preview(self):
|
def _build_hidkey_panel(self):
|
||||||
slot = self._macro_slot_var.get()
|
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
|
||||||
steps = self._macros[slot] if 0 <= slot < len(self._macros) else []
|
self._hidkey_mods = {}
|
||||||
self._macro_preview_var.set(f"Slot {slot}: {vp.macro_slot_label(steps)}")
|
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("<<ComboboxSelected>>", self._on_macro_slot_selected)
|
||||||
|
tk.Button(row1, text="Schritte bearbeiten…", command=self._edit_macro_steps,
|
||||||
|
bg=BG, fg=TEXT, activebackground=BG, relief="flat",
|
||||||
|
padx=8).pack(side="left", padx=8)
|
||||||
|
|
||||||
|
tk.Label(self._panel, text="Slots sind global (nicht pro Profil) – dasselbe Makro auf "
|
||||||
|
"zwei Tasten meint denselben Slot.",
|
||||||
|
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w", pady=(6, 0))
|
||||||
|
self._refresh_macro_choices()
|
||||||
|
|
||||||
|
def _on_macro_slot_selected(self, _event=None):
|
||||||
|
self._macro_slot = vp.macro_slot_from_choice(self._macro_choice_var.get())
|
||||||
|
|
||||||
|
def _refresh_macro_choices(self):
|
||||||
|
choices = vp.macro_slot_choices(self._macros)
|
||||||
|
self._macro_combo.configure(values=choices)
|
||||||
|
self._macro_choice_var.set(choices[self._macro_slot])
|
||||||
|
|
||||||
def _edit_macro_steps(self):
|
def _edit_macro_steps(self):
|
||||||
slot = self._macro_slot_var.get()
|
slot = self._macro_slot
|
||||||
current = self._macros[slot] if 0 <= slot < len(self._macros) else []
|
current = self._macros[slot] if 0 <= slot < len(self._macros) else []
|
||||||
dlg = MacroStepsDialog(self, current)
|
dlg = MacroStepsDialog(self, current)
|
||||||
if dlg.run():
|
if dlg.run():
|
||||||
while len(self._macros) <= slot:
|
while len(self._macros) <= slot:
|
||||||
self._macros.append([])
|
self._macros.append([])
|
||||||
self._macros[slot] = dlg.steps
|
self._macros[slot] = dlg.steps
|
||||||
self._refresh_macro_preview()
|
self._refresh_macro_choices()
|
||||||
|
|
||||||
|
# ── Ergebnis ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_ok(self):
|
def _on_ok(self):
|
||||||
t = self._type_var.get()
|
t = self._type_var.get()
|
||||||
if t == "None":
|
if t == "HidKey":
|
||||||
action = {"type": "None", "data": 0}
|
keycode = self._key_code_by_name.get(self._hidkey_key_var.get(), 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())
|
modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get())
|
||||||
action = {"type": "HidKey", "data": (modifier << 8) | keycode}
|
action = {"type": "HidKey", "data": (modifier << 8) | keycode}
|
||||||
elif t == "HidConsumer":
|
elif t == "HidConsumer":
|
||||||
action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]}
|
action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]}
|
||||||
elif t == "Macro":
|
elif t == "Macro":
|
||||||
action = {"type": "Macro", "data": self._macro_slot_var.get()}
|
action = {"type": "Macro", "data": self._macro_slot}
|
||||||
elif t == "ProfileSwitch":
|
elif t == "ProfileSwitch":
|
||||||
name = self._profile_switch_var.get()
|
name = self._profile_switch_var.get()
|
||||||
data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)
|
action = {"type": "ProfileSwitch",
|
||||||
action = {"type": "ProfileSwitch", "data": data}
|
"data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)}
|
||||||
else:
|
else:
|
||||||
action = {"type": "None", "data": 0}
|
action = {"type": "None", "data": 0}
|
||||||
|
|
||||||
|
action["note"] = self._note_var.get()
|
||||||
self.action = action
|
self.action = action
|
||||||
if self._led is not None:
|
if self._led is not None:
|
||||||
r, g, b = self._led_color
|
r, g, b = self._led_color
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis
|
# Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis
|
||||||
# nach lokal (C:\Users\<user>\AppData\Local\VersaPadViewer).
|
# nach %LOCALAPPDATA%\VersaPadViewer (nicht $projectDir\dist wie im
|
||||||
|
# jappel-Upstream -- hier bereits die tatsaechlich installierte/genutzte
|
||||||
|
# Instanz, siehe Speicher-Notiz "VersaPad Viewer Tool"; ein Pfadwechsel
|
||||||
|
# wuerde bestehende Verknuepfungen/Autostart-Eintraege brechen).
|
||||||
#
|
#
|
||||||
# WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf
|
# WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf
|
||||||
# dem Netzlaufwerk (Z:\Git\...):
|
# dem Netzlaufwerk (Z:\Git\...):
|
||||||
|
|
@ -12,13 +15,50 @@
|
||||||
# SMB-Share, kombiniert mit dem eh schon langen Projektpfad.
|
# SMB-Share, kombiniert mit dem eh schon langen Projektpfad.
|
||||||
#
|
#
|
||||||
# Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach
|
# Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach
|
||||||
# das fertige Bundle nach %LOCALAPPDATA% kopieren. Z: wird nur zum Lesen der
|
# das fertige Bundle nach $projectDir\dist kopieren. Z: wird nur zum Lesen
|
||||||
# Quelldateien angefasst.
|
# der Quelldateien angefasst.
|
||||||
|
#
|
||||||
|
# Fehlerverhalten: Bei Doppelklick im Explorer schliesst sich das Fenster
|
||||||
|
# sofort nach Skriptende -- ohne Pause waeren Fehlermeldungen unsichtbar
|
||||||
|
# ("stirbt ohne jede Fehlermeldung"). Deshalb: alles in try/catch, im
|
||||||
|
# Fehlerfall UND am Ende eine Pause, ausser bei -NoPause (fuer CI/Automation).
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$NoPause
|
||||||
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$projectDir = $PSScriptRoot
|
$projectDir = $PSScriptRoot
|
||||||
$buildSrc = "$env:TEMP\versapad_build_src"
|
$buildSrc = "$env:TEMP\versapad_build_src"
|
||||||
$localDir = "$env:LOCALAPPDATA\VersaPadViewer"
|
$localDir = "$env:LOCALAPPDATA\VersaPadViewer"
|
||||||
|
$requirementsFile = "$projectDir\requirements.txt"
|
||||||
|
|
||||||
|
function Assert-LastExitCode([string]$step) {
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "$step ist fehlgeschlagen (Exit-Code $LASTEXITCODE) -- Ausgabe oben pruefen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-ForKeyIfInteractive {
|
||||||
|
if ($NoPause) { return }
|
||||||
|
try {
|
||||||
|
Read-Host "Taste druecken zum Schliessen"
|
||||||
|
} catch {
|
||||||
|
# z.B. nicht-interaktiver Aufruf (stdin nicht verfuegbar) -- einfach ignorieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Host "Pruefe Python-Installation..."
|
||||||
|
py --version
|
||||||
|
Assert-LastExitCode "'py --version' (ist Python installiert und im PATH?)"
|
||||||
|
|
||||||
|
if (-not (Test-Path $requirementsFile)) {
|
||||||
|
throw "requirements.txt nicht gefunden unter $requirementsFile"
|
||||||
|
}
|
||||||
|
Write-Host "Installiere/aktualisiere benoetigte Pakete aus requirements.txt..."
|
||||||
|
py -m pip install --quiet --disable-pip-version-check -r $requirementsFile
|
||||||
|
Assert-LastExitCode "Paketinstallation (pip install -r requirements.txt)"
|
||||||
|
|
||||||
if (Test-Path $buildSrc) {
|
if (Test-Path $buildSrc) {
|
||||||
Remove-Item $buildSrc -Recurse -Force
|
Remove-Item $buildSrc -Recurse -Force
|
||||||
|
|
@ -30,17 +70,51 @@ Copy-Item "$projectDir\icon.png" -Destination $buildSrc
|
||||||
|
|
||||||
Push-Location $buildSrc
|
Push-Location $buildSrc
|
||||||
try {
|
try {
|
||||||
|
Write-Host "Baue mit PyInstaller (--onedir --windowed)..."
|
||||||
py -m PyInstaller --windowed --onedir --name VersaPadViewer `
|
py -m PyInstaller --windowed --onedir --name VersaPadViewer `
|
||||||
--icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." `
|
--icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." `
|
||||||
desktop_viewer.py --noconfirm
|
desktop_viewer.py --noconfirm
|
||||||
|
Assert-LastExitCode "PyInstaller-Build"
|
||||||
} finally {
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path "$buildSrc\dist\VersaPadViewer\VersaPadViewer.exe")) {
|
||||||
|
throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Zielverzeichnis abraeumen, aber NIE Nutzerdaten mitloeschen: die Config
|
||||||
|
# liegt zwar inzwischen woanders (Roaming-AppData, siehe
|
||||||
|
# versapad_data.app_dir()), aeltere Installationen haben sie aber noch
|
||||||
|
# hier liegen -- ohne diese Sicherung loescht jeder Rebuild sie mit
|
||||||
|
# (am 2026-08-15 genau so passiert, alle Notizen weg).
|
||||||
|
$userData = Get-ChildItem $localDir -Filter "versapad_config*.json" -File -ErrorAction SilentlyContinue
|
||||||
|
$rescued = @()
|
||||||
|
foreach ($f in $userData) {
|
||||||
|
$tmp = Join-Path $env:TEMP $f.Name
|
||||||
|
Copy-Item $f.FullName $tmp -Force
|
||||||
|
$rescued += @{ Tmp = $tmp; Name = $f.Name }
|
||||||
|
Write-Host "Nutzerdatei gesichert: $($f.Name)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
if (Test-Path $localDir) {
|
if (Test-Path $localDir) {
|
||||||
Remove-Item $localDir -Recurse -Force
|
Remove-Item $localDir -Recurse -Force
|
||||||
}
|
}
|
||||||
Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse
|
Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse
|
||||||
|
|
||||||
|
foreach ($r in $rescued) {
|
||||||
|
Copy-Item $r.Tmp (Join-Path $localDir $r.Name) -Force
|
||||||
|
Remove-Item $r.Tmp -Force
|
||||||
|
}
|
||||||
Remove-Item $buildSrc -Recurse -Force
|
Remove-Item $buildSrc -Recurse -Force
|
||||||
|
|
||||||
Write-Host "Fertig: $localDir\VersaPadViewer.exe"
|
Write-Host ""
|
||||||
|
Write-Host "Fertig: $localDir\VersaPadViewer.exe" -ForegroundColor Green
|
||||||
|
Wait-ForKeyIfInteractive
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "FEHLER: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Wait-ForKeyIfInteractive
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ automatisch ausgeschaltet, damit sich beide nicht um den Port streiten).
|
||||||
|
|
||||||
Start: python desktop_viewer.py
|
Start: python desktop_viewer.py
|
||||||
"""
|
"""
|
||||||
|
import copy
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -54,7 +55,11 @@ OK_GREEN = "#3ecf6e"
|
||||||
WARN_RED = "#e0895a"
|
WARN_RED = "#e0895a"
|
||||||
|
|
||||||
POLL_MS = 1500
|
POLL_MS = 1500
|
||||||
CARD_W, CARD_H = 150, 84
|
CARD_W, CARD_H = 150, 114
|
||||||
|
# Untergrenze fuers Verkleinern (self.minsize) -- knapp unter der Groesse,
|
||||||
|
# die das 4x5-Grid + Encoder mindestens brauchen, damit nichts
|
||||||
|
# abgeschnitten wird.
|
||||||
|
MIN_W, MIN_H = 700, 500
|
||||||
SERIAL_POLL_S = 1.5
|
SERIAL_POLL_S = 1.5
|
||||||
SERIAL_IDLE_S = 3.0
|
SERIAL_IDLE_S = 3.0
|
||||||
|
|
||||||
|
|
@ -97,9 +102,11 @@ Buttons setzen (profile 0-2, index 0-19):
|
||||||
set_button_profile_switch(profile, index, target)
|
set_button_profile_switch(profile, index, target)
|
||||||
set_button_none(profile, index)
|
set_button_none(profile, index)
|
||||||
set_button_led(profile, index, r, g, b, anim, period_ms)
|
set_button_led(profile, index, r, g, b, anim, period_ms)
|
||||||
|
set_button_note(profile, index, note) freie Notiz, was der Button tut
|
||||||
|
|
||||||
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
|
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
|
||||||
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
|
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
|
||||||
|
set_encoder_note(profile, index, field, note)
|
||||||
|
|
||||||
Makro:
|
Makro:
|
||||||
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
|
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
|
||||||
|
|
@ -122,13 +129,23 @@ class VersaPadViewer(tk.Tk):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.title("VersaPad Steuermatrix")
|
self.title("VersaPad Steuermatrix")
|
||||||
self.configure(bg=BG)
|
self.configure(bg=BG)
|
||||||
self.geometry("760x760")
|
# Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile
|
||||||
|
# fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114)
|
||||||
|
# reichten die alten 760px nicht mehr: der Encoder-Bereich lag
|
||||||
|
# unterhalb des Fensterrands. Wer CARD_H aendert, muss hier mit.
|
||||||
|
self.geometry("790x960")
|
||||||
self.profile = 0
|
self.profile = 0
|
||||||
self._mtimes = {}
|
self._mtimes = {}
|
||||||
self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt
|
self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt
|
||||||
|
|
||||||
self._link = vs.VersaPadLink()
|
self._link = vs.VersaPadLink()
|
||||||
self._closing = False
|
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.live_sync = tk.BooleanVar(value=False)
|
||||||
self.editing = tk.BooleanVar(value=False)
|
self.editing = tk.BooleanVar(value=False)
|
||||||
self._serial_results = queue.Queue()
|
self._serial_results = queue.Queue()
|
||||||
|
|
@ -149,55 +166,57 @@ class VersaPadViewer(tk.Tk):
|
||||||
)
|
)
|
||||||
self._tray_icon.run_detached()
|
self._tray_icon.run_detached()
|
||||||
|
|
||||||
header = tk.Frame(self, bg=BG)
|
# Normales Fenster MIT Windows-Titelleiste. Bis 2026-08-28 lief es
|
||||||
header.pack(fill="x", padx=20, pady=(18, 4))
|
# randlos (`overrideredirect(True)`) -- das kostet unter Windows aber
|
||||||
tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT,
|
# zwingend den Taskleisten-Eintrag, und der wurde ausdruecklich
|
||||||
font=("Segoe UI", 15, "bold")).pack(anchor="w")
|
# gebraucht. Nachruesten liesse er sich nur per `SetWindowLongW`
|
||||||
self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG,
|
# (WS_EX_APPWINDOW) -- genau der Aufruf, der laut Speicher-Notiz
|
||||||
fg=TEXT_DIM, font=("Segoe UI", 9))
|
# "keine Selbstversteck-Fenstertricks" schon AV-Fehlalarme
|
||||||
self.header_sub.pack(anchor="w")
|
# ausgeloest hat und deshalb nicht in Frage kommt. Mit der echten
|
||||||
|
# Titelleiste kommen Taskleiste, Alt+Tab, Aero-Snap, Ziehen und
|
||||||
|
# Groessenaendern am Rahmen nativ zurueck; die Eigenbau-Loesungen
|
||||||
|
# dafuer (ziehbare Kopfzeile, Anfasser unten rechts, eigene
|
||||||
|
# ✕/—-Knoepfe) sind damit ersatzlos entfallen.
|
||||||
|
self.minsize(MIN_W, MIN_H)
|
||||||
|
|
||||||
info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14),
|
self.toggles_row = header = tk.Frame(self, bg=BG)
|
||||||
cursor="hand2")
|
header.pack(fill="x", padx=20, pady=(10, 6))
|
||||||
info_btn.place(relx=1.0, x=0, y=-4, anchor="ne")
|
tk.Label(header, text="VersaPad", bg=BG, fg=TEXT,
|
||||||
|
font=("Segoe UI", 11, "bold")).pack(side="left", anchor="w", padx=(0, 16))
|
||||||
|
|
||||||
|
info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12),
|
||||||
|
cursor="hand2", padx=6)
|
||||||
|
info_btn.pack(side="right", padx=(0, 8))
|
||||||
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
|
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
|
||||||
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
|
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
|
||||||
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
|
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
|
||||||
|
|
||||||
self.tabs = tk.Frame(self, bg=BG)
|
# Modus-Umschalter direkt neben der Ueberschrift statt in eigener
|
||||||
self.tabs.pack(fill="x", padx=20, pady=(12, 10))
|
# Zeile -- spart eine komplette Zeile Fensterhoehe.
|
||||||
self.tab_buttons = {}
|
|
||||||
for p in sorted(vp.PROFILE_NAMES):
|
|
||||||
btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT,
|
|
||||||
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
|
|
||||||
btn.pack(side="left", padx=(0, 8))
|
|
||||||
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
|
|
||||||
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
|
|
||||||
self.tab_buttons[p] = btn
|
|
||||||
|
|
||||||
toggles_row = tk.Frame(self, bg=BG)
|
|
||||||
toggles_row.pack(fill="x", padx=20, pady=(0, 6))
|
|
||||||
self.sync_check = tk.Checkbutton(
|
self.sync_check = tk.Checkbutton(
|
||||||
toggles_row, text="Live-Sync mit Board", variable=self.live_sync,
|
header, text="Live-Sync", variable=self.live_sync,
|
||||||
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||||||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
|
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||||||
self.sync_check.pack(side="left")
|
self.sync_check.pack(side="left")
|
||||||
self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9))
|
self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||||||
self.sync_status.pack(side="left", padx=(8, 20))
|
self.sync_status.pack(side="left", padx=(4, 14))
|
||||||
|
|
||||||
self.edit_check = tk.Checkbutton(
|
self.edit_check = tk.Checkbutton(
|
||||||
toggles_row, text="Programmiermodus", variable=self.editing,
|
header, text="Programmiermodus", variable=self.editing,
|
||||||
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||||||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
|
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||||||
self.edit_check.pack(side="left", padx=(0, 20))
|
self.edit_check.pack(side="left", padx=(0, 14))
|
||||||
|
|
||||||
self.always_on_top = tk.BooleanVar(value=False)
|
self.always_on_top = tk.BooleanVar(value=False)
|
||||||
self.topmost_check = tk.Checkbutton(
|
self.topmost_check = tk.Checkbutton(
|
||||||
toggles_row, text="Immer im Vordergrund", variable=self.always_on_top,
|
header, text="Immer im Vordergrund", variable=self.always_on_top,
|
||||||
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||||||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
|
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||||||
self.topmost_check.pack(side="left")
|
self.topmost_check.pack(side="left")
|
||||||
|
|
||||||
|
# Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im
|
||||||
|
# Programmiermodus (siehe _on_toggle_editing), direkt unter den
|
||||||
|
# Checkboxen statt weit unten zwischen Tabs und Matrix.
|
||||||
self.prog_row = tk.Frame(self, bg=BG)
|
self.prog_row = tk.Frame(self, bg=BG)
|
||||||
for text, cmd in (
|
for text, cmd in (
|
||||||
("Vom Board laden", self._load_from_board),
|
("Vom Board laden", self._load_from_board),
|
||||||
|
|
@ -207,24 +226,49 @@ class VersaPadViewer(tk.Tk):
|
||||||
):
|
):
|
||||||
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
|
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
|
||||||
activebackground=ACCENT, activeforeground="#fff", relief="flat",
|
activebackground=ACCENT, activeforeground="#fff", relief="flat",
|
||||||
padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
|
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", 9))
|
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))
|
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)
|
# prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing)
|
||||||
|
|
||||||
|
# Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am
|
||||||
|
# Fensterkopf -- naeher an dem, was sie auswaehlen.
|
||||||
|
self.tabs = tk.Frame(self, bg=BG)
|
||||||
|
self.tabs.pack(fill="x", padx=20, pady=(8, 8))
|
||||||
|
self.tab_buttons = {}
|
||||||
|
for p in range(vp.NUM_PROFILES):
|
||||||
|
btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT,
|
||||||
|
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
|
||||||
|
btn.pack(side="left", padx=(0, 8))
|
||||||
|
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
|
||||||
|
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
|
||||||
|
self.tab_buttons[p] = btn
|
||||||
|
|
||||||
self.grid_frame = tk.Frame(self, bg=BG)
|
self.grid_frame = tk.Frame(self, bg=BG)
|
||||||
self.grid_frame.pack(padx=20, pady=(8, 0))
|
self.grid_frame.pack(padx=20, pady=(0, 0), anchor="w")
|
||||||
|
|
||||||
tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM,
|
tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM,
|
||||||
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8))
|
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8))
|
||||||
self.enc_frame = tk.Frame(self, bg=BG)
|
self.enc_frame = tk.Frame(self, bg=BG)
|
||||||
self.enc_frame.pack(fill="x", padx=20)
|
self.enc_frame.pack(fill="x", padx=20)
|
||||||
|
|
||||||
self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
footer_row = tk.Frame(self, bg=BG)
|
||||||
self.footer.pack(anchor="w", padx=20, pady=(20, 10))
|
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 <Unmap>-Handler auch das ins Tray
|
||||||
|
# umgeleitet, was ohne Taskleisten-Eintrag sinnvoll war und mit
|
||||||
|
# einem das Fenster nur unauffindbar machen wuerde.
|
||||||
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
|
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
|
||||||
self.bind("<Unmap>", self._on_unmap)
|
self._bind_shortcuts()
|
||||||
self._serial_thread.start()
|
self._serial_thread.start()
|
||||||
|
|
||||||
self.set_profile(0)
|
self.set_profile(0)
|
||||||
|
|
@ -242,20 +286,48 @@ class VersaPadViewer(tk.Tk):
|
||||||
self._render()
|
self._render()
|
||||||
|
|
||||||
def _update_tab_labels(self):
|
def _update_tab_labels(self):
|
||||||
for p, btn in self.tab_buttons.items():
|
"""Profilnamen kommen immer aus der kombinierten JSON (nicht mehr
|
||||||
|
hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst
|
||||||
|
per schlankem Datei-Read (kein Board-Zugriff, siehe
|
||||||
|
versapad_combined.profile_names())."""
|
||||||
if self.editing.get() and self.combined:
|
if self.editing.get() and self.combined:
|
||||||
btn.configure(text=self.combined["profile_names"][p])
|
names = self.combined["profile_names"]
|
||||||
else:
|
else:
|
||||||
btn.configure(text=vp.PROFILE_NAMES[p])
|
names = vcomb.read_profile_names()
|
||||||
|
for p, btn in self.tab_buttons.items():
|
||||||
|
btn.configure(text=names[p])
|
||||||
|
|
||||||
def _rename_tab(self, profile):
|
def _rename_tab(self, profile):
|
||||||
if not self.editing.get() or self.combined is None:
|
"""Profilname per Doppelklick auf den Tab umbenennen -- geht in
|
||||||
|
jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische
|
||||||
|
Domänenregeln). Im Programmiermodus wird der bereits geladene
|
||||||
|
In-Memory-State direkt bearbeitet + autosaved; sonst wird die
|
||||||
|
kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf
|
||||||
|
automatisch an, siehe versapad_combined.load_or_fetch())."""
|
||||||
|
if self.editing.get() and self.combined is not None:
|
||||||
|
combined = self.combined
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
combined = vcomb.load_or_fetch()
|
||||||
|
except (OSError, ValueError, KeyError) as e:
|
||||||
|
messagebox.showerror("Umbenennen fehlgeschlagen", str(e))
|
||||||
return
|
return
|
||||||
current = self.combined["profile_names"][profile]
|
|
||||||
|
current = combined["profile_names"][profile]
|
||||||
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
|
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
|
||||||
initialvalue=current, parent=self)
|
initialvalue=current, parent=self)
|
||||||
if name:
|
if not name:
|
||||||
self.combined["profile_names"][profile] = name
|
return
|
||||||
|
combined["profile_names"][profile] = name
|
||||||
|
|
||||||
|
if combined is self.combined:
|
||||||
|
self._autosave_combined()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
vcomb.save_file(combined, vcomb.DEFAULT_PATH)
|
||||||
|
except OSError as e:
|
||||||
|
messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}")
|
||||||
|
return
|
||||||
self._update_tab_labels()
|
self._update_tab_labels()
|
||||||
|
|
||||||
def _show_mcp_info(self):
|
def _show_mcp_info(self):
|
||||||
|
|
@ -342,13 +414,7 @@ class VersaPadViewer(tk.Tk):
|
||||||
text = SERIAL_STATUS_TEXT.get(error, error or "Fehler")
|
text = SERIAL_STATUS_TEXT.get(error, error or "Fehler")
|
||||||
self.sync_status.configure(text=text, fg=WARN_RED)
|
self.sync_status.configure(text=text, fg=WARN_RED)
|
||||||
|
|
||||||
# ── Tray-Icon (kein Taskleisten-Eintrag beim Minimieren, wie VersaGUI) ──
|
# ── Tray-Icon (Schliessen beendet nicht, wie bei der 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):
|
def _hide_to_tray(self):
|
||||||
self.withdraw()
|
self.withdraw()
|
||||||
|
|
@ -391,13 +457,17 @@ class VersaPadViewer(tk.Tk):
|
||||||
self._on_toggle_sync()
|
self._on_toggle_sync()
|
||||||
self.sync_check.configure(state="disabled")
|
self.sync_check.configure(state="disabled")
|
||||||
if self.combined is None:
|
if self.combined is None:
|
||||||
|
if os.path.exists(vcomb.DEFAULT_PATH):
|
||||||
|
try:
|
||||||
|
self.combined = vcomb.load_file(vcomb.DEFAULT_PATH)
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
self.combined = vcomb.default_combined()
|
self.combined = vcomb.default_combined()
|
||||||
self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs)
|
else:
|
||||||
self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten")
|
self.combined = vcomb.default_combined()
|
||||||
|
self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row)
|
||||||
else:
|
else:
|
||||||
self.sync_check.configure(state="normal")
|
self.sync_check.configure(state="normal")
|
||||||
self.prog_row.pack_forget()
|
self.prog_row.pack_forget()
|
||||||
self.header_sub.configure(text="pollt Config-JSONs alle 1.5s")
|
|
||||||
self._update_tab_labels()
|
self._update_tab_labels()
|
||||||
self._render()
|
self._render()
|
||||||
|
|
||||||
|
|
@ -426,7 +496,9 @@ class VersaPadViewer(tk.Tk):
|
||||||
macro_slots = vproto.unpack_macros(raw_macros)
|
macro_slots = vproto.unpack_macros(raw_macros)
|
||||||
|
|
||||||
names = self.combined["profile_names"] if self.combined else None
|
names = self.combined["profile_names"] if self.combined else None
|
||||||
self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
|
previous = self.combined
|
||||||
|
self.combined = vcomb.merge_notes(
|
||||||
|
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
|
||||||
self.profile = self.combined["active_profile"]
|
self.profile = self.combined["active_profile"]
|
||||||
self._status("vom Board geladen", True)
|
self._status("vom Board geladen", True)
|
||||||
self._update_tab_labels()
|
self._update_tab_labels()
|
||||||
|
|
@ -513,6 +585,183 @@ class VersaPadViewer(tk.Tk):
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self._status(f"Auto-Speichern fehlgeschlagen: {e}", False)
|
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.
|
||||||
|
("<Escape>", lambda e: self.iconify()),
|
||||||
|
("<F2>", lambda e: self._rename_tab(self.profile)),
|
||||||
|
("<Control-e>", lambda e: self._toggle_editing_shortcut()),
|
||||||
|
("<Control-E>", lambda e: self._toggle_editing_shortcut()),
|
||||||
|
("<Control-c>", lambda e: self._copy_hovered()),
|
||||||
|
("<Control-C>", lambda e: self._copy_hovered()),
|
||||||
|
("<Control-v>", lambda e: self._paste_hovered()),
|
||||||
|
("<Control-V>", lambda e: self._paste_hovered()),
|
||||||
|
):
|
||||||
|
self.bind(seq, handler)
|
||||||
|
for p in range(vp.NUM_PROFILES):
|
||||||
|
self.bind(f"<Control-Key-{p + 1}>",
|
||||||
|
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):
|
||||||
|
"""<Leave> 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("<Button-1>", lambda e: self._edit_target(target))
|
||||||
|
widget.bind("<Button-3>", lambda e: self._show_context_menu(e, target))
|
||||||
|
widget.bind("<Enter>", lambda e: self._set_hover(target))
|
||||||
|
card.bind("<Leave>", lambda e: self._clear_hover(target))
|
||||||
|
|
||||||
# ── Rendering ────────────────────────────────────────────────────
|
# ── Rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _current_profile_view(self):
|
def _current_profile_view(self):
|
||||||
|
|
@ -520,22 +769,36 @@ class VersaPadViewer(tk.Tk):
|
||||||
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
|
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
|
||||||
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
|
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
|
||||||
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
|
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
|
||||||
Faellt zurueck auf die klassischen versapad_config{1,2,3}.json, wenn
|
Fehlt sie (z.B. versehentlich geloescht, oder frische Installation
|
||||||
es noch keine kombinierte Datei gibt."""
|
ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird
|
||||||
if os.path.exists(vcomb.DEFAULT_PATH):
|
sie automatisch angelegt -- per Serial vom Board, oder als leere
|
||||||
|
Default-Config, wenn auch kein Board erreichbar ist (siehe
|
||||||
|
versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz
|
||||||
|
ohne vorhandene Config sofort benutzbar, kein Datei-Handling von
|
||||||
|
Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den
|
||||||
|
COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die
|
||||||
|
klassischen versapad_config{1,2,3}.json (Export der offiziellen
|
||||||
|
VersaGUI) aus."""
|
||||||
|
if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get():
|
||||||
try:
|
try:
|
||||||
data = vcomb.load_file(vcomb.DEFAULT_PATH)
|
data = vcomb.load_or_fetch(link=self._link)
|
||||||
raw = data["profiles"][self.profile]
|
raw = data["profiles"][self.profile]
|
||||||
cfg = vp.annotate_profile({
|
cfg = vp.annotate_profile({
|
||||||
"buttons": [dict(b) for b in raw["buttons"]],
|
"buttons": [dict(b) for b in raw["buttons"]],
|
||||||
"encoders": [dict(e) for e in raw["encoders"]],
|
"encoders": [dict(e) for e in raw["encoders"]],
|
||||||
})
|
}, data.get("macros"))
|
||||||
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
|
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
|
||||||
except (KeyError, IndexError, ValueError):
|
except (KeyError, IndexError, ValueError):
|
||||||
pass # kaputte/unvollstaendige Datei -- auf Einzel-JSONs ausweichen
|
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen
|
||||||
|
try:
|
||||||
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
|
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
hint = (" (Live-Sync ist an -- COM-Port belegt, zum automatischen "
|
||||||
|
"Neuladen vom Board erst ausschalten)") if self.live_sync.get() else ""
|
||||||
|
raise FileNotFoundError(f"{e}{hint}") from e
|
||||||
|
|
||||||
def _render(self):
|
def _render(self):
|
||||||
|
self._update_tab_labels()
|
||||||
for w in self.grid_frame.winfo_children():
|
for w in self.grid_frame.winfo_children():
|
||||||
w.destroy()
|
w.destroy()
|
||||||
for w in self.enc_frame.winfo_children():
|
for w in self.enc_frame.winfo_children():
|
||||||
|
|
@ -549,7 +812,7 @@ class VersaPadViewer(tk.Tk):
|
||||||
cfg = vp.annotate_profile({
|
cfg = vp.annotate_profile({
|
||||||
"buttons": [dict(b) for b in raw["buttons"]],
|
"buttons": [dict(b) for b in raw["buttons"]],
|
||||||
"encoders": [dict(e) for e in raw["encoders"]],
|
"encoders": [dict(e) for e in raw["encoders"]],
|
||||||
})
|
}, self.combined.get("macros"))
|
||||||
source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert"
|
source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert"
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
|
@ -591,19 +854,23 @@ class VersaPadViewer(tk.Tk):
|
||||||
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
|
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
|
||||||
font=("Segoe UI", 10, "normal" if empty else "bold"),
|
font=("Segoe UI", 10, "normal" if empty else "bold"),
|
||||||
wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
||||||
x=10, y=26, width=CARD_W - 20, height=36)
|
x=10, y=24, width=CARD_W - 20, height=28)
|
||||||
|
|
||||||
|
# Notiz bekommt den ganzen Rest der Karte (bis zur Animationszeile) --
|
||||||
|
# bei 34px wurden laengere Notizen mitten im Wort abgeschnitten.
|
||||||
|
tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM,
|
||||||
|
font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
||||||
|
x=10, y=53, width=CARD_W - 20, height=CARD_H - 69)
|
||||||
|
|
||||||
anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"])
|
anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"])
|
||||||
tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
|
tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
|
||||||
font=("Segoe UI", 7), anchor="sw").place(
|
font=("Segoe UI", 7), anchor="sw").place(
|
||||||
x=10, y=CARD_H - 20, width=CARD_W - 20, height=14)
|
x=10, y=CARD_H - 16, width=CARD_W - 20, height=13)
|
||||||
|
|
||||||
if editable:
|
if editable:
|
||||||
card.configure(cursor="hand2")
|
target = {"kind": "button", "index": btn["index"], "card": card,
|
||||||
handler = lambda e, idx=btn["index"]: self._edit_button(idx)
|
"label": "Button #{}".format(btn["index"])}
|
||||||
card.bind("<Button-1>", handler)
|
self._bind_cell_interaction(card, [card] + list(card.winfo_children()), target)
|
||||||
for child in card.winfo_children():
|
|
||||||
child.bind("<Button-1>", handler)
|
|
||||||
|
|
||||||
def _render_encoder(self, enc, editable=False):
|
def _render_encoder(self, enc, editable=False):
|
||||||
card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER,
|
card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER,
|
||||||
|
|
@ -613,18 +880,31 @@ class VersaPadViewer(tk.Tk):
|
||||||
inner.pack(fill="both", expand=True, padx=10, pady=8)
|
inner.pack(fill="both", expand=True, padx=10, pady=8)
|
||||||
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM,
|
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM,
|
||||||
font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))
|
font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))
|
||||||
for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]),
|
for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]),
|
||||||
("CCW", "ccw", enc["ccw_label"])):
|
("CW", "cw", enc["cw_label"], enc["cw_note"]),
|
||||||
|
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
|
||||||
row = tk.Frame(inner, bg=CARD_BG)
|
row = tk.Frame(inner, bg=CARD_BG)
|
||||||
row.pack(fill="x", pady=1)
|
row.pack(fill="x", pady=(1, 4))
|
||||||
tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
|
top = tk.Frame(row, bg=CARD_BG)
|
||||||
tk.Label(row, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
|
top.pack(fill="x")
|
||||||
|
tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
|
||||||
|
tk.Label(top, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
|
||||||
|
# Notizzeile nur anlegen, wenn es wirklich eine Notiz gibt --
|
||||||
|
# ein leeres Label belegt sonst pro Encoder-Aktion eine Zeile
|
||||||
|
# Hoehe (12x im Fenster) und schiebt die Fusszeile aus dem Bild.
|
||||||
|
note_label = None
|
||||||
|
if note:
|
||||||
|
note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7),
|
||||||
|
wraplength=150, justify="left", anchor="w")
|
||||||
|
note_label.pack(fill="x")
|
||||||
if editable:
|
if editable:
|
||||||
row.configure(cursor="hand2")
|
target = {"kind": "encoder", "index": enc["index"], "field": field,
|
||||||
handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl)
|
"field_label": key, "card": row,
|
||||||
row.bind("<Button-1>", handler)
|
"label": "Encoder {} {}".format(enc["index"], key)}
|
||||||
for child in row.winfo_children():
|
widgets = [row, top] + list(top.winfo_children())
|
||||||
child.bind("<Button-1>", handler)
|
if note_label is not None:
|
||||||
|
widgets.append(note_label)
|
||||||
|
self._bind_cell_interaction(row, widgets, target)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
267
docs/architecture.md
Normal file
267
docs/architecture.md
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
# Architektur
|
||||||
|
|
||||||
|
Überblick über die Schichten, Prozesse und Datenflüsse von VersaPad Viewer.
|
||||||
|
Für Domänenregeln, bekannte Bugs und Implementierungsdisziplin siehe
|
||||||
|
[`AGENTS.md`](../AGENTS.md); für Installation/Nutzung siehe
|
||||||
|
[`README.md`](../README.md). Für die genauen Datenformate siehe
|
||||||
|
[`data-model.md`](data-model.md), für das Serial-Wire-Protokoll
|
||||||
|
[`protocol.md`](protocol.md).
|
||||||
|
|
||||||
|
## Ziel und Kontext
|
||||||
|
|
||||||
|
VersaPad Viewer ist ein eigenständiges Python-Tool für das VersaPad-Makropad
|
||||||
|
(4×5-Button-Grid + 4 Encoder, SAMD21-Firmware). Es ergänzt die offizielle
|
||||||
|
VersaGUI (C#/.NET) um eine schlankere, plattformunabhängigere Alternative
|
||||||
|
zum Anzeigen, Live-Synchronisieren und Neuprogrammieren der Belegung, plus
|
||||||
|
einen MCP-Server, der dieselbe Programmierung KI-gesteuert per Tool-Aufruf
|
||||||
|
erlaubt. Beide GUIs (die offizielle VersaGUI und dieses Tool) konkurrieren
|
||||||
|
um denselben exklusiven USB-CDC-Port — siehe „Nebenläufigkeit" unten.
|
||||||
|
|
||||||
|
## Schichtenmodell
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontends │
|
||||||
|
│ ┌────────────┐ ┌──────────────────┐ ┌────────────────────┐ │
|
||||||
|
│ │ server.py │ │ desktop_viewer.py │ │ versapad_mcp_ │ │
|
||||||
|
│ │ (Browser, │ │ (Tkinter, Tray, │ │ server.py │ │
|
||||||
|
│ │ read-only)│ │ Live-Sync, Edit) │ │ (KI-Tool-Aufrufe) │ │
|
||||||
|
│ └─────┬──────┘ └────────┬──────────┘ └─────────┬──────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └───────────┬───────┴──────────────┬───────────┘ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ versapad_combined.py versapad_data.py │
|
||||||
|
│ (Ein-Datei-Format, (Decoding fürs Anzeigen, │
|
||||||
|
│ Board-Sync, Auto- Legacy-Einzel-JSONs) │
|
||||||
|
│ Create) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ versapad_protocol.py (pack/unpack, CRC16) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ versapad_serial.py (VersaPadLink, 8-Byte-Pakete) │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────┼──────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
VersaPad-Board (USB-CDC, VID:PID 239A:0042)
|
||||||
|
```
|
||||||
|
|
||||||
|
`action_dialog.py` ist ein UI-Hilfsmodul von `desktop_viewer.py`
|
||||||
|
(Bearbeiten-Dialoge für den Programmiermodus) und taucht oben nicht separat
|
||||||
|
auf.
|
||||||
|
|
||||||
|
### Read-only-Schicht
|
||||||
|
|
||||||
|
- **`versapad_data.py`** — decodiert JSON-Rohdaten (Keycodes, Consumer-IDs,
|
||||||
|
Modifier-Bits) zu lesbarem Text, kennt die Grid-Geometrie
|
||||||
|
(`index = spalte*5 + reihe`). Liest wahlweise:
|
||||||
|
- die klassischen `versapad_config1/2/3.json` (`CONFIG_PATHS`) — Export
|
||||||
|
der offiziellen VersaGUI, kein von diesem Tool geschriebenes Format;
|
||||||
|
- oder (über `versapad_combined.py`) die kombinierte Datei.
|
||||||
|
- `app_dir()` liefert das Basisverzeichnis für die eigene Config, siehe
|
||||||
|
„Config-Speicherort" unten.
|
||||||
|
- **`server.py`** — generiert bei jedem HTTP-Request frisch HTML aus dem
|
||||||
|
aktuellen Zustand (`vcomb.load_or_fetch()`), Auto-Reload alle 4s per
|
||||||
|
`<meta http-equiv="refresh">`. 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 `<KeyPress>`/`<KeyRelease>` 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 -- <python> 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).
|
||||||
216
docs/data-model.md
Normal file
216
docs/data-model.md
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# Datenmodell
|
||||||
|
|
||||||
|
Alle Formate, die VersaPad Viewer liest/schreibt: die JSON-Repräsentationen
|
||||||
|
und das binäre NVM-Layout des Boards. Das binäre Layout ist 1:1 aus den
|
||||||
|
VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
|
||||||
|
(Read → unpack → pack ist bytegenau identisch zum Original, inklusive
|
||||||
|
CRC) — siehe `versapad_protocol.py` und die „Existing-Codebase-Regel“ in
|
||||||
|
[`AGENTS.md`](../AGENTS.md), bevor hier etwas geändert wird.
|
||||||
|
|
||||||
|
## Geometrie
|
||||||
|
|
||||||
|
```
|
||||||
|
index = spalte * 5 + reihe
|
||||||
|
```
|
||||||
|
|
||||||
|
4 Spalten (`GRID_COLS`), 5 Reihen (`GRID_ROWS`), Reihe 0 = oben, Reihe 4 =
|
||||||
|
unten. Firmware-Reihenfolge, nicht neu herleiten. Damit ergeben sich die
|
||||||
|
20 Button-Indizes so auf dem physischen Grid:
|
||||||
|
|
||||||
|
| | Spalte 0 | Spalte 1 | Spalte 2 | Spalte 3 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Reihe 0 (oben) | 0 | 5 | 10 | 15 |
|
||||||
|
| Reihe 1 | 1 | 6 | 11 | 16 |
|
||||||
|
| Reihe 2 | 2 | 7 | 12 | 17 |
|
||||||
|
| Reihe 3 | 3 | 8 | 13 | 18 |
|
||||||
|
| Reihe 4 (unten) | 4 | 9 | 14 | 19 |
|
||||||
|
|
||||||
|
## Action
|
||||||
|
|
||||||
|
Eine `Action` beschreibt, was ein Button oder eine Encoder-Bewegung
|
||||||
|
auslöst. Sowohl in JSON als auch binär ein `{type, data}`-Paar
|
||||||
|
(binär: `SAction`, 3 Byte — 1 Byte Typ + 2 Byte `data`, little-endian).
|
||||||
|
|
||||||
|
| `type` | Enum-Index | `data`-Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `None` | 0 | ungenutzt (0) |
|
||||||
|
| `HidKey` | 1 | `data = keycode \| (modifier << 8)` — Keycode HID Usage Page 0x07 im unteren Byte, Modifier-Bitmaske im oberen Byte |
|
||||||
|
| `HidConsumer` | 2 | `data` = HID-Consumer-Usage-ID (Usage Page 0x0C), z.B. `0x00CD` = Play/Pause |
|
||||||
|
| `HostCommand` | 3 | Enum-Wert existiert in der Firmware, wird von diesem Tool aktuell nicht gesetzt/editiert (kein `set_button_hostcommand`-Äquivalent) |
|
||||||
|
| `Macro` | 4 | `data` = Makro-Slot-Index (0-31) |
|
||||||
|
| `ProfileSwitch` | 5 | `data` = Ziel-Profil (0/1/2) oder `0xFFFF`/`0x00FF` = „nächstes Profil“ (Zyklus) |
|
||||||
|
|
||||||
|
Modifier-Bitmaske (für `HidKey`, gilt **nicht** 1:1 für Makro-Schritte,
|
||||||
|
siehe unten):
|
||||||
|
|
||||||
|
| Bit | Modifier |
|
||||||
|
|---|---|
|
||||||
|
| `0x01` | Strg |
|
||||||
|
| `0x02` | Shift |
|
||||||
|
| `0x04` | Alt |
|
||||||
|
| `0x08` | Win |
|
||||||
|
|
||||||
|
Jede `Action` trägt zusätzlich ein optionales `note`-Feld (freier Text,
|
||||||
|
z.B. `"Speichern in Fusion 360"`) — **rein lokal**, wie `profile_names`
|
||||||
|
(siehe unten): kein Platz dafür in `SAction` (3 Byte, komplett verplant),
|
||||||
|
`to_binary()`/`pack_config()` ignorieren das Feld beim Schreiben ans
|
||||||
|
Board, `from_binary()` liefert frisch vom Board immer `note=""`.
|
||||||
|
`versapad_combined.merge_notes(neu, alt)` kopiert bestehende Notizen nach
|
||||||
|
jedem `load_from_board()`/`fetch_from_board()` zurück, sonst gingen sie
|
||||||
|
bei jedem Board-Refresh verloren. Editierbar per Programmiermodus-Dialog
|
||||||
|
oder MCP (`set_button_note()`/`set_encoder_note()`).
|
||||||
|
|
||||||
|
## LED
|
||||||
|
|
||||||
|
Pro MX-Button (nicht pro Encoder — Encoder haben keine eigene LED):
|
||||||
|
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `r`, `g`, `b` | uint8 (0-255) | Farbe |
|
||||||
|
| `brightness` | uint8 (0-255) | Helligkeit |
|
||||||
|
| `anim` | Enum-String (JSON) / Enum-Index (binär) | `Static`, `Blink`, `Pulse`, `FadeIn`, `FadeOut`, `ColorCycle`, `ColorFade` |
|
||||||
|
| `period_ms` | uint16 (little-endian) | Animationsperiode in ms (Pulse braucht `>= 2`) |
|
||||||
|
|
||||||
|
## Makro-Schritt
|
||||||
|
|
||||||
|
Ein Makro-Schritt ist **kein** `Action` — Keycode und Modifier stehen in
|
||||||
|
zwei getrennten Bytes (nicht in einem gepackten 16-Bit-`data`-Feld wie bei
|
||||||
|
`HidKey`):
|
||||||
|
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `keycode` | uint8 | HID-Keycode. `0` beendet die Sequenz (Firmware-Konvention — keine Lücken vor dem letzten belegten Schritt lassen) |
|
||||||
|
| `modifier` | uint8 | Bitmaske, aber **nur Strg/Shift/Alt** (kein Win — passend zu `ActionDialog.cs` im Original) |
|
||||||
|
|
||||||
|
Eine Makro-Tabelle hat 32 Slots (`MACRO_SLOTS`) mit je bis zu 8 Schritten
|
||||||
|
(`MACRO_MAX_STEPS`). Sie ist **eine einzige globale Tabelle**, nicht pro
|
||||||
|
Profil — zwei Profile, die per `Macro`-Action denselben Slot referenzieren,
|
||||||
|
spielen dieselben Schritte ab. Konvention für die Slot-Zuordnung (von den
|
||||||
|
Tools/der GUI benutzt, nicht von der Firmware erzwungen):
|
||||||
|
|
||||||
|
- Slot `0`–`19` = MX-Button-Index (Button `i` → Slot `i`)
|
||||||
|
- Slot `20`–`31` = `20 + enc*3 + act_idx` (Encoder `enc`, `act_idx`:
|
||||||
|
0=Druck/`sw`, 1=`cw`, 2=`ccw`)
|
||||||
|
|
||||||
|
## JSON: kombiniertes Format (`versapad_config_all.json`)
|
||||||
|
|
||||||
|
Das von diesem Tool selbst gepflegte Format (`versapad_combined.py`) — alle
|
||||||
|
3 Profile + Makro-Tabelle + lokale Profilnamen in einer Datei. Passt zum
|
||||||
|
Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den
|
||||||
|
kompletten 740B-Block, nie nur ein Profil.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"active_profile": 0,
|
||||||
|
"global_brightness": 255,
|
||||||
|
"enc_sensitivity": [1, 1, 1, 1],
|
||||||
|
"profile_names": ["Windows", "Fusion 360", "BricsCAD"],
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"action": { "type": "HidKey", "data": 30, "note": "Speichern in Fusion 360" },
|
||||||
|
"led": { "r": 80, "g": 40, "b": 0, "brightness": 255,
|
||||||
|
"anim": "Static", "period_ms": 4000 }
|
||||||
|
}
|
||||||
|
// ... 20 Buttons (index 0-19)
|
||||||
|
],
|
||||||
|
"encoders": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"sw": { "type": "ProfileSwitch", "data": 65535 },
|
||||||
|
"cw": { "type": "None", "data": 0 },
|
||||||
|
"ccw": { "type": "None", "data": 0 }
|
||||||
|
}
|
||||||
|
// ... 4 Encoder (index 0-3)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// ... 3 Profile
|
||||||
|
],
|
||||||
|
"macros": [
|
||||||
|
[{ "keycode": 30, "modifier": 0 }, { "keycode": 39, "modifier": 0 }]
|
||||||
|
// ... 32 Slots, jeweils eine Liste mit 0-8 Schritten
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Profilnamen (`profile_names`) sind **rein lokal** — die Firmware-Structs
|
||||||
|
haben keinen Platz für einen String (Header exakt 32B, jedes Profil exakt
|
||||||
|
236B, alles verplant), sie landen nie aufs Board, egal welcher
|
||||||
|
Schreibpfad benutzt wird. Dasselbe gilt für `note` in jeder `Action`
|
||||||
|
(siehe oben).
|
||||||
|
|
||||||
|
## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`)
|
||||||
|
|
||||||
|
Kein von diesem Tool geschriebenes Format — optionaler Export der
|
||||||
|
offiziellen VersaGUI, gelesen von `versapad_data.load_profile()`. Enthält
|
||||||
|
nur ein einzelnes Profil, keine Makro-Schritte, keinen Profilnamen:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"buttons": [ /* wie oben, 20 Eintraege */ ],
|
||||||
|
"encoders": [ /* wie oben, 4 Eintraege */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP-Tool-Grenzfläche (`set_macro`, `set_button_key`, …)
|
||||||
|
|
||||||
|
Die MCP-Tools nehmen **menschenlesbare** Namen entgegen, keine Rohwerte —
|
||||||
|
`versapad_data.py` übersetzt:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// set_button_key(profile=0, index=0, key="S", modifiers=["Strg"])
|
||||||
|
// set_macro(slot=7, steps=[{"key": "1", "modifiers": []}, {"key": "0", "modifiers": []}])
|
||||||
|
```
|
||||||
|
|
||||||
|
`hid_key_code_for_name()` / `consumer_id_for_name()` / `modifier_bits_for_names()`
|
||||||
|
übersetzen Namen → Rohwerte (werfen `ValueError` mit einer Liste gültiger
|
||||||
|
Namen bei Tippfehlern). Zeichentasten-Labels sind eine US-Layout-Näherung
|
||||||
|
(keine `GetKeyNameText()`-Auflösung wie im C#-Original).
|
||||||
|
|
||||||
|
## Binäres NVM-Layout
|
||||||
|
|
||||||
|
### `SDeviceConfig` (740 Byte, `versapad_protocol.CONFIG_SIZE`)
|
||||||
|
|
||||||
|
| Offset | Größe | Feld |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | 4B (uint32 LE) | Magic (`0x56503203`, `NVM_CONFIG_MAGIC`) |
|
||||||
|
| 4 | 1B | Version (`3`, `NVM_CONFIG_VERSION`) |
|
||||||
|
| 5 | 2B (uint16 LE) | CRC16 über Byte 7-739 |
|
||||||
|
| 7 | 1B | `active_profile` (0-2) |
|
||||||
|
| 8 | 1B | `global_brightness` (0-255) |
|
||||||
|
| 9 | 4B | `enc_sensitivity` (4× uint8, einer je Encoder) |
|
||||||
|
| 13 | 19B | reserviert/ungenutzt (Padding) |
|
||||||
|
| 32 | 236B | Profil 0 (`SDeviceProfile`) |
|
||||||
|
| 268 | 236B | Profil 1 |
|
||||||
|
| 504 | 236B | Profil 2 |
|
||||||
|
|
||||||
|
### `SDeviceProfile` (236 Byte)
|
||||||
|
|
||||||
|
| Offset (relativ) | Größe | Feld |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | 60B (20× 3B `SAction`) | MX-Button-Actions, Index 0-19 |
|
||||||
|
| 60 | 36B (4× 3× 3B `SAction`) | Encoder-Actions: je Encoder `sw`, `cw`, `ccw` |
|
||||||
|
| 96 | 20B | LED `r` je Button |
|
||||||
|
| 116 | 20B | LED `g` je Button |
|
||||||
|
| 136 | 20B | LED `b` je Button |
|
||||||
|
| 156 | 20B | LED `brightness` je Button |
|
||||||
|
| 176 | 20B | LED `anim` (Enum-Index) je Button |
|
||||||
|
| 196 | 40B (20× uint16 LE) | LED `period_ms` je Button |
|
||||||
|
|
||||||
|
Die LED-Felder liegen **spaltenweise** (struct-of-arrays: alle 20
|
||||||
|
`r`-Werte, dann alle 20 `g`-Werte, …), nicht verschachtelt pro Button —
|
||||||
|
`pack_profile()`/`unpack_profile()` bauen das entsprechend um.
|
||||||
|
|
||||||
|
### `SMacroTable` (512 Byte, `versapad_protocol.MACRO_SIZE`)
|
||||||
|
|
||||||
|
32 Slots × 8 Schritte × 2 Byte (`keycode`, `modifier`) = 512 Byte, flach
|
||||||
|
hintereinander: `offset = (slot_idx * 8 + step_idx) * 2`.
|
||||||
|
|
||||||
|
### CRC16
|
||||||
|
|
||||||
|
`crc16()` in `versapad_protocol.py`: CRC-CCITT, Polynom `0x1021`, Init
|
||||||
|
`0xFFFF`, MSB-first, kein XOR-Out — exakt `nvm_config_crc()` aus der
|
||||||
|
Firmware (`nvm_config.cpp`). Wird über Byte 7-739 der Config berechnet
|
||||||
|
(alles nach Magic/Version/CRC-Header selbst).
|
||||||
136
docs/protocol.md
Normal file
136
docs/protocol.md
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
# Serial-Wire-Protokoll
|
||||||
|
|
||||||
|
Beschreibt, wie `versapad_serial.VersaPadLink` mit dem Board spricht. 1:1
|
||||||
|
aus `VersaGUI/src/Protocol.cs` und `VersaMCU/doc/07_serial_protocol.md`
|
||||||
|
übernommen (siehe „Existing-Codebase-Regel“ in
|
||||||
|
[`AGENTS.md`](../AGENTS.md) — bei Änderungen gegen die Firmware-/
|
||||||
|
C#-Quellen abgleichen, nicht aus dem Gedächtnis rekonstruieren). Für die
|
||||||
|
Bedeutung der übertragenen Bytes (Config/Makro-Layout) siehe
|
||||||
|
[`data-model.md`](data-model.md).
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
- USB-CDC (virtueller COM-Port), 115200 Baud.
|
||||||
|
- Board-Erkennung über USB VID:PID `239A:0042` (`versapad_serial.find_port()`
|
||||||
|
durchsucht `serial.tools.list_ports.comports()`).
|
||||||
|
- Der Port ist **exklusiv** — siehe „Nebenläufigkeit“ in
|
||||||
|
[`architecture.md`](architecture.md).
|
||||||
|
- Jedes Paket ist exakt **8 Byte**:
|
||||||
|
|
||||||
|
| Byte | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `[0]` | Command-/Event-ID |
|
||||||
|
| `[1]` | je nach Befehl: Chunk-Index, Chunk-Anzahl, oder ungenutzt |
|
||||||
|
| `[2..7]` | Payload, 6 Byte (`PAYLOAD_SIZE`) |
|
||||||
|
|
||||||
|
## Befehle (Host → Board)
|
||||||
|
|
||||||
|
| Konstante | Wert | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `CMD_READ_STATUS` | `0x06` | Leichtgewichtiger Status-Poll (aktives Profil) |
|
||||||
|
| `CMD_CONFIG_BEGIN` | `0x10` | Start eines Config-Schreibvorgangs |
|
||||||
|
| `CMD_CONFIG_DATA` | `0x11` | Ein Config-Datenpaket |
|
||||||
|
| `CMD_CONFIG_COMMIT` | `0x12` | Config committen (NVM-Schreiben nach Validierung) |
|
||||||
|
| `CMD_CONFIG_READ` | `0x13` | Komplette Config vom Board anfordern |
|
||||||
|
| `CMD_MACRO_BEGIN` | `0x20` | Start eines Makro-Schreibvorgangs |
|
||||||
|
| `CMD_MACRO_DATA` | `0x21` | Ein Makro-Datenpaket |
|
||||||
|
| `CMD_MACRO_COMMIT` | `0x22` | Makros committen |
|
||||||
|
| `CMD_MACRO_READ` | `0x23` | Komplette Makro-Tabelle vom Board anfordern |
|
||||||
|
|
||||||
|
## Events (Board → Host)
|
||||||
|
|
||||||
|
| Konstante | Wert | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `EVT_STATUS` | `0x86` | Antwort auf `CMD_READ_STATUS` |
|
||||||
|
| `EVT_CONFIG_ACK` | `0x90` | Config-Commit erfolgreich |
|
||||||
|
| `EVT_CONFIG_NACK` | `0x91` | Config-Commit abgelehnt (Validierung fehlgeschlagen) |
|
||||||
|
| `EVT_CONFIG_BEGIN` | `0x92` | Board beginnt, Config-Dump zu senden |
|
||||||
|
| `EVT_CONFIG_DATA` | `0x93` | Ein Config-Datenpaket (Antwort auf `CMD_CONFIG_READ`) |
|
||||||
|
| `EVT_CONFIG_END` | `0x94` | Config-Dump vollständig |
|
||||||
|
| `EVT_MACRO_ACK` | `0x95` | Makro-Commit erfolgreich |
|
||||||
|
| `EVT_MACRO_BEGIN` | `0x96` | Board beginnt, Makro-Dump zu senden |
|
||||||
|
| `EVT_MACRO_DATA` | `0x97` | Ein Makro-Datenpaket |
|
||||||
|
| `EVT_MACRO_END` | `0x98` | Makro-Dump vollständig |
|
||||||
|
| `EVT_MACRO_NACK` | `0x99` | Makro-Commit abgelehnt |
|
||||||
|
|
||||||
|
## Ablauf: Lesen (`CONFIG_READ` / `MACRO_READ`)
|
||||||
|
|
||||||
|
Genutzt von `read_full_config()` (740B, ⌈740/6⌉ = 124 Datenpakete) und
|
||||||
|
`read_macros()` (512B, ⌈512/6⌉ = 86 Datenpakete). Deadline 3s.
|
||||||
|
|
||||||
|
```
|
||||||
|
Host → [CMD_CONFIG_READ, 0, 0,0,0,0,0,0]
|
||||||
|
Board → [EVT_CONFIG_BEGIN, ...]
|
||||||
|
Board → [EVT_CONFIG_DATA, chunk_idx=0, <=6B Payload]
|
||||||
|
Board → [EVT_CONFIG_DATA, chunk_idx=1, <=6B Payload]
|
||||||
|
... (124 Pakete insgesamt für Config, 86 für Makros)
|
||||||
|
Board → [EVT_CONFIG_END, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
`chunk_idx` (Byte `[1]`) bestimmt den Ziel-Offset im Empfangspuffer:
|
||||||
|
`offset = chunk_idx * 6`. Kommt `EVT_END`, ohne dass zuvor `EVT_BEGIN`
|
||||||
|
gesehen wurde, gilt das als Timeout (unvollständige/verpasste Antwort).
|
||||||
|
|
||||||
|
## Ablauf: Schreiben (`CONFIG_BEGIN/DATA/COMMIT` bzw. `MACRO_*`)
|
||||||
|
|
||||||
|
Genutzt von `write_full_config()`/`write_macros()`. Deadline 5s. Anzahl
|
||||||
|
Chunks maximal 255 (ein Byte) — bei größeren Blobs schlägt der Aufruf mit
|
||||||
|
`"too_large"` fehl, bevor überhaupt gesendet wird.
|
||||||
|
|
||||||
|
```
|
||||||
|
Host → [CMD_CONFIG_BEGIN, chunk_count, 0,0,0,0,0,0]
|
||||||
|
Host → [CMD_CONFIG_DATA, 0, <=6B Payload (zero-padded)]
|
||||||
|
Host → [CMD_CONFIG_DATA, 1, <=6B Payload]
|
||||||
|
... (ein Paket je Chunk)
|
||||||
|
Host → [CMD_CONFIG_COMMIT, 0, 0,0,0,0,0,0]
|
||||||
|
Board → [EVT_CONFIG_ACK, ...] -- oder EVT_CONFIG_NACK bei fehlgeschlagener Validierung
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sicherheitsnetz:** Die Firmware prüft Magic/Version/CRC (Config) bzw.
|
||||||
|
Keycode-Bereich (Makros) **vor** jedem NVM-Schreiben und antwortet sonst
|
||||||
|
nur mit NACK — ein fehlerhafter Schreibversuch kann das Board laut
|
||||||
|
Firmware-Design nicht in einen inkonsistenten Zustand bringen, siehe
|
||||||
|
`nvm_config_validate()` in den Firmware-Quellen.
|
||||||
|
|
||||||
|
## Ablauf: Leichtgewichtiger Status-Poll (`READ_STATUS`)
|
||||||
|
|
||||||
|
Genutzt von `read_active_profile()` für kontinuierliches Live-Sync-Polling
|
||||||
|
(alle 1,5s). Deadline 1s.
|
||||||
|
|
||||||
|
```
|
||||||
|
Host → [CMD_READ_STATUS, 0, 0,0,0,0,0,0]
|
||||||
|
Board → [EVT_STATUS, active_profile (0-2), ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein einzelnes Antwortpaket statt eines vollen `CONFIG_READ`-Dumps (124
|
||||||
|
Pakete) — Letzterer blockiert die Firmware in `poll_vendor()` lang genug,
|
||||||
|
dass laufende LED-Pulse-Animationen sichtbar stottern. Ältere Firmware
|
||||||
|
ohne `CMD_READ_STATUS` antwortet einfach gar nicht → sauberer Timeout,
|
||||||
|
kein Absturz (siehe `AGENTS.md`, Eintrag „4215323“ in der Historie).
|
||||||
|
|
||||||
|
## Verbindungsaufbau (`VersaPadLink._ensure_open()`)
|
||||||
|
|
||||||
|
1. Board per VID/PID finden (`find_port()`).
|
||||||
|
2. `serial.Serial(port, 115200, timeout=0.5)` öffnen.
|
||||||
|
3. DTR auf `True` setzen, 0,2s warten (Board braucht kurz, bis es nach dem
|
||||||
|
Öffnen/DTR-Toggle wieder reagiert).
|
||||||
|
4. Input-Buffer leeren.
|
||||||
|
|
||||||
|
**Wichtig:** `VersaPadLink` schließt die Verbindung nicht automatisch nach
|
||||||
|
einem Befehl — nur bei `serial.SerialException` (Fehlerfall) oder
|
||||||
|
explizitem `.close()`-Aufruf. Aufrufer, die den exklusiven Port nicht
|
||||||
|
dauerhaft blockieren wollen, müssen selbst schließen (siehe
|
||||||
|
`versapad_mcp_server.py`, das nach jedem Board-Tool-Aufruf `_link.close()`
|
||||||
|
in einem `finally`-Block aufruft).
|
||||||
|
|
||||||
|
## Fehlerzustände (`VersaPadLink.last_error`)
|
||||||
|
|
||||||
|
| Wert | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `None` | kein Fehler |
|
||||||
|
| `"no_pyserial"` | Paket `pyserial` nicht installiert |
|
||||||
|
| `"not_found"` | kein Gerät mit passender VID/PID gefunden |
|
||||||
|
| `"busy"` | Port gefunden, aber Öffnen fehlgeschlagen (von woanders gehalten — VersaGUI, Live-Sync, ein anderer Prozess) |
|
||||||
|
| `"timeout"` | keine (gültige) Antwort innerhalb der Deadline |
|
||||||
|
| `"nack"` | Board hat den Commit explizit abgelehnt (Validierung fehlgeschlagen) |
|
||||||
|
| `"too_large"` | zu sendender Blob braucht mehr als 255 Chunks |
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pyserial
|
||||||
|
pystray
|
||||||
|
pillow
|
||||||
|
mcp
|
||||||
|
pyinstaller
|
||||||
50
server.py
50
server.py
|
|
@ -1,7 +1,10 @@
|
||||||
"""
|
"""
|
||||||
VersaPad Viewer -- Browser-Variante.
|
VersaPad Viewer -- Browser-Variante.
|
||||||
Liest bei jedem Request live die 3 Config-JSONs vom Desktop und rendert
|
Liest bei jedem Request die kombinierte Config-JSON vom Desktop und
|
||||||
die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML.
|
rendert die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. Fehlt
|
||||||
|
die JSON (z.B. geloescht), wird sie automatisch per Serial vom Board neu
|
||||||
|
aufgebaut und als neuer Cache gespeichert -- das Board ist die eigentliche
|
||||||
|
Quelle der Wahrheit, siehe versapad_combined.load_or_fetch().
|
||||||
Kein Build-Schritt, kein externes Framework -- nur stdlib.
|
Kein Build-Schritt, kein externes Framework -- nur stdlib.
|
||||||
|
|
||||||
Start: python server.py [--port 8765]
|
Start: python server.py [--port 8765]
|
||||||
|
|
@ -12,6 +15,7 @@ import webbrowser
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
import versapad_combined as vcomb
|
||||||
import versapad_data as vp
|
import versapad_data as vp
|
||||||
|
|
||||||
PAGE_CSS = """
|
PAGE_CSS = """
|
||||||
|
|
@ -34,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 140px);
|
grid-template-columns: repeat(4, 140px);
|
||||||
grid-template-rows: repeat(5, 76px);
|
grid-template-rows: repeat(5, 104px);
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 36px;
|
margin-bottom: 36px;
|
||||||
|
|
@ -49,15 +53,17 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
||||||
}
|
}
|
||||||
.cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; }
|
.cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; }
|
||||||
.cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; }
|
.cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; }
|
||||||
|
.cell .note { font-size: 11px; color: #8a8d98; margin-top: 4px; word-break: break-word; }
|
||||||
.cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; }
|
.cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; }
|
||||||
.cell.empty .label { color: #4a4d58; font-weight: 400; }
|
.cell.empty .label { color: #4a4d58; font-weight: 400; }
|
||||||
h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; }
|
h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; }
|
||||||
.encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; }
|
.encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; }
|
||||||
.enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; }
|
.enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; }
|
||||||
.enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; }
|
.enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; }
|
||||||
.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; }
|
.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0 0; }
|
||||||
.enc .row .k { color: #8a8d98; }
|
.enc .row .k { color: #8a8d98; }
|
||||||
.enc .row .v { font-weight: 500; text-align: right; }
|
.enc .row .v { font-weight: 500; text-align: right; }
|
||||||
|
.enc .note { font-size: 11px; color: #6a6d78; padding-bottom: 6px; word-break: break-word; }
|
||||||
footer { margin-top: 40px; color: #6a6d78; font-size: 12px; }
|
footer { margin-top: 40px; color: #6a6d78; font-size: 12px; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -69,28 +75,44 @@ def render_cell(btn):
|
||||||
grid_col, grid_row = btn["col"] + 1, btn["row"] + 1
|
grid_col, grid_row = btn["col"] + 1, btn["row"] + 1
|
||||||
style = f"grid-column:{grid_col}; grid-row:{grid_row};"
|
style = f"grid-column:{grid_col}; grid-row:{grid_row};"
|
||||||
label = html.escape(btn["label"]) if btn["label"] else "—"
|
label = html.escape(btn["label"]) if btn["label"] else "—"
|
||||||
|
note = html.escape(btn["note"]) if btn.get("note") else ""
|
||||||
return f"""<div class="{css_class}" style="{style}">
|
return f"""<div class="{css_class}" style="{style}">
|
||||||
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
|
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
|
||||||
<div class="idx">#{btn['index']}</div>
|
<div class="idx">#{btn['index']}</div>
|
||||||
<div class="label">{label}</div>
|
<div class="label">{label}</div>
|
||||||
|
<div class="note">{note}</div>
|
||||||
<div class="anim">{"" if empty else anim}</div>
|
<div class="anim">{"" if empty else anim}</div>
|
||||||
</div>"""
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _encoder_row(key, label, note):
|
||||||
|
label = html.escape(label) or "—"
|
||||||
|
note_html = f'<div class="note">{html.escape(note)}</div>' if note else ""
|
||||||
|
return f"""<div class="row"><span class="k">{key}</span><span class="v">{label}</span></div>{note_html}"""
|
||||||
|
|
||||||
|
|
||||||
def render_encoder(enc):
|
def render_encoder(enc):
|
||||||
|
rows = (
|
||||||
|
_encoder_row("Druck", enc["sw_label"], enc["sw_note"])
|
||||||
|
+ _encoder_row("CW", enc["cw_label"], enc["cw_note"])
|
||||||
|
+ _encoder_row("CCW", enc["ccw_label"], enc["ccw_note"])
|
||||||
|
)
|
||||||
return f"""<div class="enc">
|
return f"""<div class="enc">
|
||||||
<div class="idx">Encoder {enc['index']}</div>
|
<div class="idx">Encoder {enc['index']}</div>
|
||||||
<div class="row"><span class="k">Druck</span><span class="v">{html.escape(enc['sw_label']) or '—'}</span></div>
|
{rows}
|
||||||
<div class="row"><span class="k">CW</span><span class="v">{html.escape(enc['cw_label']) or '—'}</span></div>
|
|
||||||
<div class="row"><span class="k">CCW</span><span class="v">{html.escape(enc['ccw_label']) or '—'}</span></div>
|
|
||||||
</div>"""
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
def render_page(profile):
|
def render_page(profile):
|
||||||
cfg = vp.load_profile(profile)
|
combined = vcomb.load_or_fetch()
|
||||||
|
raw = combined["profiles"][profile]
|
||||||
|
cfg = vp.annotate_profile({
|
||||||
|
"buttons": [dict(b) for b in raw["buttons"]],
|
||||||
|
"encoders": [dict(e) for e in raw["encoders"]],
|
||||||
|
}, combined.get("macros"))
|
||||||
tabs = "".join(
|
tabs = "".join(
|
||||||
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>'
|
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(combined["profile_names"][p])}</a>'
|
||||||
for p in sorted(vp.PROFILE_NAMES)
|
for p in range(vp.NUM_PROFILES)
|
||||||
)
|
)
|
||||||
cells = "".join(render_cell(b) for b in cfg["buttons"])
|
cells = "".join(render_cell(b) for b in cfg["buttons"])
|
||||||
encoders = "".join(render_encoder(e) for e in cfg["encoders"])
|
encoders = "".join(render_encoder(e) for e in cfg["encoders"])
|
||||||
|
|
@ -107,7 +129,7 @@ def render_page(profile):
|
||||||
<div class="grid">{cells}</div>
|
<div class="grid">{cells}</div>
|
||||||
<h2>Encoder</h2>
|
<h2>Encoder</h2>
|
||||||
<div class="encoders">{encoders}</div>
|
<div class="encoders">{encoders}</div>
|
||||||
<footer>Quelle: {html.escape(vp.CONFIG_PATHS[profile])}</footer>
|
<footer>Quelle: {html.escape(vcomb.DEFAULT_PATH)}</footer>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,7 +143,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
profile = int(query.get("profile", ["0"])[0])
|
profile = int(query.get("profile", ["0"])[0])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
profile = 0
|
profile = 0
|
||||||
if profile not in vp.PROFILE_NAMES:
|
if not (0 <= profile < vp.NUM_PROFILES):
|
||||||
profile = 0
|
profile = 0
|
||||||
try:
|
try:
|
||||||
body = render_page(profile).encode("utf-8")
|
body = render_page(profile).encode("utf-8")
|
||||||
|
|
@ -130,10 +152,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
except FileNotFoundError as e:
|
except (FileNotFoundError, RuntimeError) as e:
|
||||||
self.send_response(500)
|
self.send_response(500)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(f"Config-Datei fehlt: {e}".encode("utf-8"))
|
self.wfile.write(f"Config nicht verfuegbar: {e}".encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,18 @@ import os
|
||||||
|
|
||||||
import versapad_data as vp
|
import versapad_data as vp
|
||||||
import versapad_protocol as proto
|
import versapad_protocol as proto
|
||||||
|
import versapad_serial as vs
|
||||||
|
|
||||||
DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json")
|
DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json")
|
||||||
|
|
||||||
DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
|
DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
|
||||||
|
|
||||||
|
|
||||||
def _empty_profile():
|
def _empty_profile():
|
||||||
buttons = [{"index": i, "action": {"type": "None", "data": 0},
|
buttons = [{"index": i, "action": {"type": "None", "data": 0, "note": ""},
|
||||||
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
|
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
|
||||||
"anim": "Static", "period_ms": 4000}} for i in range(20)]
|
"anim": "Static", "period_ms": 4000}} for i in range(20)]
|
||||||
none = {"type": "None", "data": 0}
|
none = {"type": "None", "data": 0, "note": ""}
|
||||||
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
|
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
|
||||||
return {"buttons": buttons, "encoders": encoders}
|
return {"buttons": buttons, "encoders": encoders}
|
||||||
|
|
||||||
|
|
@ -60,17 +61,54 @@ def default_combined():
|
||||||
|
|
||||||
def from_binary(config_dict, macro_slots, profile_names=None):
|
def from_binary(config_dict, macro_slots, profile_names=None):
|
||||||
"""config_dict: Ergebnis von versapad_protocol.unpack_config().
|
"""config_dict: Ergebnis von versapad_protocol.unpack_config().
|
||||||
macro_slots: Ergebnis von versapad_protocol.unpack_macros()."""
|
macro_slots: Ergebnis von versapad_protocol.unpack_macros(). Actions
|
||||||
|
kommen frisch vom Board ohne "note" (die Firmware kennt keine Notizen,
|
||||||
|
siehe merge_notes()) -- hier nur mit leerem Default versehen, damit das
|
||||||
|
Feld ueberall verlaesslich existiert."""
|
||||||
|
profiles = config_dict["profiles"]
|
||||||
|
for profile in profiles:
|
||||||
|
for b in profile["buttons"]:
|
||||||
|
b["action"].setdefault("note", "")
|
||||||
|
for e in profile["encoders"]:
|
||||||
|
for field in ("sw", "cw", "ccw"):
|
||||||
|
e[field].setdefault("note", "")
|
||||||
return {
|
return {
|
||||||
"active_profile": config_dict["active_profile"],
|
"active_profile": config_dict["active_profile"],
|
||||||
"global_brightness": config_dict["global_brightness"],
|
"global_brightness": config_dict["global_brightness"],
|
||||||
"enc_sensitivity": config_dict["enc_sensitivity"],
|
"enc_sensitivity": config_dict["enc_sensitivity"],
|
||||||
"profile_names": profile_names or list(DEFAULT_NAMES),
|
"profile_names": profile_names or list(DEFAULT_NAMES),
|
||||||
"profiles": config_dict["profiles"],
|
"profiles": profiles,
|
||||||
"macros": macro_slots,
|
"macros": macro_slots,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_notes(combined, previous):
|
||||||
|
"""Kopiert Notizen (Button/Encoder-Aktion) aus einem vorherigen State in
|
||||||
|
einen frisch vom Board gelesenen State -- wie profile_names sind Notizen
|
||||||
|
rein lokal und wuerden bei jedem load_from_board()/fetch_from_board()
|
||||||
|
sonst verloren gehen, weil die Firmware sie nicht kennt. previous=None
|
||||||
|
(z.B. allererstes Laden) -> nichts zu tun, combined unveraendert
|
||||||
|
zurueckgegeben. Aendert combined in-place und gibt es zurueck."""
|
||||||
|
if not previous:
|
||||||
|
return combined
|
||||||
|
for p_idx, profile in enumerate(combined["profiles"]):
|
||||||
|
if p_idx >= len(previous["profiles"]):
|
||||||
|
continue
|
||||||
|
prev_profile = previous["profiles"][p_idx]
|
||||||
|
prev_buttons = {b["index"]: b for b in prev_profile.get("buttons", [])}
|
||||||
|
for b in profile["buttons"]:
|
||||||
|
prev = prev_buttons.get(b["index"])
|
||||||
|
if prev:
|
||||||
|
b["action"]["note"] = prev["action"].get("note", "")
|
||||||
|
prev_encoders = {e["index"]: e for e in prev_profile.get("encoders", [])}
|
||||||
|
for e in profile["encoders"]:
|
||||||
|
prev = prev_encoders.get(e["index"])
|
||||||
|
if prev:
|
||||||
|
for field in ("sw", "cw", "ccw"):
|
||||||
|
e[field]["note"] = prev[field].get("note", "")
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
def to_binary(combined):
|
def to_binary(combined):
|
||||||
"""-> (config_bytes[740], macro_bytes[512])"""
|
"""-> (config_bytes[740], macro_bytes[512])"""
|
||||||
config_bytes = proto.pack_config({
|
config_bytes = proto.pack_config({
|
||||||
|
|
@ -84,6 +122,7 @@ def to_binary(combined):
|
||||||
|
|
||||||
|
|
||||||
def save_file(combined, path=DEFAULT_PATH):
|
def save_file(combined, path=DEFAULT_PATH):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(combined, f, indent=2, ensure_ascii=False)
|
json.dump(combined, f, indent=2, ensure_ascii=False)
|
||||||
return path
|
return path
|
||||||
|
|
@ -92,3 +131,70 @@ def save_file(combined, path=DEFAULT_PATH):
|
||||||
def load_file(path=DEFAULT_PATH):
|
def load_file(path=DEFAULT_PATH):
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_from_board(link=None, profile_names=None):
|
||||||
|
"""Liest Config+Makros direkt vom Board per Serial (~1-2s), das Board
|
||||||
|
ist die eigentliche Quelle der Wahrheit (write_to_board() speichert
|
||||||
|
dauerhaft im NVM -- die JSON-Dateien hier sind nur ein Lesecache).
|
||||||
|
link: bestehender VersaPadLink wiederverwenden (z.B. desktop_viewer's
|
||||||
|
self._link, damit nicht zwei Verbindungen um denselben COM-Port
|
||||||
|
konkurrieren) -- sonst wird eine eigene geoeffnet und wieder
|
||||||
|
geschlossen. Wirft RuntimeError mit Klartext-Ursache (last_error),
|
||||||
|
z.B. wenn der Port gerade von VersaGUI/einem anderen Viewer belegt ist."""
|
||||||
|
owns_link = link is None
|
||||||
|
if owns_link:
|
||||||
|
link = vs.VersaPadLink()
|
||||||
|
try:
|
||||||
|
raw_cfg = link.read_full_config()
|
||||||
|
if raw_cfg is None:
|
||||||
|
raise RuntimeError(f"Config vom Board laden fehlgeschlagen: {link.last_error}")
|
||||||
|
raw_macros = link.read_macros()
|
||||||
|
if raw_macros is None:
|
||||||
|
raise RuntimeError(f"Makros vom Board laden fehlgeschlagen: {link.last_error}")
|
||||||
|
cfg_dict = proto.unpack_config(raw_cfg)
|
||||||
|
if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]):
|
||||||
|
raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)")
|
||||||
|
macro_slots = proto.unpack_macros(raw_macros)
|
||||||
|
return from_binary(cfg_dict, macro_slots, profile_names=profile_names)
|
||||||
|
finally:
|
||||||
|
if owns_link:
|
||||||
|
link.close()
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None):
|
||||||
|
"""Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich
|
||||||
|
geloescht, oder frische Installation ohne jede Config), wird sie
|
||||||
|
automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das
|
||||||
|
behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache
|
||||||
|
dafuer), und falls auch das Board nicht erreichbar ist (nicht
|
||||||
|
verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite)
|
||||||
|
als leere Default-Config (vgl. default_combined()) -- damit ist das
|
||||||
|
Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit
|
||||||
|
einem Fehler zu blockieren."""
|
||||||
|
if os.path.exists(path):
|
||||||
|
return load_file(path)
|
||||||
|
try:
|
||||||
|
combined = fetch_from_board(link=link, profile_names=profile_names)
|
||||||
|
except RuntimeError:
|
||||||
|
combined = default_combined()
|
||||||
|
if profile_names:
|
||||||
|
combined["profile_names"] = profile_names
|
||||||
|
try:
|
||||||
|
save_file(combined, path)
|
||||||
|
except OSError:
|
||||||
|
pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def read_profile_names(path=DEFAULT_PATH):
|
||||||
|
"""Nur die (lokalen) Profilnamen lesen, ohne die volle Config zu
|
||||||
|
brauchen -- fuer Tab-Beschriftungen im Nur-Lese-Modus. Greift bewusst
|
||||||
|
nicht aufs Board zu (kein COM-Port-Konflikt mit Live-Sync), faellt bei
|
||||||
|
fehlender/kaputter Datei auf DEFAULT_NAMES zurueck."""
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
return load_file(path).get("profile_names", list(DEFAULT_NAMES))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return list(DEFAULT_NAMES)
|
||||||
|
|
|
||||||
327
versapad_data.py
327
versapad_data.py
|
|
@ -8,18 +8,54 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import versapad_keylayout as kl
|
||||||
|
|
||||||
|
NUM_PROFILES = 3
|
||||||
|
|
||||||
|
# Groesse der globalen Makrotabelle (SMacroTable, siehe versapad_protocol.
|
||||||
|
# MACRO_SLOTS) -- hier nochmal, damit dieses Modul importfrei bleibt.
|
||||||
|
MACRO_SLOTS = 32
|
||||||
|
|
||||||
|
APP_NAME = "VersaPadViewer"
|
||||||
|
|
||||||
|
|
||||||
|
def app_dir():
|
||||||
|
"""Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json,
|
||||||
|
siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung
|
||||||
|
abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und
|
||||||
|
unter jedem Benutzer.
|
||||||
|
|
||||||
|
Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die
|
||||||
|
gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen
|
||||||
|
dieselbe Datei sehen, sonst laufen zwei Configs auseinander und
|
||||||
|
Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist
|
||||||
|
am 2026-08-15 passiert -- .exe zeigte ein anderes Profil 0 als der
|
||||||
|
MCP-Server).
|
||||||
|
|
||||||
|
Bewusst auch NICHT das Installationsverzeichnis (%LOCALAPPDATA%\\
|
||||||
|
VersaPadViewer, wo die .exe liegt): `build_and_deploy.ps1` raeumt das
|
||||||
|
Zielverzeichnis vor jedem Deploy komplett ab (`Remove-Item -Recurse`) --
|
||||||
|
laege die Config dort, wuerde JEDER Rebuild die Nutzerdaten mitloeschen
|
||||||
|
(am 2026-08-15 genau so passiert, alle Notizen weg). Programm- und
|
||||||
|
Datenverzeichnis bleiben deshalb getrennt: Roaming-AppData fuer die
|
||||||
|
Config."""
|
||||||
|
base = (os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA")
|
||||||
|
or os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||||
|
return os.path.join(base, APP_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind
|
||||||
|
# keine von diesem Tool geschriebenen Dateien, sondern ein Export der
|
||||||
|
# offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese-
|
||||||
|
# Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche,
|
||||||
|
# von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH
|
||||||
|
# (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop).
|
||||||
CONFIG_PATHS = {
|
CONFIG_PATHS = {
|
||||||
0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"),
|
0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"),
|
||||||
1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"),
|
1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"),
|
||||||
2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"),
|
2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"),
|
||||||
}
|
}
|
||||||
|
|
||||||
PROFILE_NAMES = {
|
|
||||||
0: "Profil 0 – Windows",
|
|
||||||
1: "Profil 1 – Fusion 360",
|
|
||||||
2: "Profil 2 – BricsCAD",
|
|
||||||
}
|
|
||||||
|
|
||||||
# index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge)
|
# index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge)
|
||||||
GRID_COLS = 4
|
GRID_COLS = 4
|
||||||
GRID_ROWS = 5
|
GRID_ROWS = 5
|
||||||
|
|
@ -78,6 +114,164 @@ _CONSUMER_NAMES = {
|
||||||
0x00B0: "Aufnahme",
|
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 = {
|
ANIM_LABELS = {
|
||||||
"Static": "● statisch",
|
"Static": "● statisch",
|
||||||
"Blink": "◎ blinkend",
|
"Blink": "◎ blinkend",
|
||||||
|
|
@ -89,9 +283,55 @@ ANIM_LABELS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_display_name_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
def _display_key_names():
|
||||||
|
"""{hid_code: Anzeigename} -- Zeichentasten mit dem Namen des aktiven
|
||||||
|
Windows-Layouts, alles andere mit der gepflegten deutschen Bezeichnung
|
||||||
|
aus _SPECIAL_KEYS ("Enter", "Bild↑", "Num5"); die liest sich besser als
|
||||||
|
das, was Windows liefert ("EINGABE", "4 (ZEHNERTASTATUR)").
|
||||||
|
|
||||||
|
Einmal ermittelt und behalten -- ein Layoutwechsel zur Laufzeit wird
|
||||||
|
bewusst nicht nachgezogen (siehe versapad_keylayout.key_name()).
|
||||||
|
|
||||||
|
Die Namen sind gleichzeitig Schluessel im Dropdown und in
|
||||||
|
hid_key_code_for_name(), muessen also eindeutig bleiben. Kollisionen
|
||||||
|
entstehen real: auf deutschem Layout heisst HID 0x31 (US-Backslash-
|
||||||
|
Position) schlicht "#" -- und diesen Namen trug bisher HID 0x32
|
||||||
|
(Non-US-#). Der Layoutname gewinnt, der verdraengte US-Name wird
|
||||||
|
gekennzeichnet statt verworfen, damit die Taste ansprechbar bleibt."""
|
||||||
|
global _display_name_cache
|
||||||
|
if _display_name_cache is None:
|
||||||
|
names = dict(_SPECIAL_KEYS)
|
||||||
|
layout = {}
|
||||||
|
for code in sorted(_SPECIAL_KEYS):
|
||||||
|
if code not in kl.CHARACTER_HID_CODES:
|
||||||
|
continue
|
||||||
|
name = kl.key_name(code)
|
||||||
|
if name:
|
||||||
|
layout[code] = name
|
||||||
|
names.update(layout)
|
||||||
|
claimed = set(layout.values())
|
||||||
|
for code, name in list(names.items()):
|
||||||
|
if code not in layout and name in claimed:
|
||||||
|
names[code] = f"{name} (US-Layout)"
|
||||||
|
elif code not in layout:
|
||||||
|
claimed.add(name)
|
||||||
|
_display_name_cache = names
|
||||||
|
return _display_name_cache
|
||||||
|
|
||||||
|
|
||||||
|
def hid_key_name(keycode):
|
||||||
|
"""Anzeigename einer Taste, layoutrichtig wo es darauf ankommt
|
||||||
|
(deutsch: 0x1C -> "Z", 0x34 -> "ä"). Ohne verfuegbare Layout-Abfrage
|
||||||
|
(Nicht-Windows) bleibt es bei der US-Naeherung aus _SPECIAL_KEYS."""
|
||||||
|
return _display_key_names().get(keycode, f"0x{keycode:02X}")
|
||||||
|
|
||||||
|
|
||||||
def hid_key_choices():
|
def hid_key_choices():
|
||||||
"""Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren."""
|
"""Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren."""
|
||||||
return sorted(_SPECIAL_KEYS.items())
|
return [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)]
|
||||||
|
|
||||||
|
|
||||||
def consumer_choices():
|
def consumer_choices():
|
||||||
|
|
@ -99,15 +339,28 @@ def consumer_choices():
|
||||||
return sorted(_CONSUMER_NAMES.items())
|
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()}
|
_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):
|
def hid_key_code_for_name(name):
|
||||||
"""z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen."""
|
"""z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen."""
|
||||||
if name not in _KEY_CODE_BY_NAME:
|
by_name = _key_code_by_name()
|
||||||
raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(_KEY_CODE_BY_NAME)}")
|
if name not in by_name:
|
||||||
return _KEY_CODE_BY_NAME[name]
|
raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(by_name)}")
|
||||||
|
return by_name[name]
|
||||||
|
|
||||||
|
|
||||||
def consumer_id_for_name(name):
|
def consumer_id_for_name(name):
|
||||||
|
|
@ -132,7 +385,7 @@ def macro_step_label(step):
|
||||||
"""step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante
|
"""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)."""
|
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]
|
mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit]
|
||||||
key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}")
|
key = hid_key_name(step["keycode"])
|
||||||
return "+".join(mods + [key]) if mods else key
|
return "+".join(mods + [key]) if mods else key
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -142,12 +395,25 @@ def macro_slot_label(steps):
|
||||||
return " → ".join(macro_step_label(s) for s in steps)
|
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):
|
def hid_key_label(data):
|
||||||
"""data = keycode | (modifier << 8) -> z.B. 'Strg+S'"""
|
"""data = keycode | (modifier << 8) -> z.B. 'Strg+S'"""
|
||||||
keycode = data & 0xFF
|
keycode = data & 0xFF
|
||||||
modifier = (data >> 8) & 0xFF
|
modifier = (data >> 8) & 0xFF
|
||||||
mods = [name for bit, name in MODIFIER_BITS if modifier & bit]
|
mods = [name for bit, name in MODIFIER_BITS if modifier & bit]
|
||||||
key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}")
|
key = hid_key_name(keycode)
|
||||||
return "+".join(mods + [key]) if mods else key
|
return "+".join(mods + [key]) if mods else key
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -155,8 +421,15 @@ def consumer_label(data):
|
||||||
return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}")
|
return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}")
|
||||||
|
|
||||||
|
|
||||||
def action_label(action):
|
def action_label(action, macros=None):
|
||||||
"""Menschenlesbarer Text für eine DeviceAction {type, data}."""
|
"""Menschenlesbarer Text für eine DeviceAction {type, data}.
|
||||||
|
|
||||||
|
macros: optional die globale 32-Slot-Makrotabelle (siehe
|
||||||
|
versapad_combined). Ist sie da, zeigt ein Makro die tatsaechliche
|
||||||
|
Tastenfolge statt nur der Slot-Nummer -- ohne die sagt "Makro (Slot 7)"
|
||||||
|
im Hauptfenster nichts darueber aus, was die Taste eigentlich tut.
|
||||||
|
Ohne macros (z.B. bei den Einzel-JSONs aus CONFIG_PATHS, die gar keine
|
||||||
|
Makro-Schritte enthalten) bleibt es beim Slot-Text."""
|
||||||
t = action.get("type")
|
t = action.get("type")
|
||||||
d = action.get("data", 0)
|
d = action.get("data", 0)
|
||||||
if t == "None":
|
if t == "None":
|
||||||
|
|
@ -166,6 +439,8 @@ def action_label(action):
|
||||||
if t == "HidConsumer":
|
if t == "HidConsumer":
|
||||||
return consumer_label(d)
|
return consumer_label(d)
|
||||||
if t == "Macro":
|
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})"
|
return f"Makro (Slot {d})"
|
||||||
if t == "ProfileSwitch":
|
if t == "ProfileSwitch":
|
||||||
if d in (0xFFFF, 0x00FF):
|
if d in (0xFFFF, 0x00FF):
|
||||||
|
|
@ -189,16 +464,24 @@ def button_grid_position(index):
|
||||||
return col, row
|
return col, row
|
||||||
|
|
||||||
|
|
||||||
def annotate_profile(cfg):
|
def annotate_profile(cfg, macros=None):
|
||||||
"""Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus
|
"""Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob
|
||||||
einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt."""
|
cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck)
|
||||||
|
oder aus dem kombinierten Programmiermodus-State kommt.
|
||||||
|
|
||||||
|
macros wird an action_label() durchgereicht, damit Makro-Belegungen
|
||||||
|
ihre echte Tastenfolge zeigen statt nur der Slot-Nummer."""
|
||||||
for b in cfg["buttons"]:
|
for b in cfg["buttons"]:
|
||||||
b["label"] = action_label(b["action"])
|
b["label"] = action_label(b["action"], macros)
|
||||||
|
b["note"] = b["action"].get("note", "")
|
||||||
b["col"], b["row"] = button_grid_position(b["index"])
|
b["col"], b["row"] = button_grid_position(b["index"])
|
||||||
for e in cfg["encoders"]:
|
for e in cfg["encoders"]:
|
||||||
e["sw_label"] = action_label(e["sw"])
|
e["sw_label"] = action_label(e["sw"], macros)
|
||||||
e["cw_label"] = action_label(e["cw"])
|
e["sw_note"] = e["sw"].get("note", "")
|
||||||
e["ccw_label"] = action_label(e["ccw"])
|
e["cw_label"] = action_label(e["cw"], macros)
|
||||||
|
e["cw_note"] = e["cw"].get("note", "")
|
||||||
|
e["ccw_label"] = action_label(e["ccw"], macros)
|
||||||
|
e["ccw_note"] = e["ccw"].get("note", "")
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
152
versapad_keylayout.py
Normal file
152
versapad_keylayout.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""
|
||||||
|
Abfragen ans AKTIVE Windows-Tastaturlayout: physische Tastenposition
|
||||||
|
(Scan-Code) und der Name, den diese Taste auf dem aktuellen Layout traegt.
|
||||||
|
|
||||||
|
Warum ueberhaupt WinAPI, wo dieses Projekt sonst konsequent darauf
|
||||||
|
verzichtet: HID-Keycodes bezeichnen **physische Tastenpositionen** (das
|
||||||
|
Board sendet eine Position, erst Windows macht daraus ein Zeichen). Aus
|
||||||
|
einem Tk-Event kommen aber nur `keysym` und Virtual-Key-Code -- beides ist
|
||||||
|
bereits durch das Layout gefiltert. Auf deutschem Layout landete deshalb
|
||||||
|
jedes Y auf der Z-Taste des Boards und umgekehrt, und AeOeUe/#/+ waren gar
|
||||||
|
nicht erfassbar. Die Position ist ohne `MapVirtualKeyW` schlicht nicht zu
|
||||||
|
bekommen.
|
||||||
|
|
||||||
|
Abgrenzung zu den Domaenenregeln in AGENTS.md: verboten sind dort
|
||||||
|
**Fenstermanipulation** (`SetWindowLongW`/`ShowWindow` aufs eigene Fenster)
|
||||||
|
und **globale Tastaturhooks** (`SetWindowsHookEx`) -- genau die Aufrufe, die
|
||||||
|
in einem anderen Projekt AV-Fehlalarme ausgeloest haben. Hier passiert
|
||||||
|
nichts davon: `MapVirtualKeyW` und `GetKeyNameTextW` sind passive,
|
||||||
|
lesende Layout-Abfragen ohne Fenster-, Prozess- oder Eingabezugriff. Die
|
||||||
|
offizielle VersaGUI (C#) benutzt `GetKeyNameText()` fuer denselben Zweck.
|
||||||
|
|
||||||
|
Alles hier ist optional: auf Nicht-Windows oder wenn `user32` nicht laedt,
|
||||||
|
bleibt AVAILABLE False und alle Funktionen liefern None -- `versapad_data`
|
||||||
|
faellt dann auf seine US-Layout-Naeherung zurueck.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Scan-Code (PS/2 Set 1, wie MapVirtualKeyW ihn liefert) -> HID Usage Page
|
||||||
|
# 0x07. Diese Tabelle ist layoutunabhaengig und damit der eigentliche Kern:
|
||||||
|
# eine physische Taste hat immer denselben Scan-Code und denselben HID-Code,
|
||||||
|
# egal welches Zeichen das Layout daraufschreibt.
|
||||||
|
SCANCODE_TO_HID = {
|
||||||
|
0x01: 0x29, # Esc
|
||||||
|
0x02: 0x1E, 0x03: 0x1F, 0x04: 0x20, 0x05: 0x21, 0x06: 0x22,
|
||||||
|
0x07: 0x23, 0x08: 0x24, 0x09: 0x25, 0x0A: 0x26, 0x0B: 0x27, # 1-9, 0
|
||||||
|
0x0C: 0x2D, 0x0D: 0x2E, # US -/=, deutsch ss/Akut
|
||||||
|
0x0E: 0x2A, 0x0F: 0x2B, # Backspace, Tab
|
||||||
|
0x10: 0x14, 0x11: 0x1A, 0x12: 0x08, 0x13: 0x15, 0x14: 0x17, # Q W E R T
|
||||||
|
0x15: 0x1C, 0x16: 0x18, 0x17: 0x0C, 0x18: 0x12, 0x19: 0x13, # US Y U I O P
|
||||||
|
0x1A: 0x2F, 0x1B: 0x30, # US [/], deutsch Ue/+
|
||||||
|
0x1C: 0x28, # Enter
|
||||||
|
0x1E: 0x04, 0x1F: 0x16, 0x20: 0x07, 0x21: 0x09, 0x22: 0x0A, # A S D F G
|
||||||
|
0x23: 0x0B, 0x24: 0x0D, 0x25: 0x0E, 0x26: 0x0F, # H J K L
|
||||||
|
0x27: 0x33, 0x28: 0x34, # US ;/', deutsch Oe/Ae
|
||||||
|
0x29: 0x35, # US Backtick, deutsch Zirkumflex
|
||||||
|
0x2B: 0x31, # US Backslash, deutsch #
|
||||||
|
0x2C: 0x1D, 0x2D: 0x1B, 0x2E: 0x06, 0x2F: 0x19, 0x30: 0x05, # US Z X C V B
|
||||||
|
0x31: 0x11, 0x32: 0x10, # N M
|
||||||
|
0x33: 0x36, 0x34: 0x37, # Komma, Punkt (in beiden Layouts gleich)
|
||||||
|
0x35: 0x38, # US /, deutsch -
|
||||||
|
0x37: 0x55, # Num *
|
||||||
|
0x39: 0x2C, # Leertaste
|
||||||
|
0x3A: 0x39, # Caps
|
||||||
|
0x45: 0x53, 0x46: 0x47, # NumLock, Rollen
|
||||||
|
0x47: 0x5F, 0x48: 0x60, 0x49: 0x61, 0x4A: 0x56, # Num 7 8 9 -
|
||||||
|
0x4B: 0x5C, 0x4C: 0x5D, 0x4D: 0x5E, 0x4E: 0x57, # Num 4 5 6 +
|
||||||
|
0x4F: 0x59, 0x50: 0x5A, 0x51: 0x5B, # Num 1 2 3
|
||||||
|
0x52: 0x62, 0x53: 0x63, # Num 0 .
|
||||||
|
0x56: 0x64, # ISO-Zusatztaste (deutsch <>|)
|
||||||
|
0x57: 0x44, 0x58: 0x45, # F11, F12
|
||||||
|
}
|
||||||
|
for _i in range(10):
|
||||||
|
SCANCODE_TO_HID[0x3B + _i] = 0x3A + _i # F1-F10
|
||||||
|
|
||||||
|
HID_TO_SCANCODE = {hid: scan for scan, hid in SCANCODE_TO_HID.items()}
|
||||||
|
|
||||||
|
# Nur fuer diese HID-Codes lohnt der Layout-Name: es sind die Tasten, deren
|
||||||
|
# Beschriftung sich zwischen Layouts unterscheidet (Buchstaben, Ziffern,
|
||||||
|
# Satzzeichen). Fuer Enter/F5/Entf/Numpad bleiben die gepflegten deutschen
|
||||||
|
# Namen aus versapad_data._SPECIAL_KEYS besser als das, was Windows liefert
|
||||||
|
# ("EINGABE", "4 (ZEHNERTASTATUR)").
|
||||||
|
CHARACTER_HID_CODES = (
|
||||||
|
frozenset(range(0x04, 0x1E)) # A-Z (US-Positionen)
|
||||||
|
| frozenset(range(0x1E, 0x28)) # 1-9, 0
|
||||||
|
| frozenset(range(0x2D, 0x39)) # Satzzeichen
|
||||||
|
| frozenset({0x64}) # ISO-Zusatztaste
|
||||||
|
)
|
||||||
|
|
||||||
|
MAPVK_VK_TO_VSC_EX = 4
|
||||||
|
|
||||||
|
AVAILABLE = False
|
||||||
|
_user32 = None
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
_user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||||
|
_user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT]
|
||||||
|
_user32.MapVirtualKeyW.restype = wintypes.UINT
|
||||||
|
_user32.GetKeyNameTextW.argtypes = [wintypes.LONG, wintypes.LPWSTR, ctypes.c_int]
|
||||||
|
_user32.GetKeyNameTextW.restype = ctypes.c_int
|
||||||
|
AVAILABLE = True
|
||||||
|
except (ImportError, OSError, AttributeError):
|
||||||
|
_user32 = None # z.B. exotische Python-Builds ohne ctypes
|
||||||
|
|
||||||
|
|
||||||
|
def scancode_for_vk(vk):
|
||||||
|
"""Virtual-Key-Code -> Scan-Code der physischen Taste, oder None.
|
||||||
|
|
||||||
|
MAPVK_VK_TO_VSC_EX setzt fuer manche Tasten ein 0xE0-Praefix ins High-Byte
|
||||||
|
(erweiterte Tasten), fuer die Pfeiltasten hier aber gemessen NICHT -- die
|
||||||
|
liefern denselben Scan-Code wie ihre Numpad-Zwillinge. Genau deshalb
|
||||||
|
laeuft die Aufloesung in versapad_data zuerst ueber die eindeutigen
|
||||||
|
Tk-keysyms und benutzt diesen Weg nur fuer Zeichentasten."""
|
||||||
|
if not AVAILABLE:
|
||||||
|
return None
|
||||||
|
scan = _user32.MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX)
|
||||||
|
return scan or None
|
||||||
|
|
||||||
|
|
||||||
|
def hid_for_vk(vk):
|
||||||
|
"""Virtual-Key-Code -> HID-Keycode ueber die physische Tastenposition.
|
||||||
|
Das ist der eigentliche Fix gegen vertauschtes Y/Z und nicht erfassbares
|
||||||
|
AeOeUe/#/+: der Umweg ueber das Zeichen entfaellt komplett."""
|
||||||
|
scan = scancode_for_vk(vk)
|
||||||
|
if scan is None:
|
||||||
|
return None
|
||||||
|
if (scan >> 8) == 0xE0:
|
||||||
|
return None # erweiterte Taste -- kommt hier nicht vor, siehe oben
|
||||||
|
return SCANCODE_TO_HID.get(scan & 0xFF)
|
||||||
|
|
||||||
|
|
||||||
|
def key_name(hid_code):
|
||||||
|
"""HID-Keycode -> Beschriftung dieser Taste auf dem aktiven Layout
|
||||||
|
(deutsch: HID 0x1C -> "Z", 0x34 -> "ä"), oder None wenn nicht ermittelbar.
|
||||||
|
|
||||||
|
Achtung: das Ergebnis gilt fuer das Layout, das beim Aufruf aktiv ist.
|
||||||
|
Ein Layoutwechsel zur Laufzeit wird nicht bemerkt -- fuer ein Tool, das
|
||||||
|
eine Tastatur konfiguriert, ist das vertretbar (der Cache in
|
||||||
|
versapad_data haelt entsprechend auch nur eine Fassung)."""
|
||||||
|
if not AVAILABLE:
|
||||||
|
return None
|
||||||
|
scan = HID_TO_SCANCODE.get(hid_code)
|
||||||
|
if scan is None:
|
||||||
|
return None
|
||||||
|
buffer = ctypes.create_unicode_buffer(64)
|
||||||
|
# lParam-Layout von GetKeyNameTextW: Bits 16-23 Scan-Code,
|
||||||
|
# Bit 24 "erweiterte Taste".
|
||||||
|
written = _user32.GetKeyNameTextW((scan & 0xFF) << 16, buffer, 64)
|
||||||
|
if not written:
|
||||||
|
return None
|
||||||
|
name = buffer.value.strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
# Windows liefert Mehrzeichen-Namen in Grossbuchstaben ("AKUT",
|
||||||
|
# "ZIRKUMFLEX") -- als Dropdown-Eintrag zwischen "A" und "ä" liest sich
|
||||||
|
# Titelschreibung deutlich ruhiger.
|
||||||
|
if len(name) > 1 and name == name.upper():
|
||||||
|
return name.capitalize()
|
||||||
|
return name
|
||||||
|
|
@ -61,7 +61,21 @@ def _profile_switch_data(target):
|
||||||
|
|
||||||
|
|
||||||
def _describe_action(action):
|
def _describe_action(action):
|
||||||
return {"type": action["type"], "data": action["data"], "label": vp.action_label(action)}
|
"""label zeigt bei Makros die echte Tastenfolge statt nur der
|
||||||
|
Slot-Nummer (gleiche Darstellung wie im Hauptfenster) -- die Makrotabelle
|
||||||
|
liegt im selben State, also kein Grund, hier weniger zu verraten."""
|
||||||
|
return {"type": action["type"], "data": action["data"],
|
||||||
|
"label": vp.action_label(action, _cfg().get("macros")),
|
||||||
|
"note": action.get("note", "")}
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_action(old_action, new_type, new_data):
|
||||||
|
"""Baut eine neue Action mit neuem Typ/Daten, behaelt aber die Notiz vom
|
||||||
|
vorherigen Stand bei (rein lokal, unabhaengig davon was die Aktion tut --
|
||||||
|
ein Tastenwechsel soll die Beschreibung 'was der Button macht' nicht
|
||||||
|
loeschen). Zum Loeschen explizit set_button_note()/set_encoder_note()
|
||||||
|
mit leerem String."""
|
||||||
|
return {"type": new_type, "data": new_data, "note": old_action.get("note", "")}
|
||||||
|
|
||||||
|
|
||||||
# ── Lesen ────────────────────────────────────────────────────────────────────
|
# ── Lesen ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -116,9 +130,15 @@ def get_macro(slot: int) -> dict:
|
||||||
def get_board_status() -> dict:
|
def get_board_status() -> dict:
|
||||||
"""Prueft per Serial, ob das Board erreichbar ist und welches Profil dort
|
"""Prueft per Serial, ob das Board erreichbar ist und welches Profil dort
|
||||||
gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der
|
gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der
|
||||||
Tkinter-Viewer den COM-Port gerade halten."""
|
Tkinter-Viewer den COM-Port gerade halten. Gibt den COM-Port danach
|
||||||
|
sofort wieder frei (kein dauerhaft offen gehaltener Serial-Handle --
|
||||||
|
sonst blockiert dieser Prozess andere Tools/Viewer mit "busy", bis er
|
||||||
|
beendet wird)."""
|
||||||
|
try:
|
||||||
profile = _link.read_active_profile()
|
profile = _link.read_active_profile()
|
||||||
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error}
|
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error}
|
||||||
|
finally:
|
||||||
|
_link.close()
|
||||||
|
|
||||||
|
|
||||||
# ── Buttons (20 pro Profil, MX-Matrix) ───────────────────────────────────────
|
# ── Buttons (20 pro Profil, MX-Matrix) ───────────────────────────────────────
|
||||||
|
|
@ -132,7 +152,7 @@ def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = []
|
||||||
btn = _find(_profile(profile)["buttons"], index)
|
btn = _find(_profile(profile)["buttons"], index)
|
||||||
keycode = vp.hid_key_code_for_name(key)
|
keycode = vp.hid_key_code_for_name(key)
|
||||||
mod_bits = vp.modifier_bits_for_names(modifiers)
|
mod_bits = vp.modifier_bits_for_names(modifiers)
|
||||||
btn["action"] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
|
btn["action"] = _replace_action(btn["action"], "HidKey", (mod_bits << 8) | keycode)
|
||||||
return _describe_action(btn["action"])
|
return _describe_action(btn["action"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -142,7 +162,7 @@ def set_button_consumer(profile: int, index: int, consumer: str) -> dict:
|
||||||
'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'."""
|
'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'."""
|
||||||
btn = _find(_profile(profile)["buttons"], index)
|
btn = _find(_profile(profile)["buttons"], index)
|
||||||
cid = vp.consumer_id_for_name(consumer)
|
cid = vp.consumer_id_for_name(consumer)
|
||||||
btn["action"] = {"type": "HidConsumer", "data": cid}
|
btn["action"] = _replace_action(btn["action"], "HidConsumer", cid)
|
||||||
return _describe_action(btn["action"])
|
return _describe_action(btn["action"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -151,7 +171,7 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
|
||||||
"""Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst
|
"""Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst
|
||||||
mit set_macro() befuellen."""
|
mit set_macro() befuellen."""
|
||||||
btn = _find(_profile(profile)["buttons"], index)
|
btn = _find(_profile(profile)["buttons"], index)
|
||||||
btn["action"] = {"type": "Macro", "data": slot}
|
btn["action"] = _replace_action(btn["action"], "Macro", slot)
|
||||||
return _describe_action(btn["action"])
|
return _describe_action(btn["action"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,15 +179,27 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
|
||||||
def set_button_profile_switch(profile: int, index: int, target) -> dict:
|
def set_button_profile_switch(profile: int, index: int, target) -> dict:
|
||||||
"""Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2."""
|
"""Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2."""
|
||||||
btn = _find(_profile(profile)["buttons"], index)
|
btn = _find(_profile(profile)["buttons"], index)
|
||||||
btn["action"] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
|
btn["action"] = _replace_action(btn["action"], "ProfileSwitch", _profile_switch_data(target))
|
||||||
return _describe_action(btn["action"])
|
return _describe_action(btn["action"])
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def set_button_none(profile: int, index: int) -> dict:
|
def set_button_none(profile: int, index: int) -> dict:
|
||||||
"""Entfernt die Belegung eines MX-Buttons (Action = None)."""
|
"""Entfernt die Belegung eines MX-Buttons (Action = None). Notiz bleibt
|
||||||
|
erhalten -- zum Loeschen set_button_note(profile, index, "")."""
|
||||||
btn = _find(_profile(profile)["buttons"], index)
|
btn = _find(_profile(profile)["buttons"], index)
|
||||||
btn["action"] = {"type": "None", "data": 0}
|
btn["action"] = _replace_action(btn["action"], "None", 0)
|
||||||
|
return _describe_action(btn["action"])
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def set_button_note(profile: int, index: int, note: str) -> dict:
|
||||||
|
"""Setzt/aendert die freie Notiz eines MX-Buttons -- was der Button tut,
|
||||||
|
unabhaengig von der technischen Aktion (z.B. 'Speichern in Fusion 360').
|
||||||
|
Rein lokal, landet nie aufs Board (wie Profilnamen). Leerer String
|
||||||
|
loescht die Notiz."""
|
||||||
|
btn = _find(_profile(profile)["buttons"], index)
|
||||||
|
btn["action"]["note"] = note
|
||||||
return _describe_action(btn["action"])
|
return _describe_action(btn["action"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -199,7 +231,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
|
||||||
enc, f = _encoder_field(profile, index, field)
|
enc, f = _encoder_field(profile, index, field)
|
||||||
keycode = vp.hid_key_code_for_name(key)
|
keycode = vp.hid_key_code_for_name(key)
|
||||||
mod_bits = vp.modifier_bits_for_names(modifiers)
|
mod_bits = vp.modifier_bits_for_names(modifiers)
|
||||||
enc[f] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
|
enc[f] = _replace_action(enc[f], "HidKey", (mod_bits << 8) | keycode)
|
||||||
return _describe_action(enc[f])
|
return _describe_action(enc[f])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -207,7 +239,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
|
||||||
def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict:
|
def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict:
|
||||||
"""Belegt eine Encoder-Aktion mit einer Medientaste."""
|
"""Belegt eine Encoder-Aktion mit einer Medientaste."""
|
||||||
enc, f = _encoder_field(profile, index, field)
|
enc, f = _encoder_field(profile, index, field)
|
||||||
enc[f] = {"type": "HidConsumer", "data": vp.consumer_id_for_name(consumer)}
|
enc[f] = _replace_action(enc[f], "HidConsumer", vp.consumer_id_for_name(consumer))
|
||||||
return _describe_action(enc[f])
|
return _describe_action(enc[f])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -215,7 +247,7 @@ def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) ->
|
||||||
def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict:
|
def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict:
|
||||||
"""Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31)."""
|
"""Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31)."""
|
||||||
enc, f = _encoder_field(profile, index, field)
|
enc, f = _encoder_field(profile, index, field)
|
||||||
enc[f] = {"type": "Macro", "data": slot}
|
enc[f] = _replace_action(enc[f], "Macro", slot)
|
||||||
return _describe_action(enc[f])
|
return _describe_action(enc[f])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -225,15 +257,26 @@ def set_encoder_profile_switch(profile: int, index: int, field: str, target) ->
|
||||||
Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der
|
Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der
|
||||||
Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern."""
|
Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern."""
|
||||||
enc, f = _encoder_field(profile, index, field)
|
enc, f = _encoder_field(profile, index, field)
|
||||||
enc[f] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
|
enc[f] = _replace_action(enc[f], "ProfileSwitch", _profile_switch_data(target))
|
||||||
return _describe_action(enc[f])
|
return _describe_action(enc[f])
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def set_encoder_none(profile: int, index: int, field: str) -> dict:
|
def set_encoder_none(profile: int, index: int, field: str) -> dict:
|
||||||
"""Entfernt eine Encoder-Belegung (Action = None)."""
|
"""Entfernt eine Encoder-Belegung (Action = None). Notiz bleibt erhalten
|
||||||
|
-- zum Loeschen set_encoder_note(profile, index, field, "")."""
|
||||||
enc, f = _encoder_field(profile, index, field)
|
enc, f = _encoder_field(profile, index, field)
|
||||||
enc[f] = {"type": "None", "data": 0}
|
enc[f] = _replace_action(enc[f], "None", 0)
|
||||||
|
return _describe_action(enc[f])
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def set_encoder_note(profile: int, index: int, field: str, note: str) -> dict:
|
||||||
|
"""Setzt/aendert die freie Notiz einer Encoder-Aktion (sw/cw/ccw) -- was
|
||||||
|
sie tut, unabhaengig von der technischen Aktion. Rein lokal, landet nie
|
||||||
|
aufs Board. Leerer String loescht die Notiz."""
|
||||||
|
enc, f = _encoder_field(profile, index, field)
|
||||||
|
enc[f]["note"] = note
|
||||||
return _describe_action(enc[f])
|
return _describe_action(enc[f])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -297,9 +340,11 @@ def load_local(path: str = None) -> dict:
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def load_from_board() -> dict:
|
def load_from_board() -> dict:
|
||||||
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
|
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
|
||||||
ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die
|
ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben
|
||||||
kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade
|
erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der
|
||||||
von VersaGUI/dem Tkinter-Viewer gehalten wird."""
|
COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den
|
||||||
|
COM-Port danach sofort wieder frei (siehe get_board_status())."""
|
||||||
|
try:
|
||||||
raw_cfg = _link.read_full_config()
|
raw_cfg = _link.read_full_config()
|
||||||
if raw_cfg is None:
|
if raw_cfg is None:
|
||||||
raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}")
|
raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}")
|
||||||
|
|
@ -313,8 +358,12 @@ def load_from_board() -> dict:
|
||||||
macro_slots = vproto.unpack_macros(raw_macros)
|
macro_slots = vproto.unpack_macros(raw_macros)
|
||||||
|
|
||||||
names = _cfg()["profile_names"]
|
names = _cfg()["profile_names"]
|
||||||
_state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
|
previous = _state["combined"]
|
||||||
|
_state["combined"] = vcomb.merge_notes(
|
||||||
|
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
|
||||||
return list_profiles()
|
return list_profiles()
|
||||||
|
finally:
|
||||||
|
_link.close()
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
|
@ -322,13 +371,17 @@ def write_to_board() -> dict:
|
||||||
"""Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs
|
"""Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs
|
||||||
Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft
|
Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft
|
||||||
Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit
|
Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit
|
||||||
NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port."""
|
NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.
|
||||||
|
Gibt den COM-Port danach sofort wieder frei (siehe get_board_status())."""
|
||||||
|
try:
|
||||||
cfg_bytes, macro_bytes = vcomb.to_binary(_cfg())
|
cfg_bytes, macro_bytes = vcomb.to_binary(_cfg())
|
||||||
if not _link.write_full_config(cfg_bytes):
|
if not _link.write_full_config(cfg_bytes):
|
||||||
raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}")
|
raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}")
|
||||||
if not _link.write_macros(macro_bytes):
|
if not _link.write_macros(macro_bytes):
|
||||||
raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}")
|
raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
finally:
|
||||||
|
_link.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue