From 13c37454797fe825ffe6d12c64e2b32f2d0f676d Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:15:25 +0200 Subject: [PATCH 01/15] Make build_and_deploy.ps1 self-installing and fail loudly pip install of pyinstaller/pystray/pillow was a separate manual step the README only mentioned in prose, so the script silently died on missing packages -- especially bad on Explorer double-click, where the window closes before any error is visible. Consolidate all dependencies into requirements.txt (also used by README's plain "pip install -r" flow), have the script install it itself, wrap the whole build in try/catch with exit-code checks after every native call, and pause on both success and failure unless -NoPause is passed. Also move the build output from %LOCALAPPDATA% into dist/ next to the script, so it's easy to find and matches the already-gitignored dist/ entry. --- README.md | 24 +++++++---- build_and_deploy.ps1 | 98 +++++++++++++++++++++++++++++++++----------- requirements.txt | 5 +++ 3 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 requirements.txt diff --git a/README.md b/README.md index ebe6691..c1f56c5 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,18 @@ Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`, ## Voraussetzungen - Python 3.11 oder neuer -- Pakete: `pyserial` (Board-Kommunikation), `pystray` + `pillow` - (Tray-Icon/Desktop-App), optional `mcp` (nur für den MCP-Server) +- Alle Pakete stehen in `requirements.txt` (`pyserial` fürs Board, + `pystray` + `pillow` fürs Tray-Icon/Desktop-Fenster, `mcp` für den + MCP-Server, `pyinstaller` fürs `.exe`-Bauen): ```bash -pip install pyserial pystray pillow mcp +pip install -r requirements.txt ``` Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek — -keine zusätzlichen Pakete nötig. +keine zusätzlichen Pakete nötig. `build_and_deploy.ps1` installiert +`requirements.txt` beim Bauen automatisch selbst — ein manuelles +`pip install` vorher ist dafür nicht nötig. ## Starten @@ -65,8 +68,15 @@ Maschine/Python-Version unterschiedlich). Selbst bauen: .\build_and_deploy.ps1 ``` -Das Skript baut mit PyInstaller (`--onedir --windowed`, eigenes Icon) und -kopiert das Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. +Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus +`requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann +mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das +Ergebnis nach `dist\VersaPadViewer\VersaPadViewer.exe` im Projektordner. +Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das +Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich +bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause` +unterdrückt das für automatisierte Aufrufe/CI). + **Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und @@ -75,8 +85,6 @@ das Nachladen der Bundle-DLLs von einem Netzwerkpfad, ohne jede Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb automatisch zuerst nach `%TEMP%` und baut nur dort. -Voraussetzung: `pip install pyinstaller` zusätzlich zu den obigen Paketen. - ## Aufbau | Datei | Zweck | diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index 8243798..e0617e0 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -1,5 +1,5 @@ # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis -# nach lokal (C:\Users\\AppData\Local\VersaPadViewer). +# nach $projectDir\dist\VersaPadViewer. # # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf # dem Netzlaufwerk (Z:\Git\...): @@ -12,35 +12,87 @@ # SMB-Share, kombiniert mit dem eh schon langen Projektpfad. # # Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach -# das fertige Bundle nach %LOCALAPPDATA% kopieren. Z: wird nur zum Lesen der -# Quelldateien angefasst. +# das fertige Bundle nach $projectDir\dist kopieren. Z: wird nur zum Lesen +# der Quelldateien angefasst. +# +# Fehlerverhalten: Bei Doppelklick im Explorer schliesst sich das Fenster +# sofort nach Skriptende -- ohne Pause waeren Fehlermeldungen unsichtbar +# ("stirbt ohne jede Fehlermeldung"). Deshalb: alles in try/catch, im +# Fehlerfall UND am Ende eine Pause, ausser bei -NoPause (fuer CI/Automation). + +param( + [switch]$NoPause +) $ErrorActionPreference = "Stop" $projectDir = $PSScriptRoot $buildSrc = "$env:TEMP\versapad_build_src" -$localDir = "$env:LOCALAPPDATA\VersaPadViewer" +$localDir = "$projectDir\dist\VersaPadViewer" +$requirementsFile = "$projectDir\requirements.txt" -if (Test-Path $buildSrc) { - Remove-Item $buildSrc -Recurse -Force +function Assert-LastExitCode([string]$step) { + if ($LASTEXITCODE -ne 0) { + throw "$step ist fehlgeschlagen (Exit-Code $LASTEXITCODE) -- Ausgabe oben pruefen." + } +} + +function Wait-ForKeyIfInteractive { + if ($NoPause) { return } + try { + Read-Host "Taste druecken zum Schliessen" + } catch { + # z.B. nicht-interaktiver Aufruf (stdin nicht verfuegbar) -- einfach ignorieren + } } -New-Item -ItemType Directory -Path $buildSrc | Out-Null -Copy-Item "$projectDir\*.py" -Destination $buildSrc -Copy-Item "$projectDir\icon.ico" -Destination $buildSrc -Copy-Item "$projectDir\icon.png" -Destination $buildSrc -Push-Location $buildSrc try { - py -m PyInstaller --windowed --onedir --name VersaPadViewer ` - --icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` - desktop_viewer.py --noconfirm -} finally { - Pop-Location -} + Write-Host "Pruefe Python-Installation..." + py --version + Assert-LastExitCode "'py --version' (ist Python installiert und im PATH?)" -if (Test-Path $localDir) { - Remove-Item $localDir -Recurse -Force -} -Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse -Remove-Item $buildSrc -Recurse -Force + if (-not (Test-Path $requirementsFile)) { + throw "requirements.txt nicht gefunden unter $requirementsFile" + } + Write-Host "Installiere/aktualisiere benoetigte Pakete aus requirements.txt..." + py -m pip install --quiet --disable-pip-version-check -r $requirementsFile + Assert-LastExitCode "Paketinstallation (pip install -r requirements.txt)" -Write-Host "Fertig: $localDir\VersaPadViewer.exe" + if (Test-Path $buildSrc) { + Remove-Item $buildSrc -Recurse -Force + } + New-Item -ItemType Directory -Path $buildSrc | Out-Null + Copy-Item "$projectDir\*.py" -Destination $buildSrc + Copy-Item "$projectDir\icon.ico" -Destination $buildSrc + Copy-Item "$projectDir\icon.png" -Destination $buildSrc + + Push-Location $buildSrc + try { + Write-Host "Baue mit PyInstaller (--onedir --windowed)..." + py -m PyInstaller --windowed --onedir --name VersaPadViewer ` + --icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` + desktop_viewer.py --noconfirm + Assert-LastExitCode "PyInstaller-Build" + } finally { + Pop-Location + } + + if (-not (Test-Path "$buildSrc\dist\VersaPadViewer\VersaPadViewer.exe")) { + throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen." + } + + if (Test-Path $localDir) { + Remove-Item $localDir -Recurse -Force + } + Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse + Remove-Item $buildSrc -Recurse -Force + + Write-Host "" + Write-Host "Fertig: $localDir\VersaPadViewer.exe" -ForegroundColor Green + Wait-ForKeyIfInteractive +} +catch { + Write-Host "" + Write-Host "FEHLER: $($_.Exception.Message)" -ForegroundColor Red + Wait-ForKeyIfInteractive + exit 1 +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed0027c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pyserial +pystray +pillow +mcp +pyinstaller From 5d14bdd826fe2e46128b61e26ee2859961860c2d Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:17:11 +0200 Subject: [PATCH 02/15] Move config storage next to the install and auto-create it if missing versapad_combined.DEFAULT_PATH was hardcoded to this one machine's OneDrive desktop, which made the tool unusable anywhere else. versapad_data.app_dir() now resolves to the running .exe's own folder when frozen, or the project directory when run from source, and DEFAULT_PATH hangs off that instead. load_or_fetch() previously raised when both the file was missing and the board unreachable, blocking a fresh install with no config and no board attached. It now falls back to an empty default_combined() in that case, so the tool is immediately usable either way. desktop_viewer's _current_profile_view() picks up the same fallback instead of re-implementing a narrower version of it. Also drop the hardcoded PROFILE_NAMES dict, which had drifted out of sync with the profile_names already stored in the combined JSON -- renaming a profile in Programmiermodus never showed up in the read-only/browser views. server.py and desktop_viewer.py now read names from the same JSON everywhere. versapad_data.CONFIG_PATHS (read-only interop with the official C# VersaGUI's JSON export) is intentionally left on the OneDrive desktop -- nothing in this codebase writes there, it's not part of this tool's own config. --- .gitignore | 4 ++++ AGENTS.md | 44 ++++++++++++++++++++++++++++++++++++-- README.md | 32 +++++++++++++++++++-------- desktop_viewer.py | 51 +++++++++++++++++++++----------------------- server.py | 6 +++--- versapad_combined.py | 39 ++++++++++++++++++++++++--------- versapad_data.py | 28 ++++++++++++++++++------ 7 files changed, 147 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 2a1de3f..6c4e83b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ __pycache__/ *.log build/ dist/ + +# Nutzer-Config -- landet neben der Installation (versapad_data.app_dir()), +# beim Start aus dem Quellcode also direkt hier im Projektordner +versapad_config_all.json diff --git a/AGENTS.md b/AGENTS.md index 0604c07..653ea34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ Nutzerorientierte Einführung: [`README.md`](README.md). - `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/ Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie (`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`. + `app_dir()` liefert das Verzeichnis für die eigene Config + (`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations- + ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten. + `CONFIG_PATHS` bleibt bewusst auf dem OneDrive-Desktop hartkodiert (Lese- + Interop mit einem JSON-Export der offiziellen VersaGUI, kein von diesem + Tool geschriebenes Format, siehe dort). - `server.py` — stdlib `http.server`, generiert HTML pro Request neu, Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend, kein Programmiermodus (bewusst einfach gehalten). @@ -39,8 +45,14 @@ Nutzerorientierte Einführung: [`README.md`](README.md). liefert `READ_STATUS` schlicht Timeout, kein Absturz. - `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros + **nur lokal gespeicherte** Profilnamen), Default-Pfad - `~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll: - `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den kompletten 740B-Block. + `versapad_data.app_dir()\versapad_config_all.json` (neben der + Installation, nicht mehr hartkodiert auf einer bestimmten Maschine). + Passt zum Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer + den kompletten 740B-Block. `load_or_fetch()` legt bei fehlender Datei + automatisch eine neue an — erst Versuch per Serial vom Board, sonst als + leere `default_combined()` (kein Board noetig fuer die Erstbenutzung). + `read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer + Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz). **UI:** - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi @@ -93,6 +105,34 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json` zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine Bearbeitung "verschwunden". +- **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. `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. - **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 diff --git a/README.md b/README.md index c1f56c5..b185713 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original, inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum -Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die -Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte -Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`, -`versapad_combined.DEFAULT_PATH`) — für einen anderen Rechner dort anpassen. +Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene +Config-Datei (`versapad_config_all.json`) liegt automatisch neben der +Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu +angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* +Lese-Interop mit den JSON-Exports der offiziellen VersaGUI +(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine +hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen, +falls gewünscht. ## Features @@ -102,11 +106,21 @@ Das Binärformat (`versapad_protocol.py`) ist 1:1 aus den VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). -Config-Dateien liegen standardmäßig auf dem Desktop -(`versapad_config1/2/3.json` für den reinen Lesemodus, -`versapad_config_all.json` für den Programmiermodus — Pfade sind aktuell -hartkodiert für eine bestimmte Windows-Maschine, siehe `CONFIG_PATHS` in -`versapad_data.py` bzw. `DEFAULT_PATH` in `versapad_combined.py`). +Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile + +Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der +Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem +Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (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 (Programmiermodus) oder `rename_profile()` +(MCP) ändern. + +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 diff --git a/desktop_viewer.py b/desktop_viewer.py index 926337b..54cd76f 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -167,8 +167,8 @@ class VersaPadViewer(tk.Tk): self.tabs = tk.Frame(self, bg=BG) self.tabs.pack(fill="x", padx=20, pady=(12, 10)) self.tab_buttons = {} - for p in sorted(vp.PROFILE_NAMES): - btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT, + for p in range(vp.NUM_PROFILES): + btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2") btn.pack(side="left", padx=(0, 8)) btn.bind("", lambda e, prof=p: self.set_profile(prof, manual=True)) @@ -242,11 +242,16 @@ class VersaPadViewer(tk.Tk): self._render() def _update_tab_labels(self): + """Profilnamen kommen immer aus der kombinierten JSON (nicht mehr + hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst + per schlankem Datei-Read (kein Board-Zugriff, siehe + versapad_combined.profile_names()).""" + if self.editing.get() and self.combined: + names = self.combined["profile_names"] + else: + names = vcomb.read_profile_names() for p, btn in self.tab_buttons.items(): - if self.editing.get() and self.combined: - btn.configure(text=self.combined["profile_names"][p]) - else: - btn.configure(text=vp.PROFILE_NAMES[p]) + btn.configure(text=names[p]) def _rename_tab(self, profile): if not self.editing.get() or self.combined is None: @@ -526,16 +531,19 @@ class VersaPadViewer(tk.Tk): Bevorzugt die kombinierte Datei (versapad_config_all.json), falls vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen. - Fehlt sie (z.B. versehentlich geloescht) und ist Live-Sync gerade aus - (COM-Port frei), wird sie automatisch per Serial vom Board neu - aufgebaut und als neuer Cache gespeichert -- das Board ist die - eigentliche Quelle der Wahrheit, kein Datei-Handling von Hand mehr - noetig. Nur wenn das nicht klappt (Board nicht erreichbar, Live-Sync - haelt den Port), weicht es zuletzt auf die klassischen - versapad_config{1,2,3}.json aus.""" - if os.path.exists(vcomb.DEFAULT_PATH): + Fehlt sie (z.B. versehentlich geloescht, oder frische Installation + ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird + sie automatisch angelegt -- per Serial vom Board, oder als leere + Default-Config, wenn auch kein Board erreichbar ist (siehe + versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz + ohne vorhandene Config sofort benutzbar, kein Datei-Handling von + Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den + COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die + klassischen versapad_config{1,2,3}.json (Export der offiziellen + VersaGUI) aus.""" + if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get(): try: - data = vcomb.load_file(vcomb.DEFAULT_PATH) + data = vcomb.load_or_fetch(link=self._link) raw = data["profiles"][self.profile] cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], @@ -544,18 +552,6 @@ class VersaPadViewer(tk.Tk): return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" except (KeyError, IndexError, ValueError): pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen - elif not self.live_sync.get(): - try: - data = vcomb.fetch_from_board(link=self._link) - vcomb.save_file(data, vcomb.DEFAULT_PATH) - raw = data["profiles"][self.profile] - cfg = vp.annotate_profile({ - "buttons": [dict(b) for b in raw["buttons"]], - "encoders": [dict(e) for e in raw["encoders"]], - }) - return cfg, "Quelle: Board (neu vom Geraet geladen)" - except RuntimeError: - pass # Board nicht erreichbar -- weiter unten ausweichen try: return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}" except FileNotFoundError as e: @@ -564,6 +560,7 @@ class VersaPadViewer(tk.Tk): raise FileNotFoundError(f"{e}{hint}") from e def _render(self): + self._update_tab_labels() for w in self.grid_frame.winfo_children(): w.destroy() for w in self.enc_frame.winfo_children(): diff --git a/server.py b/server.py index d184a88..9188d64 100644 --- a/server.py +++ b/server.py @@ -98,8 +98,8 @@ def render_page(profile): "encoders": [dict(e) for e in raw["encoders"]], }) tabs = "".join( - f'{html.escape(vp.PROFILE_NAMES[p])}' - for p in sorted(vp.PROFILE_NAMES) + f'{html.escape(combined["profile_names"][p])}' + for p in range(vp.NUM_PROFILES) ) cells = "".join(render_cell(b) for b in cfg["buttons"]) encoders = "".join(render_encoder(e) for e in cfg["encoders"]) @@ -130,7 +130,7 @@ class Handler(BaseHTTPRequestHandler): profile = int(query.get("profile", ["0"])[0]) except ValueError: profile = 0 - if profile not in vp.PROFILE_NAMES: + if not (0 <= profile < vp.NUM_PROFILES): profile = 0 try: body = render_page(profile).encode("utf-8") diff --git a/versapad_combined.py b/versapad_combined.py index e6011ae..aad8f48 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -19,7 +19,7 @@ import versapad_data as vp import versapad_protocol as proto import versapad_serial as vs -DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json") +DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json") DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] @@ -126,18 +126,37 @@ def fetch_from_board(link=None, profile_names=None): def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None): """Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich - geloescht), wird sie automatisch per Serial vom Board neu aufgebaut und - als neuer Cache gespeichert, statt einen Fehler zu werfen -- das Board - behaelt die Config dauerhaft im NVM, die Desktop-JSON ist nur ein - Lesecache dafuer und muss nicht von Hand gepflegt werden. Ist das Board - nicht erreichbar (nicht verbunden, COM-Port belegt), wirft es - RuntimeError mit Klartext-Ursache -- Aufrufer entscheidet, ob es einen - weiteren Fallback gibt (z.B. alte Einzel-JSONs).""" + 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) - combined = fetch_from_board(link=link, profile_names=profile_names) + 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 # Board-Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden + pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden return combined + + +def read_profile_names(path=DEFAULT_PATH): + """Nur die (lokalen) Profilnamen lesen, ohne die volle Config zu + brauchen -- fuer Tab-Beschriftungen im Nur-Lese-Modus. Greift bewusst + nicht aufs Board zu (kein COM-Port-Konflikt mit Live-Sync), faellt bei + fehlender/kaputter Datei auf DEFAULT_NAMES zurueck.""" + if os.path.exists(path): + try: + return load_file(path).get("profile_names", list(DEFAULT_NAMES)) + except (OSError, ValueError): + pass + return list(DEFAULT_NAMES) diff --git a/versapad_data.py b/versapad_data.py index f4abcc0..0e147c6 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -7,19 +7,35 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. """ import json import os +import sys +NUM_PROFILES = 3 + + +def app_dir(): + """Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json, + siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir) + das Installationsverzeichnis neben der .exe, sonst der Ordner dieses + Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter + Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/ + installieren, ohne Pfade von Hand anzupassen.""" + if getattr(sys, "frozen", False): + return os.path.dirname(sys.executable) + return os.path.dirname(os.path.abspath(__file__)) + + +# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind +# keine von diesem Tool geschriebenen Dateien, sondern ein Export der +# offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese- +# Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche, +# von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH +# (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop). CONFIG_PATHS = { 0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"), 1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"), 2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"), } -PROFILE_NAMES = { - 0: "Profil 0 – Windows", - 1: "Profil 1 – Fusion 360", - 2: "Profil 2 – BricsCAD", -} - # index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge) GRID_COLS = 4 GRID_ROWS = 5 From 82c3fd005668a5e015cd9582894b2e909c4ca963 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:17:38 +0200 Subject: [PATCH 03/15] Allow renaming profile tabs outside Programmiermodus Double-click-to-rename already existed but silently no-op'd unless Programmiermodus was on, and even there it only updated the in-memory self.combined without saving -- the name was lost unless some later button edit happened to trigger an autosave. Since profile_names lives purely in the combined JSON and never touches the board, there's no reason to gate it behind the heavier editing mode: it now works from any mode, loading/saving the combined file directly (auto-creating it if needed) when not already in an active Programmiermodus session, and always persists immediately. --- README.md | 4 ++-- desktop_viewer.py | 35 +++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b185713..7a06535 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,8 @@ Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (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 (Programmiermodus) oder `rename_profile()` -(MCP) ändern. +per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder +Programmiermodus) oder `rename_profile()` (MCP) ändern. Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export diff --git a/desktop_viewer.py b/desktop_viewer.py index 54cd76f..81b1805 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -254,14 +254,37 @@ class VersaPadViewer(tk.Tk): btn.configure(text=names[p]) def _rename_tab(self, profile): - if not self.editing.get() or self.combined is None: - return - current = self.combined["profile_names"][profile] + """Profilname per Doppelklick auf den Tab umbenennen -- geht in + jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische + Domänenregeln). Im Programmiermodus wird der bereits geladene + In-Memory-State direkt bearbeitet + autosaved; sonst wird die + kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf + automatisch an, siehe versapad_combined.load_or_fetch()).""" + if self.editing.get() and self.combined is not None: + combined = self.combined + else: + try: + combined = vcomb.load_or_fetch() + except (OSError, ValueError, KeyError) as e: + messagebox.showerror("Umbenennen fehlgeschlagen", str(e)) + return + + current = combined["profile_names"][profile] name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):", initialvalue=current, parent=self) - if name: - self.combined["profile_names"][profile] = name - self._update_tab_labels() + if not name: + return + combined["profile_names"][profile] = name + + if combined is self.combined: + self._autosave_combined() + else: + try: + vcomb.save_file(combined, vcomb.DEFAULT_PATH) + except OSError as e: + messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}") + return + self._update_tab_labels() def _show_mcp_info(self): win = tk.Toplevel(self) From 09fbd6ad96f066b0152e0c2ff00cea0f40598f8e Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:18:08 +0200 Subject: [PATCH 04/15] Register versapad MCP server via project-scoped .mcp.json Lets Claude Code offer the "versapad" MCP server automatically when this project is opened, instead of requiring a manual `claude mcp add -s user` per machine. Note: the script path is currently absolute (this machine's checkout location) rather than relative -- .mcp.json doesn't reliably support workspace-relative variables across Claude Code environments, so this only works as-is on this specific checkout path for now. --- .mcp.json | 8 ++++++++ AGENTS.md | 5 +++-- README.md | 9 ++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..2b7f17d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "versapad": { + "command": "py", + "args": ["C:\\Users\\Julian\\Documents\\__CODE\\VersaGUI-py\\versapad_mcp_server.py"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 653ea34..b508aaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,8 +62,9 @@ Nutzerorientierte Einführung: [`README.md`](README.md). `MacroStepsDialog`) für den Programmiermodus. **MCP-Server:** -- `versapad_mcp_server.py` — registriert als User-Scope-MCP-Server - "versapad" (`claude mcp add -s user versapad -- versapad_mcp_server.py`). +- `versapad_mcp_server.py` — registriert als projektgebundener MCP-Server + "versapad" über `.mcp.json` im Projektordner (Alternative: + `claude mcp add -s user versapad -- versapad_mcp_server.py`). Nutzt MCP-SDK v2 (Paket `mcp`, Klasse `mcp.server.mcpserver.MCPServer` — **nicht** `FastMCP` aus `mcp.server.fastmcp`, das existiert in dieser SDK-Version nicht mehr, wurde umbenannt). `@mcp.tool()`-dekorierte diff --git a/README.md b/README.md index 7a06535..e556a47 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,12 @@ OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in `versapad_mcp_server.py` macht die Config per Tool-Aufruf statt Hand-JSON programmierbar — nutzbar von jeder MCP-fähigen KI-Anwendung (Claude Code, -Claude Desktop, andere). In der MCP-Server-Liste der jeweiligen Anwendung -eintragen: Kommando `python`/`py`, Argument der Pfad zu -`versapad_mcp_server.py`. +Claude Desktop, andere). Für Claude Code liegt bereits eine projektgebundene +[`.mcp.json`](.mcp.json) im Repo (Server "versapad", Kommando `py +versapad_mcp_server.py`) — beim Öffnen des Projekts wird sie automatisch +zum Verbinden angeboten. Für andere Anwendungen oder user-scope-Registrierung +in der jeweiligen MCP-Server-Liste eintragen: Kommando `python`/`py`, +Argument der Pfad zu `versapad_mcp_server.py`. Werkzeuge (Auszug): `list_profiles`, `get_profile`, `get_macro`, `get_board_status` (lesen) · `set_button_key`/`_consumer`/`_macro`/ From 7d40fdaa60fb009162751dfd7f0b5e58a03c9a2a Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:18:32 +0200 Subject: [PATCH 05/15] Close the serial link after each MCP board operation VersaPadLink never closes itself; get_board_status()/load_from_board()/ write_to_board() were leaving the exclusive COM port open for the rest of the MCP server process's lifetime after a single call. That locked out Live-Sync, the official VersaGUI, and even the MCP server's own next call with "busy", live-observed today after a single write_to_board() call. Each of the three now closes the link in a finally block regardless of outcome. Also documented in AGENTS.md: this environment can run several independent versapad_mcp_server.py processes at once, each with its own in-memory state, which caused a write_to_board() call to silently write blank data from a fresh process instead of the config that had just been built up on another one (ACK still said {"ok": true}). Recommended workaround noted there: load_local() right before write_to_board(), and read back with load_from_board() + get_profile() afterwards instead of trusting the ACK. --- AGENTS.md | 20 +++++++++++++ versapad_mcp_server.py | 66 +++++++++++++++++++++++++++--------------- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b508aaf..525e540 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,26 @@ Nutzerorientierte Einführung: [`README.md`](README.md). Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei älteren FastMCP-Versionen). Tool-Liste: siehe README oder `MCP_INFO_TEXT` in `desktop_viewer.py`. +- **Board-Serial-Tools schliessen den Link nach jedem Aufruf** (`get_board_status`, + `load_from_board`, `write_to_board` — `finally: _link.close()`). Grund: + `VersaPadLink` schliesst nie von selbst, ein einzelner Aufruf hätte sonst + den exklusiven COM-Port dauerhaft für den Rest des MCP-Serverprozesses + blockiert und Live-Sync/VersaGUI/den nächsten Aufruf mit "busy" ausgesperrt + (am 2026-08-14 live so aufgetreten, siehe unten). +- **Bug beobachtet 2026-08-14:** In diesem Agenten-Environment (Claude-Code- + VSCode-Extension) können mehrere unabhängige `versapad_mcp_server.py`- + Prozesse gleichzeitig laufen (bis zu 8 beobachtet, vermutlich durch + wiederholte Tool-Ladevorgänge/Reconnects innerhalb einer Session) — jeder + mit eigenem, nicht geteiltem In-Memory-State (`_state["combined"]`). + Konkret beobachtet: `set_button_*`/`set_macro` + `save_local()` liefen + korrekt auf einem Prozess, ein späterer `write_to_board()`-Aufruf landete + aber auf einem anderen (frischen, leeren) Prozess und schrieb versehentlich + eine leere Default-Config aufs Board, trotz `{"ok": true}`-Antwort. Fix: + vor `write_to_board()` immer erst `load_local()` (liest die Datei frisch + von der Platte, unabhängig davon welcher Prozess antwortet), und nach + jedem Schreibvorgang mit `load_from_board()` + `get_profile()` gegenlesen + statt dem ACK allein zu vertrauen — genau dieses Verify-Pattern hat den + Fehler hier live aufgedeckt. Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code nachschlagen — die Dateien sind klein genug, dass eine separate diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 3149cdb..52e644f 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -116,9 +116,15 @@ def get_macro(slot: int) -> dict: def get_board_status() -> dict: """Prueft per Serial, ob das Board erreichbar ist und welches Profil dort gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der - Tkinter-Viewer den COM-Port gerade halten.""" - profile = _link.read_active_profile() - return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} + Tkinter-Viewer den COM-Port gerade halten. Schliesst die Verbindung + danach wieder (siehe write_to_board() fuer den Grund) -- der Port + ist exklusiv, ein einzelner Status-Check darf ihn nicht dauerhaft + fuer Live-Sync/VersaGUI blockieren.""" + try: + profile = _link.read_active_profile() + return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} + finally: + _link.close() # ── Buttons (20 pro Profil, MX-Matrix) ─────────────────────────────────────── @@ -299,22 +305,26 @@ def load_from_board() -> dict: """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade - von VersaGUI/dem Tkinter-Viewer gehalten wird.""" - raw_cfg = _link.read_full_config() - if raw_cfg is None: - raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") - raw_macros = _link.read_macros() - if raw_macros is None: - raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}") + von VersaGUI/dem Tkinter-Viewer gehalten wird. Schliesst die Verbindung + danach wieder (siehe write_to_board() fuer den Grund).""" + try: + raw_cfg = _link.read_full_config() + if raw_cfg is None: + raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") + raw_macros = _link.read_macros() + if raw_macros is None: + raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}") - cfg_dict = vproto.unpack_config(raw_cfg) - if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): - raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") - macro_slots = vproto.unpack_macros(raw_macros) + cfg_dict = vproto.unpack_config(raw_cfg) + if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): + raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") + macro_slots = vproto.unpack_macros(raw_macros) - names = _cfg()["profile_names"] - _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) - return list_profiles() + names = _cfg()["profile_names"] + _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) + return list_profiles() + finally: + _link.close() @mcp.tool() @@ -322,13 +332,21 @@ def write_to_board() -> dict: """Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit - NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.""" - cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) - if not _link.write_full_config(cfg_bytes): - raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") - if not _link.write_macros(macro_bytes): - raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") - return {"ok": True} + NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port. + Schliesst die Verbindung danach wieder -- VersaPadLink haelt den Port + sonst dauerhaft offen (kein automatisches Schliessen nach einem Befehl), + was Live-Sync/VersaGUI/den naechsten MCP-Aufruf sonst dauerhaft mit + "busy" blockieren wuerde, obwohl der eigentliche Vorgang laengst fertig + ist -- der Port ist exklusiv (siehe versapad_serial.py).""" + try: + cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) + if not _link.write_full_config(cfg_bytes): + raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") + if not _link.write_macros(macro_bytes): + raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") + return {"ok": True} + finally: + _link.close() if __name__ == "__main__": From 83429363c1a54c2a4134da105d724f2bdec11095 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:19:13 +0200 Subject: [PATCH 06/15] Add architecture/data-model/protocol reference docs Human-facing reference documentation, split from AGENTS.md's agent-facing domain rules and bug history (which stays there, not duplicated here): - docs/architecture.md: layer diagram, module responsibilities, config storage location, the three GUI modes, and the port-exclusivity / multi-process caveats around concurrent access - docs/data-model.md: the combined and legacy JSON formats, the binary SDeviceConfig/SDeviceProfile/SMacroTable NVM layout byte-for-byte, action types, LED fields, macro-slot conventions, button grid geometry - docs/protocol.md: the 8-byte serial packet format, command/event tables, the read/write/status-poll flows, connection lifecycle, and error states README.md now links to all three from a new "Dokumentation" section, and AGENTS.md's outdated "docs/ tree isn't warranted yet" note is removed now that it exists on explicit user request. --- AGENTS.md | 19 ++-- README.md | 16 +++- docs/architecture.md | 185 ++++++++++++++++++++++++++++++++++++++ docs/data-model.md | 205 +++++++++++++++++++++++++++++++++++++++++++ docs/protocol.md | 136 ++++++++++++++++++++++++++++ 5 files changed, 550 insertions(+), 11 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/data-model.md create mode 100644 docs/protocol.md diff --git a/AGENTS.md b/AGENTS.md index 525e540..4afe1a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,8 +242,6 @@ selbst vorgegeben (`SAction.data`), dort beibehalten statt umzubenennen. ## Deferred Work -- Volle `docs/`-Baumstruktur (siehe Dokumentation und Verifikation unten — - Projektgröße rechtfertigt das aktuell nicht, kein DB-/API-Dienst) - Board-seitiges Umschalten des aktiven Profils per Button in der GUI — explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only - Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im @@ -262,13 +260,16 @@ Nicht an diesen Punkten arbeiten, ohne dass der User es explizit anfragt. - `README.md` ist der Einstiegspunkt (Installation, Nutzung, Architektur- Überblick). - `AGENTS.md` (diese Datei) ist die agentenseitige Quelle der Wahrheit für - Domänenregeln und Architekturgrenzen — jede Session aktualisieren, die - daran etwas ändert oder etwas Wichtiges lernt. -- Größeneinschätzung nach Projekt-Dokumentationsstandard: kleines/mittleres - Tool ohne eigene Datenbank und ohne persistenten API-Dienst (der - Browser-Server ist ein einfacher lokaler Lese-Viewer, kein - Mehrbenutzer-Backend) → `README.md` + `AGENTS.md` sind Pflicht und - vorhanden, ein voller `docs/`-Baum ist nicht angemessen. + Domänenregeln, Architekturgrenzen und Bug-Historie — jede Session + aktualisieren, die daran etwas ändert oder etwas Wichtiges lernt. +- `docs/` (seit 2026-08-14, auf expliziten User-Wunsch) enthält die + menschenlesbare Referenzdoku: `architecture.md` (Schichten, Prozess-/ + Nebenläufigkeitsmodell, Config-Speicherort), `data-model.md` (JSON- + Formate, binäres NVM-Layout, Geometrie, Enums), `protocol.md` + (Serial-Wire-Protokoll). Bug-Historie/Domänenregeln bleiben bewusst nur + in `AGENTS.md`, nicht dupliziert in `docs/`. Bei Änderungen am + Binärformat/Protokoll/Datenmodell `docs/data-model.md` bzw. + `docs/protocol.md` mitpflegen. Prüfungen vor einem Commit an Binärformat/Protokoll: ```bash diff --git a/README.md b/README.md index e556a47..c6af12c 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,22 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`. werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert es aber nicht +## Dokumentation + +Ausführlichere technische Doku im [`docs/`](docs/)-Ordner: + +- [`docs/architecture.md`](docs/architecture.md) — Schichtenmodell, + Prozessmodell, Modi, Config-Speicherort, Nebenläufigkeit +- [`docs/data-model.md`](docs/data-model.md) — JSON-Formate (kombiniert + + Legacy), binäres NVM-Layout, Geometrie, Enums +- [`docs/protocol.md`](docs/protocol.md) — Serial-Wire-Protokoll (Befehle, + Events, Paketformat, CRC16) + ## Weiterentwicklung -Tiefere technische Notizen (Protokoll-Details, bekannte Stolpersteine beim -Bauen, Design-Entscheidungen) stehen in [`AGENTS.md`](AGENTS.md). +Agentenseitige Notizen (Domänenregeln, bekannte Bugs und ihre Fixes, +Implementierungsdisziplin, Design-Entscheidungen) stehen in +[`AGENTS.md`](AGENTS.md). ## Lizenz / Herkunft diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..142fc34 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,185 @@ +# Architektur + +Überblick über die Schichten, Prozesse und Datenflüsse von VersaPad Viewer. +Für Domänenregeln, bekannte Bugs und Implementierungsdisziplin siehe +[`AGENTS.md`](../AGENTS.md); für Installation/Nutzung siehe +[`README.md`](../README.md). Für die genauen Datenformate siehe +[`data-model.md`](data-model.md), für das Serial-Wire-Protokoll +[`protocol.md`](protocol.md). + +## Ziel und Kontext + +VersaPad Viewer ist ein eigenständiges Python-Tool für das VersaPad-Makropad +(4×5-Button-Grid + 4 Encoder, SAMD21-Firmware). Es ergänzt die offizielle +VersaGUI (C#/.NET) um eine schlankere, plattformunabhängigere Alternative +zum Anzeigen, Live-Synchronisieren und Neuprogrammieren der Belegung, plus +einen MCP-Server, der dieselbe Programmierung KI-gesteuert per Tool-Aufruf +erlaubt. Beide GUIs (die offizielle VersaGUI und dieses Tool) konkurrieren +um denselben exklusiven USB-CDC-Port — siehe „Nebenläufigkeit" unten. + +## Schichtenmodell + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontends │ +│ ┌────────────┐ ┌──────────────────┐ ┌────────────────────┐ │ +│ │ server.py │ │ desktop_viewer.py │ │ versapad_mcp_ │ │ +│ │ (Browser, │ │ (Tkinter, Tray, │ │ server.py │ │ +│ │ read-only)│ │ Live-Sync, Edit) │ │ (KI-Tool-Aufrufe) │ │ +│ └─────┬──────┘ └────────┬──────────┘ └─────────┬──────────┘ │ +│ │ │ │ │ +│ └───────────┬───────┴──────────────┬───────────┘ │ +│ ▼ ▼ │ +│ versapad_combined.py versapad_data.py │ +│ (Ein-Datei-Format, (Decoding fürs Anzeigen, │ +│ Board-Sync, Auto- Legacy-Einzel-JSONs) │ +│ Create) │ +│ │ │ +│ ▼ │ +│ versapad_protocol.py (pack/unpack, CRC16) │ +│ │ │ +│ ▼ │ +│ versapad_serial.py (VersaPadLink, 8-Byte-Pakete) │ +│ │ │ +└─────────────────────┼──────────────────────────────────────────────┘ + ▼ + VersaPad-Board (USB-CDC, VID:PID 239A:0042) +``` + +`action_dialog.py` ist ein UI-Hilfsmodul von `desktop_viewer.py` +(Bearbeiten-Dialoge für den Programmiermodus) und taucht oben nicht separat +auf. + +### Read-only-Schicht + +- **`versapad_data.py`** — decodiert JSON-Rohdaten (Keycodes, Consumer-IDs, + Modifier-Bits) zu lesbarem Text, kennt die Grid-Geometrie + (`index = spalte*5 + reihe`). Liest wahlweise: + - die klassischen `versapad_config1/2/3.json` (`CONFIG_PATHS`) — Export + der offiziellen VersaGUI, kein von diesem Tool geschriebenes Format; + - oder (über `versapad_combined.py`) die kombinierte Datei. + - `app_dir()` liefert das Basisverzeichnis für die eigene Config, siehe + „Config-Speicherort" unten. +- **`server.py`** — generiert bei jedem HTTP-Request frisch HTML aus dem + aktuellen Zustand (`vcomb.load_or_fetch()`), Auto-Reload alle 4s per + ``. Rein lesend, kein eigener Zustand zwischen + Requests, daher nie „veraltet" im Sinne von In-Memory-Staleness. + +### Binär-/Serial-Schicht + +- **`versapad_protocol.py`** — pack/unpack für `SDeviceConfig` (740B, alle + 3 Profile) und `SMacroTable` (512B, 32 Slots), plus CRC16. 1:1 aus den + Firmware-Structs übernommen, siehe [`data-model.md`](data-model.md). +- **`versapad_serial.py`** — `VersaPadLink`: öffnet bei Bedarf den COM-Port + (per VID/PID-Erkennung), spricht das 8-Byte-Paket-Protokoll, siehe + [`protocol.md`](protocol.md). **Schließt die Verbindung nicht von selbst** + nach einem Befehl — Aufrufer müssen das selbst tun, wenn sie den Port + nicht dauerhaft blockieren wollen (siehe „Nebenläufigkeit" unten). +- **`versapad_combined.py`** — das Ein-Datei-Format: alle 3 Profile + + Makro-Tabelle + lokale Profilnamen in einer JSON + (`versapad_config_all.json`). Bindeglied zwischen den JSON-Strukturen und + den Binärblobs aus `versapad_protocol.py`. Zentrale Funktionen: + - `load_or_fetch()` — bevorzugt die lokale Datei, baut sie bei Bedarf + automatisch neu auf (erst Board-Versuch, sonst leere Default-Config). + - `save_file()`/`load_file()` — reines Lesen/Schreiben der JSON. + - `fetch_from_board()`/`to_binary()` — Konvertierung zu/von den + Binärblobs für Board-Lese-/Schreibvorgänge. + - `read_profile_names()` — liest nur die Profilnamen, ohne Board-Zugriff + (für Tab-Beschriftungen im Nur-Lese-Modus). + +### UI + +- **`desktop_viewer.py`** — Tkinter-Fenster mit drei unabhängig + umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit + MCP-Doku. +- **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`, + `MacroStepsDialog`) für den Programmiermodus. + +### MCP-Server + +- **`versapad_mcp_server.py`** — registriert als projektgebundener + MCP-Server "versapad" (`.mcp.json`) oder wahlweise user-scope + (`claude mcp add -s user versapad -- versapad_mcp_server.py`). + Hält einen eigenen In-Memory-Zustand (`_state["combined"]`, + unabhängig von jeder laufenden GUI), der explizit per `save_local()` / + `load_local()` mit der Datei bzw. `load_from_board()` / `write_to_board()` + mit dem Board synchronisiert wird. Board-Serial-Tools schließen die + Verbindung nach jedem Aufruf wieder (siehe unten). + +## Config-Speicherort + +`versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene +Config-Datei (`versapad_combined.DEFAULT_PATH` = +`app_dir()/versapad_config_all.json`): + +- **Gebaute `.exe`** (PyInstaller `--onedir`): `sys.executable`s Ordner — + die Config liegt also neben `VersaPadViewer.exe`, in welchem + Installationsverzeichnis sie auch liegt. +- **Start aus dem Quellcode** (`py desktop_viewer.py`, `py server.py`, + `py versapad_mcp_server.py`): der Projektordner (`__file__`-Verzeichnis). + +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…“. | + +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 +`dist\VersaPadViewer\` im Projektordner. `--onedir` statt `--onefile`, um +AV-Fehlalarme zu verringern. Läuft komplett in try/catch mit +Exit-Code-Prüfung und pausiert am Ende (Erfolg wie Fehler) auf +Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem +Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details: +[`README.md`](../README.md#als-eigenständige-exe-windows). diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..88d160f --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,205 @@ +# 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 | + +## 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 }, + "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. + +## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`) + +Kein von diesem Tool geschriebenes Format — optionaler Export der +offiziellen VersaGUI, gelesen von `versapad_data.load_profile()`. Enthält +nur ein einzelnes Profil, keine Makro-Schritte, keinen Profilnamen: + +```jsonc +{ + "buttons": [ /* wie oben, 20 Eintraege */ ], + "encoders": [ /* wie oben, 4 Eintraege */ ] +} +``` + +## MCP-Tool-Grenzfläche (`set_macro`, `set_button_key`, …) + +Die MCP-Tools nehmen **menschenlesbare** Namen entgegen, keine Rohwerte — +`versapad_data.py` übersetzt: + +```jsonc +// set_button_key(profile=0, index=0, key="S", modifiers=["Strg"]) +// set_macro(slot=7, steps=[{"key": "1", "modifiers": []}, {"key": "0", "modifiers": []}]) +``` + +`hid_key_code_for_name()` / `consumer_id_for_name()` / `modifier_bits_for_names()` +übersetzen Namen → Rohwerte (werfen `ValueError` mit einer Liste gültiger +Namen bei Tippfehlern). Zeichentasten-Labels sind eine US-Layout-Näherung +(keine `GetKeyNameText()`-Auflösung wie im C#-Original). + +## Binäres NVM-Layout + +### `SDeviceConfig` (740 Byte, `versapad_protocol.CONFIG_SIZE`) + +| Offset | Größe | Feld | +|---|---|---| +| 0 | 4B (uint32 LE) | Magic (`0x56503203`, `NVM_CONFIG_MAGIC`) | +| 4 | 1B | Version (`3`, `NVM_CONFIG_VERSION`) | +| 5 | 2B (uint16 LE) | CRC16 über Byte 7-739 | +| 7 | 1B | `active_profile` (0-2) | +| 8 | 1B | `global_brightness` (0-255) | +| 9 | 4B | `enc_sensitivity` (4× uint8, einer je Encoder) | +| 13 | 19B | reserviert/ungenutzt (Padding) | +| 32 | 236B | Profil 0 (`SDeviceProfile`) | +| 268 | 236B | Profil 1 | +| 504 | 236B | Profil 2 | + +### `SDeviceProfile` (236 Byte) + +| Offset (relativ) | Größe | Feld | +|---|---|---| +| 0 | 60B (20× 3B `SAction`) | MX-Button-Actions, Index 0-19 | +| 60 | 36B (4× 3× 3B `SAction`) | Encoder-Actions: je Encoder `sw`, `cw`, `ccw` | +| 96 | 20B | LED `r` je Button | +| 116 | 20B | LED `g` je Button | +| 136 | 20B | LED `b` je Button | +| 156 | 20B | LED `brightness` je Button | +| 176 | 20B | LED `anim` (Enum-Index) je Button | +| 196 | 40B (20× uint16 LE) | LED `period_ms` je Button | + +Die LED-Felder liegen **spaltenweise** (struct-of-arrays: alle 20 +`r`-Werte, dann alle 20 `g`-Werte, …), nicht verschachtelt pro Button — +`pack_profile()`/`unpack_profile()` bauen das entsprechend um. + +### `SMacroTable` (512 Byte, `versapad_protocol.MACRO_SIZE`) + +32 Slots × 8 Schritte × 2 Byte (`keycode`, `modifier`) = 512 Byte, flach +hintereinander: `offset = (slot_idx * 8 + step_idx) * 2`. + +### CRC16 + +`crc16()` in `versapad_protocol.py`: CRC-CCITT, Polynom `0x1021`, Init +`0xFFFF`, MSB-first, kein XOR-Out — exakt `nvm_config_crc()` aus der +Firmware (`nvm_config.cpp`). Wird über Byte 7-739 der Config berechnet +(alles nach Magic/Version/CRC-Header selbst). diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..73f76e7 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,136 @@ +# Serial-Wire-Protokoll + +Beschreibt, wie `versapad_serial.VersaPadLink` mit dem Board spricht. 1:1 +aus `VersaGUI/src/Protocol.cs` und `VersaMCU/doc/07_serial_protocol.md` +übernommen (siehe „Existing-Codebase-Regel“ in +[`AGENTS.md`](../AGENTS.md) — bei Änderungen gegen die Firmware-/ +C#-Quellen abgleichen, nicht aus dem Gedächtnis rekonstruieren). Für die +Bedeutung der übertragenen Bytes (Config/Makro-Layout) siehe +[`data-model.md`](data-model.md). + +## Transport + +- USB-CDC (virtueller COM-Port), 115200 Baud. +- Board-Erkennung über USB VID:PID `239A:0042` (`versapad_serial.find_port()` + durchsucht `serial.tools.list_ports.comports()`). +- Der Port ist **exklusiv** — siehe „Nebenläufigkeit“ in + [`architecture.md`](architecture.md). +- Jedes Paket ist exakt **8 Byte**: + + | Byte | Bedeutung | + |---|---| + | `[0]` | Command-/Event-ID | + | `[1]` | je nach Befehl: Chunk-Index, Chunk-Anzahl, oder ungenutzt | + | `[2..7]` | Payload, 6 Byte (`PAYLOAD_SIZE`) | + +## Befehle (Host → Board) + +| Konstante | Wert | Zweck | +|---|---|---| +| `CMD_READ_STATUS` | `0x06` | Leichtgewichtiger Status-Poll (aktives Profil) | +| `CMD_CONFIG_BEGIN` | `0x10` | Start eines Config-Schreibvorgangs | +| `CMD_CONFIG_DATA` | `0x11` | Ein Config-Datenpaket | +| `CMD_CONFIG_COMMIT` | `0x12` | Config committen (NVM-Schreiben nach Validierung) | +| `CMD_CONFIG_READ` | `0x13` | Komplette Config vom Board anfordern | +| `CMD_MACRO_BEGIN` | `0x20` | Start eines Makro-Schreibvorgangs | +| `CMD_MACRO_DATA` | `0x21` | Ein Makro-Datenpaket | +| `CMD_MACRO_COMMIT` | `0x22` | Makros committen | +| `CMD_MACRO_READ` | `0x23` | Komplette Makro-Tabelle vom Board anfordern | + +## Events (Board → Host) + +| Konstante | Wert | Zweck | +|---|---|---| +| `EVT_STATUS` | `0x86` | Antwort auf `CMD_READ_STATUS` | +| `EVT_CONFIG_ACK` | `0x90` | Config-Commit erfolgreich | +| `EVT_CONFIG_NACK` | `0x91` | Config-Commit abgelehnt (Validierung fehlgeschlagen) | +| `EVT_CONFIG_BEGIN` | `0x92` | Board beginnt, Config-Dump zu senden | +| `EVT_CONFIG_DATA` | `0x93` | Ein Config-Datenpaket (Antwort auf `CMD_CONFIG_READ`) | +| `EVT_CONFIG_END` | `0x94` | Config-Dump vollständig | +| `EVT_MACRO_ACK` | `0x95` | Makro-Commit erfolgreich | +| `EVT_MACRO_BEGIN` | `0x96` | Board beginnt, Makro-Dump zu senden | +| `EVT_MACRO_DATA` | `0x97` | Ein Makro-Datenpaket | +| `EVT_MACRO_END` | `0x98` | Makro-Dump vollständig | +| `EVT_MACRO_NACK` | `0x99` | Makro-Commit abgelehnt | + +## Ablauf: Lesen (`CONFIG_READ` / `MACRO_READ`) + +Genutzt von `read_full_config()` (740B, ⌈740/6⌉ = 124 Datenpakete) und +`read_macros()` (512B, ⌈512/6⌉ = 86 Datenpakete). Deadline 3s. + +``` +Host → [CMD_CONFIG_READ, 0, 0,0,0,0,0,0] +Board → [EVT_CONFIG_BEGIN, ...] +Board → [EVT_CONFIG_DATA, chunk_idx=0, <=6B Payload] +Board → [EVT_CONFIG_DATA, chunk_idx=1, <=6B Payload] + ... (124 Pakete insgesamt für Config, 86 für Makros) +Board → [EVT_CONFIG_END, ...] +``` + +`chunk_idx` (Byte `[1]`) bestimmt den Ziel-Offset im Empfangspuffer: +`offset = chunk_idx * 6`. Kommt `EVT_END`, ohne dass zuvor `EVT_BEGIN` +gesehen wurde, gilt das als Timeout (unvollständige/verpasste Antwort). + +## Ablauf: Schreiben (`CONFIG_BEGIN/DATA/COMMIT` bzw. `MACRO_*`) + +Genutzt von `write_full_config()`/`write_macros()`. Deadline 5s. Anzahl +Chunks maximal 255 (ein Byte) — bei größeren Blobs schlägt der Aufruf mit +`"too_large"` fehl, bevor überhaupt gesendet wird. + +``` +Host → [CMD_CONFIG_BEGIN, chunk_count, 0,0,0,0,0,0] +Host → [CMD_CONFIG_DATA, 0, <=6B Payload (zero-padded)] +Host → [CMD_CONFIG_DATA, 1, <=6B Payload] + ... (ein Paket je Chunk) +Host → [CMD_CONFIG_COMMIT, 0, 0,0,0,0,0,0] +Board → [EVT_CONFIG_ACK, ...] -- oder EVT_CONFIG_NACK bei fehlgeschlagener Validierung +``` + +**Sicherheitsnetz:** Die Firmware prüft Magic/Version/CRC (Config) bzw. +Keycode-Bereich (Makros) **vor** jedem NVM-Schreiben und antwortet sonst +nur mit NACK — ein fehlerhafter Schreibversuch kann das Board laut +Firmware-Design nicht in einen inkonsistenten Zustand bringen, siehe +`nvm_config_validate()` in den Firmware-Quellen. + +## Ablauf: Leichtgewichtiger Status-Poll (`READ_STATUS`) + +Genutzt von `read_active_profile()` für kontinuierliches Live-Sync-Polling +(alle 1,5s). Deadline 1s. + +``` +Host → [CMD_READ_STATUS, 0, 0,0,0,0,0,0] +Board → [EVT_STATUS, active_profile (0-2), ...] +``` + +Ein einzelnes Antwortpaket statt eines vollen `CONFIG_READ`-Dumps (124 +Pakete) — Letzterer blockiert die Firmware in `poll_vendor()` lang genug, +dass laufende LED-Pulse-Animationen sichtbar stottern. Ältere Firmware +ohne `CMD_READ_STATUS` antwortet einfach gar nicht → sauberer Timeout, +kein Absturz (siehe `AGENTS.md`, Eintrag „4215323“ in der Historie). + +## Verbindungsaufbau (`VersaPadLink._ensure_open()`) + +1. Board per VID/PID finden (`find_port()`). +2. `serial.Serial(port, 115200, timeout=0.5)` öffnen. +3. DTR auf `True` setzen, 0,2s warten (Board braucht kurz, bis es nach dem + Öffnen/DTR-Toggle wieder reagiert). +4. Input-Buffer leeren. + +**Wichtig:** `VersaPadLink` schließt die Verbindung nicht automatisch nach +einem Befehl — nur bei `serial.SerialException` (Fehlerfall) oder +explizitem `.close()`-Aufruf. Aufrufer, die den exklusiven Port nicht +dauerhaft blockieren wollen, müssen selbst schließen (siehe +`versapad_mcp_server.py`, das nach jedem Board-Tool-Aufruf `_link.close()` +in einem `finally`-Block aufruft). + +## Fehlerzustände (`VersaPadLink.last_error`) + +| Wert | Bedeutung | +|---|---| +| `None` | kein Fehler | +| `"no_pyserial"` | Paket `pyserial` nicht installiert | +| `"not_found"` | kein Gerät mit passender VID/PID gefunden | +| `"busy"` | Port gefunden, aber Öffnen fehlgeschlagen (von woanders gehalten — VersaGUI, Live-Sync, ein anderer Prozess) | +| `"timeout"` | keine (gültige) Antwort innerhalb der Deadline | +| `"nack"` | Board hat den Commit explizit abgelehnt (Validierung fehlgeschlagen) | +| `"too_large"` | zu sendender Blob braucht mehr als 255 Chunks | From 73c1135655e201ab51b3114363da8306d3c9f8a1 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:32:52 +0200 Subject: [PATCH 07/15] Map Tk key events to HID codes; show real macro sequences Datenschicht fuer zwei UI-Verbesserungen: tk_event_to_hid() uebersetzt einen Tk-Tastendruck (keysym, Windows-VK-Code, state) in HID-Keycode + Modifier-Bits -- Grundlage fuer die Tastendruck-Erkennung im Bearbeiten-Dialog. Bewusst nur aus Fenster-Events ableitbar, kein WinAPI-Hook. Bei gehaltenem Shift zaehlt zuerst der VK-Code, weil der keysym dann das verschobene Zeichen ist (deutsch: Shift+7 -> "slash", was sonst faelschlich auf Taste 0x38 zeigen wuerde). action_label()/annotate_profile() nehmen die Makrotabelle optional entgegen und zeigen dann "Makro 3: Strg+C -> Strg+V" statt "Makro (Slot 3)" -- ohne das sagt eine Makro-Belegung im Grid nichts darueber aus, was sie tut. macro_slot_choices()/macro_slot_from_choice() liefern dieselbe Ansicht fuer eine Slot-Auswahl. Co-Authored-By: Claude Opus 5 --- versapad_data.py | 191 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 8 deletions(-) diff --git a/versapad_data.py b/versapad_data.py index 45be287..cb41158 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -10,6 +10,10 @@ import os 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" @@ -108,6 +112,152 @@ _CONSUMER_NAMES = { 0x00B0: "Aufnahme", } +# ── Tastendruck-Erkennung (Tk-Events -> HID) ────────────────────────────── +# +# Bewusst OHNE WinAPI-Hook: die Zuordnung arbeitet nur mit dem, was Tk beim +# Fokus auf dem Bearbeiten-Dialog ohnehin liefert (keysym, keycode, state). +# Damit bleibt es ein normales Fenster-Tastaturereignis -- kein globaler +# Low-Level-Hook, der (wie die Fensterverstecktricks in anderen Projekten) +# AV-Fehlalarme provozieren koennte. Preis: erkannt wird nur, was das +# fokussierte Fenster ueberhaupt erreicht (Win+L, Strg+Alt+Entf und +# aehnliche vom System abgefangene Kombinationen also nicht). + +# Modifier-Tasten selbst sind nie das "Ziel" eines Captures, sie setzen nur +# Bits -- Win ist hier mit dabei (Einzeltaste erlaubt Win), Makro-Schritte +# filtern es spaeter selbst wieder raus (Firmware kennt dort kein Win). +TK_MODIFIER_KEYSYMS = { + "Control_L": 0x01, "Control_R": 0x01, + "Shift_L": 0x02, "Shift_R": 0x02, + "Alt_L": 0x04, "Alt_R": 0x04, "ISO_Level3_Shift": 0x04, + "Super_L": 0x08, "Super_R": 0x08, "Win_L": 0x08, "Win_R": 0x08, +} + +# event.state-Bits unter Windows-Tk (Fallback, falls ein KeyRelease der +# Modifier-Taste verloren ging -- z.B. weil der Dialog waehrenddessen den +# Fokus hatte/verlor). +TK_STATE_MODIFIER_BITS = [(0x0001, 0x02), (0x0004, 0x01), (0x20000, 0x04)] + +# Benannte Tasten: keysym ist layoutunabhaengig, deshalb erste Wahl. +_TK_NAMED_KEYSYMS = { + "Return": 0x28, "Escape": 0x29, "BackSpace": 0x2A, "Tab": 0x2B, + "ISO_Left_Tab": 0x2B, "space": 0x2C, "Caps_Lock": 0x39, + "Print": 0x46, "Scroll_Lock": 0x47, "Pause": 0x48, "Cancel": 0x48, + "Insert": 0x49, "Home": 0x4A, "Prior": 0x4B, "Delete": 0x4C, + "End": 0x4D, "Next": 0x4E, + "Right": 0x4F, "Left": 0x50, "Down": 0x51, "Up": 0x52, + "Num_Lock": 0x53, "KP_Divide": 0x54, "KP_Multiply": 0x55, + "KP_Subtract": 0x56, "KP_Add": 0x57, "KP_Enter": 0x58, + "KP_1": 0x59, "KP_End": 0x59, "KP_2": 0x5A, "KP_Down": 0x5A, + "KP_3": 0x5B, "KP_Next": 0x5B, "KP_4": 0x5C, "KP_Left": 0x5C, + "KP_5": 0x5D, "KP_Begin": 0x5D, "KP_6": 0x5E, "KP_Right": 0x5E, + "KP_7": 0x5F, "KP_Home": 0x5F, "KP_8": 0x60, "KP_Up": 0x60, + "KP_9": 0x61, "KP_Prior": 0x61, "KP_0": 0x62, "KP_Insert": 0x62, + "KP_Decimal": 0x63, "KP_Delete": 0x63, + "Menu": 0x65, "App": 0x65, +} +for _i in range(12): + _TK_NAMED_KEYSYMS[f"F{_i + 1}"] = 0x3A + _i + +# Zeichentasten: HID-Keycodes sind US-Positionen, die keysyms kommen aber vom +# aktiven Windows-Layout. Beide Belegungen (US + Deutsch) stehen deshalb +# nebeneinander. Einzige echte Kollision ist "minus" (US-Layout: Taste neben +# der 0 = 0x2D, deutsches Layout: Taste neben dem Punkt = 0x38) -- dort +# gewinnt die US-Position, damit die Erkennung dieselbe Naeherung liefert wie +# die Dropdown-Beschriftung (siehe Layout-Vorbehalt bei tk_event_to_hid). +_TK_CHAR_KEYSYMS = { + # US-Layout + "minus": 0x2D, "equal": 0x2E, "bracketleft": 0x2F, "bracketright": 0x30, + "backslash": 0x31, "semicolon": 0x33, "apostrophe": 0x34, + "quoteright": 0x34, "grave": 0x35, "quoteleft": 0x35, + "comma": 0x36, "period": 0x37, "slash": 0x38, + # Deutsches Layout -- gleiche physische Tasten, andere Zeichen + "ssharp": 0x2D, "acute": 0x2E, "dead_acute": 0x2E, + "udiaeresis": 0x2F, "plus": 0x30, "numbersign": 0x32, + "odiaeresis": 0x33, "adiaeresis": 0x34, + "asciicircum": 0x35, "dead_circumflex": 0x35, + "less": 0x64, "greater": 0x64, "bar": 0x64, +} + +# Windows-Virtual-Key-Codes (event.keycode) als Rueckfallebene, wenn der +# keysym nichts hergibt -- z.B. wenn Shift/AltGr das Zeichen veraendert +# ("exclam" statt "1"). Nur die Bereiche, die layoutstabil sind. +_WIN_VK_TO_HID = { + 0x08: 0x2A, 0x09: 0x2B, 0x0D: 0x28, 0x13: 0x48, 0x14: 0x39, 0x1B: 0x29, + 0x20: 0x2C, 0x21: 0x4B, 0x22: 0x4E, 0x23: 0x4D, 0x24: 0x4A, + 0x25: 0x50, 0x26: 0x52, 0x27: 0x4F, 0x28: 0x51, + 0x2C: 0x46, 0x2D: 0x49, 0x2E: 0x4C, + 0x30: 0x27, 0x6A: 0x55, 0x6B: 0x57, 0x6D: 0x56, 0x6E: 0x63, 0x6F: 0x54, + 0x90: 0x53, 0x91: 0x47, 0x5D: 0x65, +} +for _i in range(9): + _WIN_VK_TO_HID[0x31 + _i] = 0x1E + _i # '1'-'9' +for _i in range(26): + _WIN_VK_TO_HID[0x41 + _i] = 0x04 + _i # 'A'-'Z' +for _i in range(12): + _WIN_VK_TO_HID[0x70 + _i] = 0x3A + _i # F1-F12 +_WIN_VK_TO_HID[0x60] = 0x62 # Numpad 0 +for _i in range(9): + _WIN_VK_TO_HID[0x61 + _i] = 0x59 + _i # Numpad 1-9 + + +def tk_keysym_to_hid(keysym): + """Tk-keysym -> HID-Keycode, oder None wenn nicht zuordenbar. + Modifier-Tasten liefern bewusst None (sie sind kein Capture-Ziel).""" + if keysym in TK_MODIFIER_KEYSYMS: + return None + if len(keysym) == 1: + upper = keysym.upper() + if "A" <= upper <= "Z": + return 0x04 + (ord(upper) - ord("A")) + if "1" <= keysym <= "9": + return 0x1E + (ord(keysym) - ord("1")) + if keysym == "0": + return 0x27 + if keysym in _TK_NAMED_KEYSYMS: + return _TK_NAMED_KEYSYMS[keysym] + return _TK_CHAR_KEYSYMS.get(keysym) + + +def tk_event_to_hid(keysym, keycode, state, held_modifier=0): + """Ein Tk-KeyPress -> (hid_keycode, modifier_bits) oder None, wenn die + Taste sich nicht auf einen HID-Keycode abbilden laesst (dann im Dialog + einfach weiter warten statt Muell zu speichern). + + keysym/keycode/state kommen direkt aus dem Tk-Event, held_modifier ist + das vom Dialog selbst mitgefuehrte Bitfeld der aktuell gedrueckten + Modifier (siehe TK_MODIFIER_KEYSYMS) -- beides wird verodert, damit ein + verlorenes KeyRelease die Erkennung nicht verfaelscht. + + Bei gehaltenem Shift zaehlt zuerst der Virtual-Key-Code: der keysym ist + dann das *verschobene* Zeichen (deutsches Layout: Shift+7 -> "slash", + was sonst faelschlich auf die Taste 0x38 zeigen wuerde), der VK-Code + bleibt derselbe wie ohne Shift. + + Layout-Vorbehalt (derselbe wie bei den Dropdown-Labels, siehe + _SPECIAL_KEYS): HID-Keycodes sind US-Tastenpositionen, Tk liefert aber + nur Zeichen/VK-Codes des aktiven Layouts -- die physische Position + (Scan-Code) waere dafuer noetig und ist ohne WinAPI nicht zu bekommen. + Auf deutschem Layout landen Y und Z deshalb vertauscht auf dem Board. + Das Ergebnis ist im Dialog sichtbar (Dropdown + Modifier-Checkboxen + werden gefuellt) und laesst sich dort von Hand korrigieren.""" + shifted = bool(state & 0x0001) or bool(held_modifier & 0x02) + if shifted: + code = _WIN_VK_TO_HID.get(keycode) + if code is None: + code = tk_keysym_to_hid(keysym) + else: + code = tk_keysym_to_hid(keysym) + if code is None: + code = _WIN_VK_TO_HID.get(keycode) + if code is None: + return None + modifier = held_modifier + for bit, mod in TK_STATE_MODIFIER_BITS: + if state & bit: + modifier |= mod + return code, modifier + + ANIM_LABELS = { "Static": "● statisch", "Blink": "◎ blinkend", @@ -172,6 +322,19 @@ def macro_slot_label(steps): return " → ".join(macro_step_label(s) for s in steps) +def macro_slot_choices(macros): + """["Slot 0 — Strg+C → Strg+V", ...] fuer die Slot-Auswahl im + Bearbeiten-Dialog: alle 32 Slots samt Inhalt auf einen Blick, statt sich + per Spinbox durch die Tabelle zu klicken.""" + return [f"Slot {slot} — {macro_slot_label(macros[slot] if slot < len(macros) else [])}" + for slot in range(MACRO_SLOTS)] + + +def macro_slot_from_choice(choice): + """Umkehrung von macro_slot_choices(): "Slot 7 — ..." -> 7.""" + return int(choice.split("—")[0].strip().split()[-1]) + + def hid_key_label(data): """data = keycode | (modifier << 8) -> z.B. 'Strg+S'""" keycode = data & 0xFF @@ -185,8 +348,15 @@ def consumer_label(data): return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}") -def action_label(action): - """Menschenlesbarer Text für eine DeviceAction {type, data}.""" +def action_label(action, macros=None): + """Menschenlesbarer Text für eine DeviceAction {type, data}. + + macros: optional die globale 32-Slot-Makrotabelle (siehe + versapad_combined). Ist sie da, zeigt ein Makro die tatsaechliche + Tastenfolge statt nur der Slot-Nummer -- ohne die sagt "Makro (Slot 7)" + im Hauptfenster nichts darueber aus, was die Taste eigentlich tut. + Ohne macros (z.B. bei den Einzel-JSONs aus CONFIG_PATHS, die gar keine + Makro-Schritte enthalten) bleibt es beim Slot-Text.""" t = action.get("type") d = action.get("data", 0) if t == "None": @@ -196,6 +366,8 @@ def action_label(action): if t == "HidConsumer": return consumer_label(d) if t == "Macro": + if macros is not None and 0 <= d < len(macros): + return f"Makro {d}: {macro_slot_label(macros[d])}" return f"Makro (Slot {d})" if t == "ProfileSwitch": if d in (0xFFFF, 0x00FF): @@ -219,20 +391,23 @@ def button_grid_position(index): return col, row -def annotate_profile(cfg): +def annotate_profile(cfg, macros=None): """Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck) - oder aus dem kombinierten Programmiermodus-State kommt.""" + oder aus dem kombinierten Programmiermodus-State kommt. + + macros wird an action_label() durchgereicht, damit Makro-Belegungen + ihre echte Tastenfolge zeigen statt nur der Slot-Nummer.""" for b in cfg["buttons"]: - b["label"] = action_label(b["action"]) + b["label"] = action_label(b["action"], macros) b["note"] = b["action"].get("note", "") b["col"], b["row"] = button_grid_position(b["index"]) for e in cfg["encoders"]: - e["sw_label"] = action_label(e["sw"]) + e["sw_label"] = action_label(e["sw"], macros) e["sw_note"] = e["sw"].get("note", "") - e["cw_label"] = action_label(e["cw"]) + e["cw_label"] = action_label(e["cw"], macros) e["cw_note"] = e["cw"].get("note", "") - e["ccw_label"] = action_label(e["ccw"]) + e["ccw_label"] = action_label(e["ccw"], macros) e["ccw_note"] = e["ccw"].get("note", "") return cfg From f6f4edbdde4f548cfc8562cd24133ec705fa8b53 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:33:08 +0200 Subject: [PATCH 08/15] Rework the edit dialogs: key capture, placement, shortcuts, quick colours Der Bearbeiten-Dialog war der langsamste Teil der Bedienung. Fuenf Punkte, alle auf gemeinsamer Basis _ModalDialog: - Oeffnet mittig ueber dem aufrufenden Fenster statt bei +0+0 in der Bildschirmecke -- bei 20 Tasten hintereinander wanderte der Blick sonst jedes Mal dorthin. Aufbau erfolgt withdraw()'t, damit er nicht kurz in der Ecke aufblitzt, bevor er springt. - Enter = OK, Escape = Abbrechen. Die Sequenzen sind nicht einzeln gebunden, sondern werden aus dem -Handler verteilt -- waehrend einer laufenden Tastendruck-Aufnahme muessen sie als normale Tasten erfassbar bleiben. - "Taste druecken" erfasst Taste + Modifier per Tastendruck, Makro-Schritte zusaetzlich als Folge am Stueck ("Folge aufnehmen"). _KeyCapture haengt an einem Label, nicht an einem Button: Buttons reagieren per Klassen-Binding selbst auf Leertaste/Enter und wuerden die Aufnahme mit ihrem eigenen Klick beantworten. Das Dropdown bleibt daneben stehen, damit ein falsch erkanntes Zeichen (US-Layout-Naeherung) korrigierbar ist. - Die Makro-Slot-Auswahl ist ein Dropdown mit allen 32 Slots samt Inhalt statt einer Spinbox, durch die man sich klicken musste. - Zwoelf Grundfarben direkt in der LED-Zeile, der System-Farbdialog nur noch fuer den Rest. Die Panel-Hoehe ist fix (grid_propagate(False)), sonst springt die Fenstergroesse bei jedem Typwechsel und OK/Abbrechen wandern unter dem Mauszeiger weg. run() gibt den Grab an einen aufrufenden Dialog zurueck -- grab_release() des verschachtelten MacroStepsDialog nimmt ihn sonst mit. Co-Authored-By: Claude Opus 5 --- action_dialog.py | 509 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 392 insertions(+), 117 deletions(-) diff --git a/action_dialog.py b/action_dialog.py index 6a5ccf2..96eb2fb 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -1,12 +1,19 @@ """ Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu -VersaGUI/src/ActionDialog.cs, aber in Tkinter und ohne Tastendruck-Capture -(kein WinAPI-Hook -- stattdessen Tasten-Auswahl per Dropdown, siehe -versapad_data.hid_key_choices()). +VersaGUI/src/ActionDialog.cs, in Tkinter. ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons). MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur echten GUI ("8 Step-Buttons + je Strg/Shift/Alt-Checkboxen", kein Win). + +Tastendruck-Erkennung (_KeyCapture): Tasten lassen sich statt per Dropdown +auch einfach druecken. Das laeuft ueber ganz normale Tk-Fokus-Events des +Dialogfensters -- KEIN globaler WinAPI-Tastaturhook (siehe Domaenenregel +"keine Selbstversteck-/Hook-Fenstertricks"). Konsequenz: erkannt wird nur, +was das fokussierte Fenster erreicht, und die HID-Zuordnung ist dieselbe +US-Layout-Naeherung wie bei den Dropdown-Labels (siehe +versapad_data.tk_event_to_hid). Das Dropdown bleibt deshalb daneben stehen, +damit ein falsch erkanntes Zeichen von Hand korrigiert werden kann. """ import tkinter as tk from tkinter import colorchooser, ttk @@ -18,6 +25,7 @@ BG2 = "#14161b" TEXT = "#e8e8ec" TEXT_DIM = "#8a8d98" ACCENT = "#3a6ff0" +CAPTURE_BG = "#c04a2a" TYPE_CHOICES = [ ("None", "Keine"), @@ -34,8 +42,100 @@ PROFILE_SWITCH_CHOICES = [ ("Profil 3", 2), ] +# Schnellzugriff direkt in der LED-Zeile -- der Umweg ueber den +# System-Farbdialog lohnt sich fuer die paar Standardfarben nicht. +# ("aus" ist Schwarz: LED bleibt dunkel, die Firmware kennt kein +# separates Enable-Flag.) +QUICK_COLORS = [ + ("aus", (0, 0, 0)), + ("weiß", (255, 255, 255)), + ("rot", (255, 0, 0)), + ("orange", (255, 80, 0)), + ("gelb", (255, 200, 0)), + ("grün", (0, 255, 0)), + ("türkis", (0, 255, 160)), + ("cyan", (0, 200, 255)), + ("blau", (0, 64, 255)), + ("violett", (128, 0, 255)), + ("magenta", (255, 0, 200)), + ("rosa", (255, 128, 128)), +] + +MOD_CHECKBOXES = (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08)) +MACRO_MOD_CHECKBOXES = MOD_CHECKBOXES[:3] # Firmware kennt in Makros kein Win + + +class _KeyCapture: + """Macht ein Label zum "Taste drücken"-Schalter: solange er aktiv ist, + wandert jeder Tastendruck im Dialog nicht ins Widget, sondern durch + versapad_data.tk_event_to_hid() in ein (keycode, modifier)-Paar. + + on_result(keycode, modifier) wird im Tk-Main-Thread aufgerufen. + repeat=True laesst die Aufnahme nach einem Treffer weiterlaufen (fuer + "Folge aufnehmen" im Makro-Dialog), sonst endet sie nach dem ersten. + allow_win=False filtert das Win-Bit weg (Makro-Schritte). + """ + + def __init__(self, dialog, label, on_result, idle_text, allow_win=True, repeat=False): + self.dialog = dialog + self.label = label + self.on_result = on_result + self.idle_text = idle_text + self.allow_win = allow_win + self.repeat = repeat + self.active = False + self._held = 0 + label.configure(text=idle_text, cursor="hand2") + label.bind("", lambda e: self.toggle()) + + def toggle(self): + self.stop() if self.active else self.start() + + def start(self): + self.active = True + self._held = 0 + self.dialog.begin_capture(self) + self.label.configure(text="… jetzt drücken (Klick = Stopp)", bg=CAPTURE_BG, fg="#fff") + self.label.focus_set() + + def stop(self): + if not self.active: + return + self.active = False + self._held = 0 + self.dialog.end_capture(self) + self.label.configure(text=self.idle_text, bg=BG, fg=TEXT) + + def handle_key(self, event, pressed): + """Vom Dialog aufgerufen, solange diese Aufnahme aktiv ist. + Gibt immer "break" zurueck -- waehrend der Aufnahme darf kein + Tastendruck als Text im Notizfeld oder als Dialog-Shortcut landen.""" + mod = vp.TK_MODIFIER_KEYSYMS.get(event.keysym) + if mod is not None: + # Modifier selbst sind nie das Ziel, sie sammeln nur Bits -- + # ein KeyRelease sieht der Dialog nicht immer (Fokuswechsel), + # das state-Feld im Treffer-Event faengt das ab. + self._held = (self._held | mod) if pressed else (self._held & ~mod) + return "break" + if not pressed: + return "break" + hit = vp.tk_event_to_hid(event.keysym, event.keycode, event.state, self._held) + if hit is None: + return "break" # nicht zuordenbar -> einfach weiter warten + keycode, modifier = hit + if not self.allow_win: + modifier &= ~0x08 + if not self.repeat: + self.stop() + self.on_result(keycode, modifier) + return "break" + class _ModalDialog(tk.Toplevel): + """Basis: oeffnet mittig ueber dem aufrufenden Fenster (nicht in der + Bildschirmecke), Enter = OK, Escape = Abbrechen, und verteilt + Tastendruecke an eine ggf. laufende _KeyCapture.""" + def __init__(self, parent, title): super().__init__(parent) self.title(title) @@ -43,18 +143,110 @@ class _ModalDialog(tk.Toplevel): self.transient(parent) self.resizable(False, False) self.cancelled = True + self._active_capture = None + # Erst unsichtbar aufbauen, in run() positionieren und dann zeigen -- + # sonst blitzt der Dialog kurz in der linken oberen Bildschirmecke auf. + self.withdraw() + self.bind("", self._on_key_press) + self.bind("", self._on_key_release) + self.protocol("WM_DELETE_WINDOW", lambda: self._finish(True)) + + # ── Tastendruck-Aufnahme ────────────────────────────────────────── + + def begin_capture(self, capture): + if self._active_capture is not None and self._active_capture is not capture: + self._active_capture.stop() + self._active_capture = capture + + def end_capture(self, capture): + if self._active_capture is capture: + self._active_capture = None + + def _on_key_press(self, event): + if self._active_capture is not None: + return self._active_capture.handle_key(event, pressed=True) + if event.keysym in ("Return", "KP_Enter"): + self._on_ok() + return "break" + if event.keysym == "Escape": + self._finish(True) + return "break" + return None + + def _on_key_release(self, event): + if self._active_capture is not None: + return self._active_capture.handle_key(event, pressed=False) + return None + + def _on_ok(self): + """Von Enter und vom OK-Knopf -- Unterklassen ueberschreiben das.""" + self._finish(False) def _finish(self, cancelled): + if self._active_capture is not None: + self._active_capture.stop() self.cancelled = cancelled self.grab_release() self.destroy() + # ── Positionierung + Ablauf ─────────────────────────────────────── + + def _center_on_parent(self): + """Mittig ueber dem Elternfenster, aber immer vollstaendig auf dem + Bildschirm. Ohne das oeffnet Tk jeden Toplevel bei +0+0, also bei + jeder bearbeiteten Taste erneut in der Bildschirmecke -- weit weg + vom Hauptfenster, in dem gerade geklickt wurde.""" + 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 + (pw - width) // 2 + y = py + (ph - height) // 3 # etwas oberhalb der Mitte wirkt ruhiger + x = max(0, min(x, self.winfo_screenwidth() - width)) + y = max(0, min(y, self.winfo_screenheight() - height)) + self.geometry(f"+{x}+{y}") + def run(self): self.update_idletasks() + self._center_on_parent() + self.deiconify() self.grab_set() + self.focus_force() self.wait_window(self) + # Ein verschachtelter Dialog (Makro-Schritte aus dem Action-Dialog) + # nimmt beim Schliessen den Grab mit -- ohne Rueckgabe waere der + # aufrufende Dialog danach nicht mehr modal. + parent = self.master + if isinstance(parent, _ModalDialog) and parent.winfo_exists(): + parent.grab_set() + parent.focus_force() return not self.cancelled + # ── gemeinsame kleine Bausteine ─────────────────────────────────── + + def _capture_label(self, parent, on_result, idle_text="⌨ Taste drücken", + allow_win=True, repeat=False): + """Bewusst ein Label statt tk.Button: Buttons reagieren per + Klassen-Binding selbst auf Leertaste/Enter und wuerden die laufende + Aufnahme mit ihrem eigenen Klick beantworten.""" + label = tk.Label(parent, bg=BG, fg=TEXT, font=("Segoe UI", 9), + padx=10, pady=3, relief="flat") + _KeyCapture(self, label, on_result, idle_text, allow_win=allow_win, repeat=repeat) + return label + + def _button_row(self, row, columnspan, ok_text="OK"): + btns = tk.Frame(self, bg=BG2) + btns.grid(row=row, column=0, columnspan=columnspan, pady=14) + tk.Button(btns, text=f"{ok_text} (Enter)", command=self._on_ok, bg=ACCENT, fg="#fff", + activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) + tk.Button(btns, text="Abbrechen (Esc)", command=lambda: self._finish(True), bg=BG, + fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + return btns + class MacroStepsDialog(_ModalDialog): """Ergebnis in self.steps nach run()==True.""" @@ -68,43 +260,81 @@ class MacroStepsDialog(_ModalDialog): self._code_by_name = {name: code for code, name in key_choices} names = ["(leer)"] + [name for _, name in key_choices] - tk.Label(self, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.", - bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).grid( - row=0, column=0, columnspan=5, sticky="w", padx=12, pady=(12, 6)) + head = tk.Frame(self, bg=BG2) + head.grid(row=0, column=0, columnspan=6, sticky="w", padx=12, pady=(12, 6)) + tk.Label(head, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w") + tk.Label(head, text="Einzeln erfassen mit ⌨ je Zeile, oder die ganze Folge am Stück " + "aufnehmen. Win ist in Makros nicht möglich (Firmware).", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w") + + tools = tk.Frame(self, bg=BG2) + tools.grid(row=1, column=0, columnspan=6, sticky="w", padx=12, pady=(0, 6)) + self._record_label = tk.Label(tools, bg=BG, fg=TEXT, font=("Segoe UI", 9), + padx=10, pady=3) + self._record = _KeyCapture(self, self._record_label, self._on_record_step, + "⏺ Folge aufnehmen", allow_win=False, repeat=True) + self._record_label.pack(side="left") + tk.Button(tools, text="Alle leeren", command=self._clear_all, bg=BG, fg=TEXT, + activebackground=BG, relief="flat", padx=10).pack(side="left", padx=6) + self._record_hint = tk.Label(tools, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)) + self._record_hint.pack(side="left", padx=6) self._key_vars = [] self._mod_vars = [] for i in range(8): - r = i + 1 + r = i + 2 tk.Label(self, text=f"{i + 1}.", bg=BG2, fg=TEXT_DIM, - font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e") + font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e") key_var = tk.StringVar(value="(leer)") - combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly") - combo.grid(row=r, column=1, padx=4, pady=2) + ttk.Combobox(self, textvariable=key_var, values=names, width=16, + state="readonly").grid(row=r, column=1, padx=4, pady=2) self._key_vars.append(key_var) mods = {} - for j, label in enumerate(("Strg", "Shift", "Alt")): + for j, (label, _bit) in enumerate(MACRO_MOD_CHECKBOXES): v = tk.BooleanVar(value=False) tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT, - selectcolor=BG, activebackground=BG2, activeforeground=TEXT, - font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, sticky="w") + selectcolor=BG, activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, + sticky="w") mods[label] = v self._mod_vars.append(mods) - for i, step in enumerate(steps[:8]): - self._key_vars[i].set(self._name_by_code.get(step["keycode"], "(leer)")) - self._mod_vars[i]["Strg"].set(bool(step["modifier"] & 0x01)) - self._mod_vars[i]["Shift"].set(bool(step["modifier"] & 0x02)) - self._mod_vars[i]["Alt"].set(bool(step["modifier"] & 0x04)) + self._capture_label( + self, lambda code, mod, idx=i: self._apply_step(idx, code, mod), + idle_text="⌨", allow_win=False, + ).grid(row=r, column=5, padx=(6, 12), pady=2) - btns = tk.Frame(self, bg=BG2) - btns.grid(row=9, column=0, columnspan=5, pady=12) - tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff", - activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) - tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG, - fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + for i, step in enumerate(steps[:8]): + self._apply_step(i, step["keycode"], step["modifier"]) + + self._button_row(row=10, columnspan=6) + + def _apply_step(self, index, keycode, modifier): + self._key_vars[index].set(self._name_by_code.get(keycode, "(leer)")) + for label, bit in MACRO_MOD_CHECKBOXES: + self._mod_vars[index][label].set(bool(modifier & bit)) + + def _clear_all(self): + for i in range(8): + self._apply_step(i, 0, 0) + self._record_hint.configure(text="") + + def _on_record_step(self, keycode, modifier): + """Aufnahmemodus: jeder Tastendruck fuellt den naechsten freien + Schritt. Nach dem 8. stoppt die Aufnahme selbst (mehr Schritte + kennt SMacroTable nicht).""" + free = next((i for i in range(8) if self._key_vars[i].get() == "(leer)"), None) + if free is None: + self._record.stop() + self._record_hint.configure(text="alle 8 Schritte belegt") + return + self._apply_step(free, keycode, modifier) + self._record_hint.configure(text=f"Schritt {free + 1} aufgenommen") + if free == 7: + self._record.stop() def _on_ok(self): steps = [] @@ -112,11 +342,8 @@ class MacroStepsDialog(_ModalDialog): name = self._key_vars[i].get() if name == "(leer)": break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren - modifier = ( - (0x01 if self._mod_vars[i]["Strg"].get() else 0) | - (0x02 if self._mod_vars[i]["Shift"].get() else 0) | - (0x04 if self._mod_vars[i]["Alt"].get() else 0) - ) + modifier = sum(bit for label, bit in MACRO_MOD_CHECKBOXES + if self._mod_vars[i][label].get()) steps.append({"keycode": self._code_by_name[name], "modifier": modifier}) self.steps = steps self._finish(False) @@ -140,8 +367,8 @@ class ActionEditDialog(_ModalDialog): note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1 tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") self._note_var = tk.StringVar(value=action.get("note", "")) - tk.Entry(note_row, textvariable=self._note_var, width=36, bg=BG, fg=TEXT, - insertbackground=TEXT, relief="flat").pack(side="left", padx=6) + tk.Entry(note_row, textvariable=self._note_var, width=52, bg=BG, fg=TEXT, + insertbackground=TEXT, relief="flat").pack(side="left", padx=6) tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid( row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1 @@ -151,13 +378,15 @@ class ActionEditDialog(_ModalDialog): type_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12); row += 1 for value, label in TYPE_CHOICES: tk.Radiobutton(type_frame, text=label, variable=self._type_var, value=value, - command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG, - activebackground=BG2, activeforeground=TEXT, - font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) + command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG, + activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) - self._panel_row = row - self._panel = tk.Frame(self, bg=BG2) - self._panel.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=8) + # Feste Groesse: sonst springt die Fensterhoehe (und damit die + # Position von OK/Abbrechen) bei jedem Typwechsel. + self._panel = tk.Frame(self, bg=BG2, width=600, height=90) + self._panel.grid(row=row, column=0, columnspan=3, sticky="ew", padx=12, pady=8) + self._panel.grid_propagate(False) row += 1 # HidKey-Panel @@ -174,17 +403,16 @@ class ActionEditDialog(_ModalDialog): self._consumer_var = tk.StringVar() # Macro-Panel - self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0) - self._macro_preview_var = tk.StringVar() + self._macro_slot = action["data"] if action["type"] == "Macro" else 0 + self._macro_choice_var = tk.StringVar() # ProfileSwitch-Panel self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0]) if action["type"] == "HidKey": keycode = action["data"] & 0xFF - modifier = (action["data"] >> 8) & 0xFF self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)")) - self._hidkey_mods_init = modifier + self._hidkey_mods_init = (action["data"] >> 8) & 0xFF else: self._hidkey_key_var.set("(leer)") self._hidkey_mods_init = 0 @@ -206,45 +434,67 @@ class ActionEditDialog(_ModalDialog): self._led_color = (self._led["r"], self._led["g"], self._led["b"]) if self._led else (80, 40, 0) if self._led is not None: - led_frame = tk.Frame(self, bg=BG2) - led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)); row += 1 - tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).pack(anchor="w") - - color_row = tk.Frame(led_frame, bg=BG2) - color_row.pack(anchor="w", pady=4) - self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4) - self._swatch.pack(side="left") - tk.Button(color_row, text="Farbe wählen...", command=self._pick_color, bg=BG, - fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) - - anim_row = tk.Frame(led_frame, bg=BG2) - anim_row.pack(anchor="w", pady=4) - tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()), - width=12, state="readonly").pack(side="left", padx=6) - tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left", padx=(12, 0)) - tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var, - width=7).pack(side="left", padx=6) - - btns = tk.Frame(self, bg=BG2) - btns.grid(row=row, column=0, columnspan=3, pady=14) - tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff", - activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) - tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG, - fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + row = self._build_led_panel(row) + self._button_row(row=row, columnspan=3) self._on_type_change() + # ── LED ─────────────────────────────────────────────────────────── + + def _build_led_panel(self, row): + led_frame = tk.Frame(self, bg=BG2) + led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)) + tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9, "bold")).pack(anchor="w") + + color_row = tk.Frame(led_frame, bg=BG2) + color_row.pack(anchor="w", pady=4) + self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4) + self._swatch.pack(side="left") + self._hex_label = tk.Label(color_row, text=self._hex(), bg=BG2, fg=TEXT_DIM, + font=("Consolas", 9), width=9) + self._hex_label.pack(side="left", padx=(6, 6)) + for name, rgb in QUICK_COLORS: + chip = tk.Label(color_row, bg="#%02x%02x%02x" % rgb, width=2, height=1, + cursor="hand2", relief="flat", + highlightbackground="#3a3d47", highlightthickness=1) + chip.pack(side="left", padx=1) + chip.bind("", lambda e, c=rgb: self._set_color(c)) + # Tk kennt keine Tooltips -- der Farbname landet in der Zeile darunter. + chip.bind("", lambda e, n=name: self._color_hint.configure(text=n)) + chip.bind("", lambda e: self._color_hint.configure(text="")) + tk.Button(color_row, text="mehr…", command=self._pick_color, bg=BG, fg=TEXT, + activebackground=BG, relief="flat", padx=8).pack(side="left", padx=(8, 0)) + self._color_hint = tk.Label(led_frame, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)) + self._color_hint.pack(anchor="w") + + anim_row = tk.Frame(led_frame, bg=BG2) + anim_row.pack(anchor="w", pady=4) + tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).pack(side="left") + ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()), + width=12, state="readonly").pack(side="left", padx=6) + tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).pack(side="left", padx=(12, 0)) + tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var, + width=7).pack(side="left", padx=6) + return row + 1 + def _hex(self): r, g, b = self._led_color return f"#{r:02x}{g:02x}{b:02x}" + def _set_color(self, rgb): + self._led_color = tuple(rgb) + self._swatch.configure(bg=self._hex()) + self._hex_label.configure(text=self._hex()) + def _pick_color(self): - result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe") + result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe", parent=self) if result and result[0]: - r, g, b = (int(c) for c in result[0]) - self._led_color = (r, g, b) - self._swatch.configure(bg=self._hex()) + self._set_color(tuple(int(c) for c in result[0])) + + # ── Action-Panels ───────────────────────────────────────────────── def _on_type_change(self): for w in self._panel.winfo_children(): @@ -252,80 +502,105 @@ class ActionEditDialog(_ModalDialog): t = self._type_var.get() if t == "HidKey": - row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - self._hidkey_mods = {} - for label, bit in (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08)): - v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit)) - tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG, - activebackground=BG2, activeforeground=TEXT, - font=("Segoe UI", 9)).pack(side="left", padx=(0, 6)) - self._hidkey_mods[bit] = v - row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4) - tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - names = ["(leer)"] + [n for _, n in vp.hid_key_choices()] - ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names, - width=18, state="readonly").pack(side="left", padx=6) - + self._build_hidkey_panel() elif t == "HidConsumer": row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - names = [n for _, n in vp.consumer_choices()] - ttk.Combobox(row1, textvariable=self._consumer_var, values=names, - width=20, state="readonly").pack(side="left", padx=6) - + tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).pack(side="left") + ttk.Combobox(row1, textvariable=self._consumer_var, + values=[n for _, n in vp.consumer_choices()], + width=20, state="readonly").pack(side="left", padx=6) elif t == "Macro": - row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - tk.Label(row1, text="Slot (0-31):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - tk.Spinbox(row1, from_=0, to=31, textvariable=self._macro_slot_var, width=5, - command=self._refresh_macro_preview).pack(side="left", padx=6) - tk.Button(row1, text="Schritte bearbeiten...", command=self._edit_macro_steps, - bg=BG, fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) - row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=(4, 0)) - tk.Label(row2, textvariable=self._macro_preview_var, bg=BG2, fg=TEXT_DIM, - font=("Segoe UI", 8), wraplength=340, justify="left").pack(anchor="w") - self._refresh_macro_preview() - + self._build_macro_panel() elif t == "ProfileSwitch": row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) tk.Label(row1, text="Ziel:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") ttk.Combobox(row1, textvariable=self._profile_switch_var, - values=[n for n, _ in PROFILE_SWITCH_CHOICES], - width=22, state="readonly").pack(side="left", padx=6) + values=[n for n, _ in PROFILE_SWITCH_CHOICES], + width=22, state="readonly").pack(side="left", padx=6) self.update_idletasks() - def _refresh_macro_preview(self): - slot = self._macro_slot_var.get() - steps = self._macros[slot] if 0 <= slot < len(self._macros) else [] - self._macro_preview_var.set(f"Slot {slot}: {vp.macro_slot_label(steps)}") + def _build_hidkey_panel(self): + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + self._hidkey_mods = {} + for label, bit in MOD_CHECKBOXES: + v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit)) + tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG, + activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 6)) + self._hidkey_mods[bit] = v + + row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4) + tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + names = ["(leer)"] + [n for _, n in vp.hid_key_choices()] + ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names, + width=18, state="readonly").pack(side="left", padx=6) + self._capture_label(row2, self._apply_captured_key).pack(side="left", padx=(8, 0)) + + tk.Label(self._panel, text="Erkennung läuft über das Dialogfenster, nicht über einen " + "System-Hook: Win+L o.ä. fängt Windows selbst ab.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w") + + def _apply_captured_key(self, keycode, modifier): + self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)")) + self._hidkey_mods_init = modifier + for bit, var in self._hidkey_mods.items(): + var.set(bool(modifier & bit)) + + def _build_macro_panel(self): + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + tk.Label(row1, text="Slot:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + # Dropdown listet alle 32 Slots MIT Inhalt -- vorher musste man sich + # per Spinbox durch die Tabelle klicken, um ein Makro wiederzufinden. + self._macro_combo = ttk.Combobox(row1, textvariable=self._macro_choice_var, + values=vp.macro_slot_choices(self._macros), + width=52, state="readonly") + self._macro_combo.pack(side="left", padx=6) + self._macro_combo.bind("<>", self._on_macro_slot_selected) + tk.Button(row1, text="Schritte bearbeiten…", command=self._edit_macro_steps, + bg=BG, fg=TEXT, activebackground=BG, relief="flat", + padx=8).pack(side="left", padx=8) + + tk.Label(self._panel, text="Slots sind global (nicht pro Profil) – dasselbe Makro auf " + "zwei Tasten meint denselben Slot.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w", pady=(6, 0)) + self._refresh_macro_choices() + + def _on_macro_slot_selected(self, _event=None): + self._macro_slot = vp.macro_slot_from_choice(self._macro_choice_var.get()) + + def _refresh_macro_choices(self): + choices = vp.macro_slot_choices(self._macros) + self._macro_combo.configure(values=choices) + self._macro_choice_var.set(choices[self._macro_slot]) def _edit_macro_steps(self): - slot = self._macro_slot_var.get() + slot = self._macro_slot current = self._macros[slot] if 0 <= slot < len(self._macros) else [] dlg = MacroStepsDialog(self, current) if dlg.run(): while len(self._macros) <= slot: self._macros.append([]) self._macros[slot] = dlg.steps - self._refresh_macro_preview() + self._refresh_macro_choices() + + # ── Ergebnis ────────────────────────────────────────────────────── def _on_ok(self): t = self._type_var.get() - if t == "None": - action = {"type": "None", "data": 0} - elif t == "HidKey": - name = self._hidkey_key_var.get() - keycode = self._key_code_by_name.get(name, 0) + if t == "HidKey": + keycode = self._key_code_by_name.get(self._hidkey_key_var.get(), 0) modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get()) action = {"type": "HidKey", "data": (modifier << 8) | keycode} elif t == "HidConsumer": action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]} elif t == "Macro": - action = {"type": "Macro", "data": self._macro_slot_var.get()} + action = {"type": "Macro", "data": self._macro_slot} elif t == "ProfileSwitch": name = self._profile_switch_var.get() - data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name) - action = {"type": "ProfileSwitch", "data": data} + action = {"type": "ProfileSwitch", + "data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)} else: action = {"type": "None", "data": 0} From aba81463e5a3dbc88dc20c2097d2f7d3a0212760 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:33:15 +0200 Subject: [PATCH 09/15] Show macro key sequences in the browser view and MCP replies Beide Ansichten reichen jetzt die Makrotabelle an action_label() durch, so dass eine Makro-Belegung ihre echte Tastenfolge zeigt statt nur der Slot-Nummer -- dieselbe Darstellung wie im Desktop-Fenster. Co-Authored-By: Claude Opus 5 --- server.py | 2 +- versapad_mcp_server.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index 238d13c..7e1b181 100644 --- a/server.py +++ b/server.py @@ -109,7 +109,7 @@ def render_page(profile): cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], - }) + }, combined.get("macros")) tabs = "".join( f'{html.escape(combined["profile_names"][p])}' for p in range(vp.NUM_PROFILES) diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 5a262c7..c47ecc6 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -61,7 +61,11 @@ def _profile_switch_data(target): def _describe_action(action): - return {"type": action["type"], "data": action["data"], "label": vp.action_label(action), + """label zeigt bei Makros die echte Tastenfolge statt nur der + Slot-Nummer (gleiche Darstellung wie im Hauptfenster) -- die Makrotabelle + liegt im selben State, also kein Grund, hier weniger zu verraten.""" + return {"type": action["type"], "data": action["data"], + "label": vp.action_label(action, _cfg().get("macros")), "note": action.get("note", "")} From 21f9cbc2edf340c8eb81f2acc5f3560734bf89d9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:33:26 +0200 Subject: [PATCH 10/15] Copy button settings between keys; add window shortcuts Rechtsklick auf eine Karte im Programmiermodus: Belegung und/oder LED-Farbe kopieren und auf andere Tasten anwenden, Belegung leeren, bearbeiten. Auf Encodern dasselbe fuer die drei Aktionen (dort ohne Farbe -- Encoder haben keine eigene LED). Strg+C/Strg+V wirken auf die Karte unter dem Mauszeiger. Die Ablage ist bewusst eine reine In-Memory-Struktur (self._clip), nicht die System-Zwischenablage: uebertragen werden ganze Action-Dicts, kein Text. Eingefuegt wird immer eine deepcopy, sonst teilen sich zwei Tasten dasselbe Dict und eine spaetere Bearbeitung aendert beide. "Leeren" behaelt die Notiz -- gleiche Regel wie beim Typwechsel im Dialog, die Notiz beschreibt die Taste, nicht die konkrete Aktion. Ausserdem: Makro-Belegungen zeigen im Grid ihre echte Tastenfolge (Makrotabelle wird an annotate_profile() durchgereicht), und das Fenster kennt Strg+1/2/3 (Profil), F2 (umbenennen), Strg+E (Programmiermodus), Esc (ins Tray). Co-Authored-By: Claude Opus 5 --- desktop_viewer.py | 210 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 196 insertions(+), 14 deletions(-) diff --git a/desktop_viewer.py b/desktop_viewer.py index a27e191..b2a4af1 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -19,6 +19,7 @@ automatisch ausgeschaltet, damit sich beide nicht um den Port streiten). Start: python desktop_viewer.py """ +import copy import os import queue import sys @@ -138,6 +139,12 @@ class VersaPadViewer(tk.Tk): self._link = vs.VersaPadLink() self._closing = False + # Zwischenablage fuer "Belegung/Farbe auf andere Taste uebertragen" + # (Rechtsklickmenue bzw. Strg+C/V auf der Karte unter dem Mauszeiger). + # Bewusst NUR im Speicher, nicht die System-Zwischenablage: hier + # liegen Action-Dicts, kein Text. + self._clip = None + self._hover = None self.live_sync = tk.BooleanVar(value=False) self.editing = tk.BooleanVar(value=False) self._serial_results = queue.Queue() @@ -230,6 +237,10 @@ class VersaPadViewer(tk.Tk): padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6)) self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) self.prog_status.pack(side="left", padx=(8, 0)) + # Kurz halten: die Toolbar ist bei der Standardbreite (790px) schon + # fast voll, laengerer Text wird rechts abgeschnitten. + tk.Label(self.prog_row, text="Rechtsklick = kopieren/einfügen", + bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="right") # prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing) # Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am @@ -276,6 +287,7 @@ class VersaPadViewer(tk.Tk): self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) self.bind("", self._on_unmap) + self._bind_shortcuts() self._serial_thread.start() self.set_profile(0) @@ -617,6 +629,179 @@ class VersaPadViewer(tk.Tk): except OSError as e: self._status(f"Auto-Speichern fehlgeschlagen: {e}", False) + # ── Kopieren/Einfuegen + Tastenkuerzel ───────────────────────── + + def _bind_shortcuts(self): + """Fensterweite Kuerzel. Waehrend ein Bearbeiten-Dialog offen ist, + haelt dessen grab_set() die Tastatur -- die Kuerzel hier koennen ihm + also nicht dazwischenfunken.""" + for seq, handler in ( + ("", lambda e: self._hide_to_tray()), + ("", lambda e: self._rename_tab(self.profile)), + ("", lambda e: self._toggle_editing_shortcut()), + ("", lambda e: self._toggle_editing_shortcut()), + ("", lambda e: self._copy_hovered()), + ("", lambda e: self._copy_hovered()), + ("", lambda e: self._paste_hovered()), + ("", lambda e: self._paste_hovered()), + ): + self.bind(seq, handler) + for p in range(vp.NUM_PROFILES): + self.bind(f"", + lambda e, prof=p: self.set_profile(prof, manual=True)) + + def _toggle_editing_shortcut(self): + self.editing.set(not self.editing.get()) + self._on_toggle_editing() + + def _hint(self, text): + """Kurze Rueckmeldung in der Fusszeile -- die Programmiermodus- + Statuszeile haengt an der Toolbar und ist sonst leicht zu uebersehen. + Der naechste _render() setzt die Quellenangabe wieder ein.""" + self.footer.configure(text=text) + + def _target_entry(self, target): + """Liefert den Eintrag im combined-State, auf den ein Ziel zeigt -- + Button-Karte oder Encoder (dort waehlt target["field"] danach noch + sw/cw/ccw aus).""" + profile = self.combined["profiles"][self.profile] + items = profile["buttons"] if target["kind"] == "button" else profile["encoders"] + return next(item for item in items if item["index"] == target["index"]) + + def _set_hover(self, target): + self._hover = target + + def _clear_hover(self, target): + """ feuert auch beim Wechsel auf ein Kindwidget derselben + Karte -- deshalb erst pruefen, ob der Zeiger die Karte wirklich + verlassen hat.""" + card = target["card"] + if not card.winfo_exists(): + self._hover = None + return + x, y = card.winfo_pointerxy() + inside = (card.winfo_rootx() <= x < card.winfo_rootx() + card.winfo_width() + and card.winfo_rooty() <= y < card.winfo_rooty() + card.winfo_height()) + if not inside and self._hover is target: + self._hover = None + + def _copy_hovered(self): + if self._hover is not None: + self._copy_target(self._hover, "all") + + def _paste_hovered(self): + if self._hover is not None: + self._paste_target(self._hover, "all") + + def _copy_target(self, target, what): + if not self.editing.get() or self.combined is None: + return + entry = self._target_entry(target) + if target["kind"] == "button": + action, led = entry["action"], entry["led"] + else: + action, led = entry[target["field"]], None + self._clip = { + "action": copy.deepcopy(action) if what in ("all", "action") else None, + "led": copy.deepcopy(led) if what in ("all", "led") else None, + } + parts = [name for name, key in (("Belegung", "action"), ("Farbe", "led")) + if self._clip[key] is not None] + self._hint("kopiert: {} von {}".format(" + ".join(parts) or "nichts", target["label"])) + + def _paste_target(self, target, what): + if not self.editing.get() or self.combined is None or not self._clip: + return + entry = self._target_entry(target) + applied = [] + if what in ("all", "action") and self._clip["action"] is not None: + action = copy.deepcopy(self._clip["action"]) + if target["kind"] == "button": + entry["action"] = action + else: + entry[target["field"]] = action + applied.append("Belegung") + if what in ("all", "led") and self._clip["led"] is not None and target["kind"] == "button": + entry["led"] = copy.deepcopy(self._clip["led"]) + applied.append("Farbe") + if not applied: + self._hint("nichts eingefügt -- Zwischenablage passt nicht zu diesem Ziel") + return + self._autosave_combined() + self._render() + self._hint("eingefügt: {} → {}".format(" + ".join(applied), target["label"])) + + def _clear_target(self, target): + if not self.editing.get() or self.combined is None: + return + entry = self._target_entry(target) + # Notiz bleibt bewusst erhalten (gleiche Regel wie beim Typwechsel im + # Dialog): sie beschreibt die Taste, nicht die konkrete Aktion. + if target["kind"] == "button": + entry["action"] = {"type": "None", "data": 0, + "note": entry["action"].get("note", "")} + else: + old = entry[target["field"]] + entry[target["field"]] = {"type": "None", "data": 0, "note": old.get("note", "")} + self._autosave_combined() + self._render() + self._hint("geleert: {}".format(target["label"])) + + def _show_context_menu(self, event, target): + menu = tk.Menu(self, tearoff=0, bg=CARD_BG, fg=TEXT, activebackground=ACCENT, + activeforeground="#ffffff", bd=0, font=("Segoe UI", 9)) + is_button = target["kind"] == "button" + has_action = bool(self._clip and self._clip.get("action")) + has_led = bool(self._clip and self._clip.get("led")) + + menu.add_command(label="Bearbeiten…", command=lambda: self._edit_target(target)) + menu.add_separator() + if is_button: + menu.add_command(label="Kopieren: Belegung + Farbe (Strg+C)", + command=lambda: self._copy_target(target, "all")) + menu.add_command(label="Kopieren: nur Belegung", + command=lambda: self._copy_target(target, "action")) + menu.add_command(label="Kopieren: nur Farbe", + command=lambda: self._copy_target(target, "led")) + else: + menu.add_command(label="Kopieren: Belegung (Strg+C)", + command=lambda: self._copy_target(target, "action")) + menu.add_separator() + paste_all = has_action or (has_led and is_button) + menu.add_command(label="Einfügen: alles (Strg+V)", + state="normal" if paste_all else "disabled", + command=lambda: self._paste_target(target, "all")) + menu.add_command(label="Einfügen: nur Belegung", + state="normal" if has_action else "disabled", + command=lambda: self._paste_target(target, "action")) + if is_button: + menu.add_command(label="Einfügen: nur Farbe", + state="normal" if has_led else "disabled", + command=lambda: self._paste_target(target, "led")) + menu.add_separator() + menu.add_command(label="Leeren (Belegung entfernen)", + command=lambda: self._clear_target(target)) + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _edit_target(self, target): + if target["kind"] == "button": + self._edit_button(target["index"]) + else: + self._edit_encoder_action(target["index"], target["field"], target["field_label"]) + + def _bind_cell_interaction(self, card, widgets, target): + """Linksklick = bearbeiten, Rechtsklick = Kontextmenue; der + Mauszeiger merkt sich das Ziel fuer Strg+C/Strg+V.""" + card.configure(cursor="hand2") + for widget in widgets: + widget.bind("", lambda e: self._edit_target(target)) + widget.bind("", lambda e: self._show_context_menu(e, target)) + widget.bind("", lambda e: self._set_hover(target)) + card.bind("", lambda e: self._clear_hover(target)) + # ── Rendering ──────────────────────────────────────────────────── def _current_profile_view(self): @@ -641,7 +826,7 @@ class VersaPadViewer(tk.Tk): cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], - }) + }, data.get("macros")) return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" except (KeyError, IndexError, ValueError): pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen @@ -667,7 +852,7 @@ class VersaPadViewer(tk.Tk): cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], - }) + }, self.combined.get("macros")) source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert" else: try: @@ -723,11 +908,9 @@ class VersaPadViewer(tk.Tk): x=10, y=CARD_H - 16, width=CARD_W - 20, height=13) if editable: - card.configure(cursor="hand2") - handler = lambda e, idx=btn["index"]: self._edit_button(idx) - card.bind("", handler) - for child in card.winfo_children(): - child.bind("", handler) + target = {"kind": "button", "index": btn["index"], "card": card, + "label": "Button #{}".format(btn["index"])} + self._bind_cell_interaction(card, [card] + list(card.winfo_children()), target) def _render_encoder(self, enc, editable=False): card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER, @@ -755,14 +938,13 @@ class VersaPadViewer(tk.Tk): wraplength=150, justify="left", anchor="w") note_label.pack(fill="x") if editable: - row.configure(cursor="hand2") - handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl) - row.bind("", handler) - top.bind("", handler) + target = {"kind": "encoder", "index": enc["index"], "field": field, + "field_label": key, "card": row, + "label": "Encoder {} {}".format(enc["index"], key)} + widgets = [row, top] + list(top.winfo_children()) if note_label is not None: - note_label.bind("", handler) - for child in top.winfo_children(): - child.bind("", handler) + widgets.append(note_label) + self._bind_cell_interaction(row, widgets, target) if __name__ == "__main__": From 8a00b3ae369d7f8ff29cc3e1935cebe6796c22c0 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:33:38 +0200 Subject: [PATCH 11/15] Document key capture, dialog placement and copy/paste AGENTS.md: die Regel "Auswahl ist ein Dropdown, kein Tastendruck-Capture" ist ueberholt -- Capture gibt es jetzt, aber weiterhin ohne WinAPI-Hook, und genau diese Grenze muss bleiben. Dazu die Layout-Naeherung (Y/Z vertauscht auf deutschem Layout, minus-Kollision bewusst zugunsten der US-Position aufgeloest), die neuen UI-Regeln (Dialogposition, Grab-Rueckgabe bei verschachtelten Dialogen, feste Panel-Hoehe, In-Memory-Ablage fuers Kopieren) und ein Verifikationsrezept fuer UI-Aenderungen inkl. der DPI-Falle beim Screenshot-Vergleich. Der Deferred-Work-Eintrag zum Capture faellt weg. README.md: neue Bedienung in den Features, Tastenkuerzel-Uebersicht, und die Einschraenkung praezisiert (nicht mehr "Dropdown statt Capture", sondern was die Fenster-basierte Erkennung nicht sehen kann). docs/architecture.md: _ModalDialog/_KeyCapture in der UI-Schicht, plus zwei neue Abschnitte zu den Grenzen der Erkennung und zur Kopier-Ablage. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 82 ++++++++++++++++++++++++++++++++++++++++---- README.md | 36 ++++++++++++++++--- docs/architecture.md | 46 ++++++++++++++++++++++++- 3 files changed, 152 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d3a001..4971069 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,11 @@ Nutzerorientierte Einführung: [`README.md`](README.md). - `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/ Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie (`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`. + Seit 2026-08-28 zusätzlich `tk_event_to_hid()` (Tk-Tastendruck → + HID-Keycode+Modifier, siehe Tastendruck-Erkennung unten) und + `macro_slot_choices()`/`macro_slot_from_choice()` für die Slot-Auswahl. + `action_label()`/`annotate_profile()` nehmen die Makrotabelle optional + entgegen und zeigen dann statt „Makro (Slot 7)" die echte Tastenfolge. `app_dir()` liefert das Verzeichnis für die eigene Config (`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations- ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten. @@ -59,7 +64,9 @@ Nutzerorientierte Einführung: [`README.md`](README.md). (Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln), Tray-Icon statt Taskleisten-Minimierung, Info-Button mit MCP-Doku. - `action_dialog.py` — Bearbeiten-Dialoge (`ActionEditDialog`, - `MacroStepsDialog`) für den Programmiermodus. + `MacroStepsDialog`) für den Programmiermodus. Gemeinsame Basis + `_ModalDialog` (Positionierung über dem Elternfenster, Enter/Escape, + Verteilung der Tastenevents) und `_KeyCapture` (Tastendruck-Erkennung). **MCP-Server:** - `versapad_mcp_server.py` — registriert als projektgebundener MCP-Server @@ -246,12 +253,66 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. 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. -- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein - Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm - wie bei den Fensterverstecktricks in anderen Projekten zu riskieren). +- **Dialoge öffnen über dem Hauptfenster, nicht in der Bildschirmecke + (2026-08-28):** `_ModalDialog` baut sich `withdraw()`n auf, positioniert + sich in `run()` per `_center_on_parent()` und wird erst dann + `deiconify()`t. Ohne das legt Tk jeden Toplevel bei `+0+0` an — bei 20 + Tasten hintereinander wandert der Blick jedes Mal in die linke obere Ecke. + Das `withdraw()` gehört zwingend dazu, sonst blitzt der Dialog dort auf, + bevor er springt. +- **Verschachtelte Dialoge geben den Grab zurück:** `MacroStepsDialog` läuft + im `ActionEditDialog`. `_finish()` ruft `grab_release()`, was den Grab des + *aufrufenden* Dialogs mit wegnimmt — `run()` setzt ihn deshalb am Ende + wieder, wenn der Parent ein `_ModalDialog` ist. +- **Panel-Höhe im `ActionEditDialog` ist fix** (`grid_propagate(False)`): + sonst springt die Fenstergröße bei jedem Typwechsel (Keine/Taste/Makro/…) + und OK/Abbrechen wandern unter dem Mauszeiger weg. +- **Kopieren/Einfügen zwischen Tasten** (Rechtsklickmenü bzw. Strg+C/Strg+V + auf der Karte unter dem Mauszeiger) benutzt eine reine In-Memory-Ablage + (`self._clip`), **nicht** die System-Zwischenablage — dort liegen + Action-Dicts, kein Text. Immer `copy.deepcopy()`, sonst teilen sich zwei + Tasten dasselbe Dict und eine spätere Bearbeitung ändert beide. „Leeren" + behält die Notiz (gleiche Regel wie beim Typwechsel im Dialog, siehe + Notizen-Bullet oben). Eine kopierte LED-Farbe auf einen Encoder + einzufügen ist wirkungslos (Encoder haben keine eigene LED) — das ist + Absicht, kein Fehler. +- Die Toolbar des Programmiermodus ist bei der Standardbreite (790px) fast + voll — zusätzliche Hinweistexte dort kurz halten, sonst werden sie rechts + abgeschnitten. +- **Tastendruck-Erkennung (seit 2026-08-28, auf expliziten User-Wunsch):** + Tasten lassen sich im Bearbeiten-Dialog per „⌨ Taste drücken" erfassen, + Makro-Schritte zusätzlich als Folge am Stück („⏺ Folge aufnehmen"). + Umgesetzt **ausschließlich über Tk-Fenster-Events** (``/ + `` auf dem Dialog-Toplevel, ausgewertet in + `versapad_data.tk_event_to_hid()`) — **weiterhin kein WinAPI-Hook** + (`SetWindowsHookEx` o.ä.), aus demselben AV-Fehlalarm-Grund wie bei den + Fensterverstecktricks. Wer hier auf einen globalen Hook „aufrüstet", + baut genau dieses Risiko ein. Konsequenzen, die so bleiben müssen: + - Erkannt wird nur, was das fokussierte Fenster erreicht — Win+L, + Strg+Alt+Entf und andere vom System abgefangene Kombinationen nicht. + - 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. - Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich aktive Windows-Tastaturlayout (die echte VersaGUI löst das über `GetKeyNameText()`, das bilden wir ohne WinAPI-Call nicht nach). + **Dieselbe Näherung gilt für die Tastendruck-Erkennung:** HID-Keycodes + sind physische US-Tastenpositionen, Tk liefert aber nur keysym/VK-Code des + aktiven Layouts — die Position (Scan-Code) wäre dafür nötig und ist ohne + WinAPI nicht zu bekommen. Auf deutschem Layout landen Y und Z deshalb + vertauscht auf dem Board, und die einzige echte Keysym-Kollision (`minus`: + US-Position 0x2D vs. deutsche Position 0x38) ist bewusst zugunsten der + US-Position aufgelöst, damit Erkennung und Dropdown-Beschriftung dasselbe + sagen. Bei gehaltenem Shift zählt zuerst der VK-Code, weil der keysym dann + das verschobene Zeichen ist (deutsch: Shift+7 → `slash`, was sonst + fälschlich auf Taste 0x38 zeigen würde). - Tk-Aufrufe (`self.after()`, Widget-Konfiguration) NIE direkt aus einem Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in @@ -334,8 +395,6 @@ selbst vorgegeben (`SAction.data`), dort beibehalten statt umzubenennen. explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only - Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im Firmware-Struct), bleibt lokal -- Tastendruck-Capture statt Dropdown im Programmiermodus — bewusst - vermieden (WinAPI-Hook-Risiko) - Hintergrund-Thread für "Vom Board laden"/"Zum Board übertragen" — laufen aktuell synchron im UI-Thread (kurzzeitiges Einfrieren möglich) - Vorgefertigte `.exe` im Repo/als Release-Asset — bewusst nicht committet @@ -363,6 +422,17 @@ Prüfungen vor einem Commit an Binärformat/Protokoll: ```bash python -m py_compile *.py ``` + +Für UI-Änderungen (Dialoge, Tastendruck-Erkennung, Kopieren/Einfügen) hat +sich zusätzlich bewährt, ein Wegwerf-Skript im Scratchpad zu fahren, das die +Dialoge ohne Board aufbaut, Tk-Events als kleine Fake-Event-Objekte +(`keysym`/`keycode`/`state`) durchreicht und das Ergebnis-Dict prüft — die +komplette Capture- und Copy/Paste-Logik ist so ohne Klicken verifizierbar. +Wichtig dabei: `versapad_combined.DEFAULT_PATH` vorher auf eine Temp-Datei +umbiegen, sonst schreibt `_autosave_combined()` in die echte Nutzer-Config. +Für Screenshots gilt: Tk rechnet in logischen Pixeln, `ImageGrab` liefert +physische — bei aktiver Windows-Skalierung (hier 125%) sonst ein zu kleiner +Ausschnitt, der wie ein Layout-Fehler aussieht. Danach ein Live-Testskript gegen ein angeschlossenes Board laufen lassen (read → unpack → pack → Bytevergleich, siehe Existing-Codebase-Regel) — es gibt keine automatisierten Unit-Tests dafür, die Verifikation läuft diff --git a/README.md b/README.md index 29e1ea0..bcbb275 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,27 @@ falls gewünscht. und schaltet die Ansicht automatisch mit - **Programmiermodus** — Zellen anklicken und bearbeiten (Taste, Medientaste, Makro, Profilwechsel, LED-Farbe/Animation), direkt aufs Board schreiben - oder als Datei speichern + 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). Läuft über das Dialogfenster, nicht über + einen System-Hook — vom System abgefangene Kombinationen (Win+L, + Strg+Alt+Entf) kommen deshalb nicht an, und das Dropdown bleibt zum + Nachkorrigieren daneben stehen - **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, @@ -46,6 +64,9 @@ falls gewünscht. der Kopfzeile, Größe ändern am Anfasser unten rechts, `✕`/`—` legen ins Tray. Einen Taskleisten-Eintrag gibt es dadurch nicht — das Fenster kommt über das Tray-Icon zurück. +- **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` ins Tray. ## Voraussetzungen @@ -163,10 +184,15 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`. gleichzeitig mit der offiziellen VersaGUI laufen (die läuft dauerhaft als Tray-App weiter, auch wenn nur ihr Konfigurationsfenster geschlossen wird — für Parallelbetrieb muss sie über ihr Tray-Menü beendet werden) -- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein - Tastendruck-Capture (bewusst, um keinen WinAPI-Hook zu brauchen) +- Die Tastendruck-Erkennung läuft bewusst über das Dialogfenster statt über + einen globalen WinAPI-Hook — vom System abgefangene Kombinationen (Win+L, + Strg+Alt+Entf) erreichen das Fenster nie und lassen sich so nicht erfassen - Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich - aktive Tastatur-Layout + aktive Tastatur-Layout. Das betrifft auch die Tastendruck-Erkennung: + HID-Keycodes sind physische US-Tastenpositionen, erkennbar ist ohne + WinAPI aber nur das Zeichen des aktiven Layouts — auf deutschem Layout + landen Y und Z deshalb vertauscht auf dem Board. Das Ergebnis steht immer + sichtbar im Dropdown und lässt sich dort korrigieren - Unsignierte `.exe` — kann von Antivirus/Smart App Control blockiert werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert es aber nicht diff --git a/docs/architecture.md b/docs/architecture.md index 0c5dfc0..c16e23f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,7 +93,17 @@ auf. umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit MCP-Doku. - **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`, - `MacroStepsDialog`) für den Programmiermodus. + `MacroStepsDialog`) für den Programmiermodus, auf gemeinsamer Basis + `_ModalDialog`: + - positioniert sich beim Öffnen mittig über dem aufrufenden Fenster + (Aufbau `withdraw()`n, `_center_on_parent()`, dann `deiconify()`); + - bindet ``/`` auf dem Toplevel und verteilt sie: + entweder an eine laufende Tastendruck-Aufnahme, sonst als + Enter = OK / Escape = Abbrechen; + - `_KeyCapture` schaltet ein Label in den Aufnahmemodus und schickt jeden + Tastendruck durch `versapad_data.tk_event_to_hid()` — reine + Tk-Fenster-Events, **kein globaler Tastaturhook** (siehe „Grenzen der + Tastendruck-Erkennung" unten). ### MCP-Server @@ -145,6 +155,40 @@ Drei Checkboxen, unabhängig voneinander: | 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…“. | +### Grenzen der Tastendruck-Erkennung + +Die Erkennung im Bearbeiten-Dialog nutzt ausschließlich Tk-Events des +fokussierten Fensters. Daraus folgt zweierlei, und beides ist bewusst so: + +1. **Nur was das Fenster erreicht, wird erkannt.** Win+L, Strg+Alt+Entf und + andere vom Betriebssystem abgefangene Kombinationen kommen nie an. Ein + globaler `SetWindowsHookEx`-Hook würde sie sehen, ist aber ausgeschlossen + (AV-Fehlalarm-Risiko, siehe `AGENTS.md`). +2. **Die Zuordnung ist eine US-Layout-Näherung.** HID-Keycodes bezeichnen + physische Tastenpositionen des US-Layouts; Tk liefert nur `keysym` und + Windows-Virtual-Key-Code, beide vom *aktiven* Layout abgeleitet. Die + physische Position (Scan-Code) wäre nötig, um das exakt aufzulösen, und + ist ohne WinAPI-Aufruf nicht verfügbar. Praktische Folge auf deutschem + Layout: Y und Z landen vertauscht auf dem Board. Das Ergebnis wird immer + ins Dropdown und in die Modifier-Checkboxen geschrieben und ist dort + korrigierbar — die Erkennung ersetzt die manuelle Auswahl nicht, sie + beschleunigt sie nur. + +Details der Zuordnungstabellen: `versapad_data.tk_event_to_hid()` und die +`_TK_*`/`_WIN_VK_TO_HID`-Dicts darüber. + +### 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 From 42897d553cdc098a47ed7d64558c047895b045d3 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 29 Aug 2026 01:01:51 +0200 Subject: [PATCH 12/15] Resolve captured keys by physical position, not by character Die Tastendruck-Erkennung ging bisher ueber das erzeugte Zeichen (keysym bzw. Virtual-Key). Das dreht die Kette falsch herum: HID-Keycodes SIND physische Tastenpositionen -- das Board sendet eine Position, erst Windows macht daraus ueber das aktive Layout ein Zeichen. Auf deutschem Layout landete deshalb jedes Y auf der Z-Taste des Boards und umgekehrt, und AeOeUe/#/+/ss waren gar nicht erfassbar. versapad_keylayout.py loest das ueber MapVirtualKeyW (Virtual-Key -> Scan-Code) und eine layoutunabhaengige Scan-Code-zu-HID-Tabelle. GetKeyNameTextW liefert dazu den Namen, den eine Taste auf dem aktiven Layout traegt, so dass Dropdown und Grid "Strg+Z" zeigen, wenn Strg+Z gemeint ist. Beides sind passive Layout-Abfragen -- kein Hook, keine Fenstermanipulation, also nicht das, was AGENTS.md verbietet (die offizielle VersaGUI benutzt GetKeyNameText fuer denselben Zweck). Benannte Tasten laufen weiterhin zuerst ueber den Tk-keysym, und das ist kein Schoenheitsfehler: MapVirtualKeyW liefert fuer die Pfeiltasten denselben Scan-Code wie fuer ihre Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B). Ohne diesen Schritt waeren beide nicht zu unterscheiden. Namen sind zugleich Schluessel (Dropdown, hid_key_code_for_name), muessen also eindeutig bleiben -- auf deutschem Layout heisst HID 0x31 "#", ein Name den bisher HID 0x32 trug. Der Layoutname gewinnt, der verdraengte US-Name wird gekennzeichnet statt verworfen. Bestehende Belegungen aendern damit ihre Anzeige, nicht ihre Daten. Co-Authored-By: Claude Opus 5 --- versapad_data.py | 127 +++++++++++++++++++++++++++-------- versapad_keylayout.py | 152 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 27 deletions(-) create mode 100644 versapad_keylayout.py diff --git a/versapad_data.py b/versapad_data.py index cb41158..46705ec 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -8,6 +8,8 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. import json import os +import versapad_keylayout as kl + NUM_PROFILES = 3 # Groesse der globalen Makrotabelle (SMacroTable, siehe versapad_protocol. @@ -228,29 +230,41 @@ def tk_event_to_hid(keysym, keycode, state, held_modifier=0): Modifier (siehe TK_MODIFIER_KEYSYMS) -- beides wird verodert, damit ein verlorenes KeyRelease die Erkennung nicht verfaelscht. - Bei gehaltenem Shift zaehlt zuerst der Virtual-Key-Code: der keysym ist - dann das *verschobene* Zeichen (deutsches Layout: Shift+7 -> "slash", - was sonst faelschlich auf die Taste 0x38 zeigen wuerde), der VK-Code - bleibt derselbe wie ohne Shift. + Aufloesungsreihenfolge, und warum genau so: - Layout-Vorbehalt (derselbe wie bei den Dropdown-Labels, siehe - _SPECIAL_KEYS): HID-Keycodes sind US-Tastenpositionen, Tk liefert aber - nur Zeichen/VK-Codes des aktiven Layouts -- die physische Position - (Scan-Code) waere dafuer noetig und ist ohne WinAPI nicht zu bekommen. - Auf deutschem Layout landen Y und Z deshalb vertauscht auf dem Board. - Das Ergebnis ist im Dialog sichtbar (Dropdown + Modifier-Checkboxen - werden gefuellt) und laesst sich dort von Hand korrigieren.""" - shifted = bool(state & 0x0001) or bool(held_modifier & 0x02) - if shifted: - code = _WIN_VK_TO_HID.get(keycode) - if code is None: - code = tk_keysym_to_hid(keysym) - else: - code = tk_keysym_to_hid(keysym) - if code is None: - code = _WIN_VK_TO_HID.get(keycode) + 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: @@ -269,9 +283,55 @@ ANIM_LABELS = { } +_display_name_cache = None + + +def _display_key_names(): + """{hid_code: Anzeigename} -- Zeichentasten mit dem Namen des aktiven + Windows-Layouts, alles andere mit der gepflegten deutschen Bezeichnung + aus _SPECIAL_KEYS ("Enter", "Bild↑", "Num5"); die liest sich besser als + das, was Windows liefert ("EINGABE", "4 (ZEHNERTASTATUR)"). + + Einmal ermittelt und behalten -- ein Layoutwechsel zur Laufzeit wird + bewusst nicht nachgezogen (siehe versapad_keylayout.key_name()). + + Die Namen sind gleichzeitig Schluessel im Dropdown und in + hid_key_code_for_name(), muessen also eindeutig bleiben. Kollisionen + entstehen real: auf deutschem Layout heisst HID 0x31 (US-Backslash- + Position) schlicht "#" -- und diesen Namen trug bisher HID 0x32 + (Non-US-#). Der Layoutname gewinnt, der verdraengte US-Name wird + gekennzeichnet statt verworfen, damit die Taste ansprechbar bleibt.""" + global _display_name_cache + if _display_name_cache is None: + names = dict(_SPECIAL_KEYS) + layout = {} + for code in sorted(_SPECIAL_KEYS): + if code not in kl.CHARACTER_HID_CODES: + continue + name = kl.key_name(code) + if name: + layout[code] = name + names.update(layout) + claimed = set(layout.values()) + for code, name in list(names.items()): + if code not in layout and name in claimed: + names[code] = f"{name} (US-Layout)" + elif code not in layout: + claimed.add(name) + _display_name_cache = names + return _display_name_cache + + +def hid_key_name(keycode): + """Anzeigename einer Taste, layoutrichtig wo es darauf ankommt + (deutsch: 0x1C -> "Z", 0x34 -> "ä"). Ohne verfuegbare Layout-Abfrage + (Nicht-Windows) bleibt es bei der US-Naeherung aus _SPECIAL_KEYS.""" + return _display_key_names().get(keycode, f"0x{keycode:02X}") + + def hid_key_choices(): """Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren.""" - return sorted(_SPECIAL_KEYS.items()) + return [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)] def consumer_choices(): @@ -279,15 +339,28 @@ def consumer_choices(): return sorted(_CONSUMER_NAMES.items()) -_KEY_CODE_BY_NAME = {name: code for code, name in _SPECIAL_KEYS.items()} _CONSUMER_ID_BY_NAME = {name: cid for cid, name in _CONSUMER_NAMES.items()} +def _key_code_by_name(): + """Namen -> Keycode, Layoutnamen haben Vorrang vor den US-Namen. + + Beide Schreibweisen bleiben gueltig, damit aeltere Aufrufe nicht brechen. + Bei einer echten Kollision (deutsch: "Z" ist US-Position 0x1D *und* + Layoutname von 0x1C) gewinnt bewusst das Layout: wer "Z" sagt, will die + Taste, die auf dieser Tastatur ein Z tippt -- alles andere waere genau + der Fehler, der hier gerade behoben wurde.""" + by_name = {name: code for code, name in _SPECIAL_KEYS.items()} + by_name.update({name: code for code, name in _display_key_names().items()}) + return by_name + + def hid_key_code_for_name(name): """z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen.""" - if name not in _KEY_CODE_BY_NAME: - raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(_KEY_CODE_BY_NAME)}") - return _KEY_CODE_BY_NAME[name] + by_name = _key_code_by_name() + if name not in by_name: + raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(by_name)}") + return by_name[name] def consumer_id_for_name(name): @@ -312,7 +385,7 @@ def macro_step_label(step): """step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante von hid_key_label() (dort steckt beides in einem 16-Bit data-Feld, hier getrennt).""" mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit] - key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}") + key = hid_key_name(step["keycode"]) return "+".join(mods + [key]) if mods else key @@ -340,7 +413,7 @@ def hid_key_label(data): keycode = data & 0xFF modifier = (data >> 8) & 0xFF mods = [name for bit, name in MODIFIER_BITS if modifier & bit] - key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}") + key = hid_key_name(keycode) return "+".join(mods + [key]) if mods else key diff --git a/versapad_keylayout.py b/versapad_keylayout.py new file mode 100644 index 0000000..f3ef70e --- /dev/null +++ b/versapad_keylayout.py @@ -0,0 +1,152 @@ +""" +Abfragen ans AKTIVE Windows-Tastaturlayout: physische Tastenposition +(Scan-Code) und der Name, den diese Taste auf dem aktuellen Layout traegt. + +Warum ueberhaupt WinAPI, wo dieses Projekt sonst konsequent darauf +verzichtet: HID-Keycodes bezeichnen **physische Tastenpositionen** (das +Board sendet eine Position, erst Windows macht daraus ein Zeichen). Aus +einem Tk-Event kommen aber nur `keysym` und Virtual-Key-Code -- beides ist +bereits durch das Layout gefiltert. Auf deutschem Layout landete deshalb +jedes Y auf der Z-Taste des Boards und umgekehrt, und AeOeUe/#/+ waren gar +nicht erfassbar. Die Position ist ohne `MapVirtualKeyW` schlicht nicht zu +bekommen. + +Abgrenzung zu den Domaenenregeln in AGENTS.md: verboten sind dort +**Fenstermanipulation** (`SetWindowLongW`/`ShowWindow` aufs eigene Fenster) +und **globale Tastaturhooks** (`SetWindowsHookEx`) -- genau die Aufrufe, die +in einem anderen Projekt AV-Fehlalarme ausgeloest haben. Hier passiert +nichts davon: `MapVirtualKeyW` und `GetKeyNameTextW` sind passive, +lesende Layout-Abfragen ohne Fenster-, Prozess- oder Eingabezugriff. Die +offizielle VersaGUI (C#) benutzt `GetKeyNameText()` fuer denselben Zweck. + +Alles hier ist optional: auf Nicht-Windows oder wenn `user32` nicht laedt, +bleibt AVAILABLE False und alle Funktionen liefern None -- `versapad_data` +faellt dann auf seine US-Layout-Naeherung zurueck. +""" +import sys + +# Scan-Code (PS/2 Set 1, wie MapVirtualKeyW ihn liefert) -> HID Usage Page +# 0x07. Diese Tabelle ist layoutunabhaengig und damit der eigentliche Kern: +# eine physische Taste hat immer denselben Scan-Code und denselben HID-Code, +# egal welches Zeichen das Layout daraufschreibt. +SCANCODE_TO_HID = { + 0x01: 0x29, # Esc + 0x02: 0x1E, 0x03: 0x1F, 0x04: 0x20, 0x05: 0x21, 0x06: 0x22, + 0x07: 0x23, 0x08: 0x24, 0x09: 0x25, 0x0A: 0x26, 0x0B: 0x27, # 1-9, 0 + 0x0C: 0x2D, 0x0D: 0x2E, # US -/=, deutsch ss/Akut + 0x0E: 0x2A, 0x0F: 0x2B, # Backspace, Tab + 0x10: 0x14, 0x11: 0x1A, 0x12: 0x08, 0x13: 0x15, 0x14: 0x17, # Q W E R T + 0x15: 0x1C, 0x16: 0x18, 0x17: 0x0C, 0x18: 0x12, 0x19: 0x13, # US Y U I O P + 0x1A: 0x2F, 0x1B: 0x30, # US [/], deutsch Ue/+ + 0x1C: 0x28, # Enter + 0x1E: 0x04, 0x1F: 0x16, 0x20: 0x07, 0x21: 0x09, 0x22: 0x0A, # A S D F G + 0x23: 0x0B, 0x24: 0x0D, 0x25: 0x0E, 0x26: 0x0F, # H J K L + 0x27: 0x33, 0x28: 0x34, # US ;/', deutsch Oe/Ae + 0x29: 0x35, # US Backtick, deutsch Zirkumflex + 0x2B: 0x31, # US Backslash, deutsch # + 0x2C: 0x1D, 0x2D: 0x1B, 0x2E: 0x06, 0x2F: 0x19, 0x30: 0x05, # US Z X C V B + 0x31: 0x11, 0x32: 0x10, # N M + 0x33: 0x36, 0x34: 0x37, # Komma, Punkt (in beiden Layouts gleich) + 0x35: 0x38, # US /, deutsch - + 0x37: 0x55, # Num * + 0x39: 0x2C, # Leertaste + 0x3A: 0x39, # Caps + 0x45: 0x53, 0x46: 0x47, # NumLock, Rollen + 0x47: 0x5F, 0x48: 0x60, 0x49: 0x61, 0x4A: 0x56, # Num 7 8 9 - + 0x4B: 0x5C, 0x4C: 0x5D, 0x4D: 0x5E, 0x4E: 0x57, # Num 4 5 6 + + 0x4F: 0x59, 0x50: 0x5A, 0x51: 0x5B, # Num 1 2 3 + 0x52: 0x62, 0x53: 0x63, # Num 0 . + 0x56: 0x64, # ISO-Zusatztaste (deutsch <>|) + 0x57: 0x44, 0x58: 0x45, # F11, F12 +} +for _i in range(10): + SCANCODE_TO_HID[0x3B + _i] = 0x3A + _i # F1-F10 + +HID_TO_SCANCODE = {hid: scan for scan, hid in SCANCODE_TO_HID.items()} + +# Nur fuer diese HID-Codes lohnt der Layout-Name: es sind die Tasten, deren +# Beschriftung sich zwischen Layouts unterscheidet (Buchstaben, Ziffern, +# Satzzeichen). Fuer Enter/F5/Entf/Numpad bleiben die gepflegten deutschen +# Namen aus versapad_data._SPECIAL_KEYS besser als das, was Windows liefert +# ("EINGABE", "4 (ZEHNERTASTATUR)"). +CHARACTER_HID_CODES = ( + frozenset(range(0x04, 0x1E)) # A-Z (US-Positionen) + | frozenset(range(0x1E, 0x28)) # 1-9, 0 + | frozenset(range(0x2D, 0x39)) # Satzzeichen + | frozenset({0x64}) # ISO-Zusatztaste +) + +MAPVK_VK_TO_VSC_EX = 4 + +AVAILABLE = False +_user32 = None + +if sys.platform == "win32": + try: + import ctypes + from ctypes import wintypes + + _user32 = ctypes.WinDLL("user32", use_last_error=True) + _user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] + _user32.MapVirtualKeyW.restype = wintypes.UINT + _user32.GetKeyNameTextW.argtypes = [wintypes.LONG, wintypes.LPWSTR, ctypes.c_int] + _user32.GetKeyNameTextW.restype = ctypes.c_int + AVAILABLE = True + except (ImportError, OSError, AttributeError): + _user32 = None # z.B. exotische Python-Builds ohne ctypes + + +def scancode_for_vk(vk): + """Virtual-Key-Code -> Scan-Code der physischen Taste, oder None. + + MAPVK_VK_TO_VSC_EX setzt fuer manche Tasten ein 0xE0-Praefix ins High-Byte + (erweiterte Tasten), fuer die Pfeiltasten hier aber gemessen NICHT -- die + liefern denselben Scan-Code wie ihre Numpad-Zwillinge. Genau deshalb + laeuft die Aufloesung in versapad_data zuerst ueber die eindeutigen + Tk-keysyms und benutzt diesen Weg nur fuer Zeichentasten.""" + if not AVAILABLE: + return None + scan = _user32.MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX) + return scan or None + + +def hid_for_vk(vk): + """Virtual-Key-Code -> HID-Keycode ueber die physische Tastenposition. + Das ist der eigentliche Fix gegen vertauschtes Y/Z und nicht erfassbares + AeOeUe/#/+: der Umweg ueber das Zeichen entfaellt komplett.""" + scan = scancode_for_vk(vk) + if scan is None: + return None + if (scan >> 8) == 0xE0: + return None # erweiterte Taste -- kommt hier nicht vor, siehe oben + return SCANCODE_TO_HID.get(scan & 0xFF) + + +def key_name(hid_code): + """HID-Keycode -> Beschriftung dieser Taste auf dem aktiven Layout + (deutsch: HID 0x1C -> "Z", 0x34 -> "ä"), oder None wenn nicht ermittelbar. + + Achtung: das Ergebnis gilt fuer das Layout, das beim Aufruf aktiv ist. + Ein Layoutwechsel zur Laufzeit wird nicht bemerkt -- fuer ein Tool, das + eine Tastatur konfiguriert, ist das vertretbar (der Cache in + versapad_data haelt entsprechend auch nur eine Fassung).""" + if not AVAILABLE: + return None + scan = HID_TO_SCANCODE.get(hid_code) + if scan is None: + return None + buffer = ctypes.create_unicode_buffer(64) + # lParam-Layout von GetKeyNameTextW: Bits 16-23 Scan-Code, + # Bit 24 "erweiterte Taste". + written = _user32.GetKeyNameTextW((scan & 0xFF) << 16, buffer, 64) + if not written: + return None + name = buffer.value.strip() + if not name: + return None + # Windows liefert Mehrzeichen-Namen in Grossbuchstaben ("AKUT", + # "ZIRKUMFLEX") -- als Dropdown-Eintrag zwischen "A" und "ä" liest sich + # Titelschreibung deutlich ruhiger. + if len(name) > 1 and name == name.upper(): + return name.capitalize() + return name From 50868ab081a4e6251ba5c4c85f49fa3d775d9c9a Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 29 Aug 2026 01:02:07 +0200 Subject: [PATCH 13/15] Keep edit dialogs on the monitor the main window is on _center_on_parent() hat gegen winfo_screenwidth()/screenheight() begrenzt -- Tk meldet dort aber nur den Hauptbildschirm. Lag das Hauptfenster auf einem zweiten Monitor, zog genau diese Begrenzung den Dialog zurueck an den Rand des ersten. Begrenzt wird jetzt gegen das Elternfenster: passt der Dialog hinein, wird er zentriert, sonst an dessen linker oberer Ecke ausgerichtet -- so bleibt er in jedem Fall dort, wo gearbeitet wird. Co-Authored-By: Claude Opus 5 --- action_dialog.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/action_dialog.py b/action_dialog.py index 96eb2fb..511c091 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -10,10 +10,15 @@ 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, und die HID-Zuordnung ist dieselbe -US-Layout-Naeherung wie bei den Dropdown-Labels (siehe -versapad_data.tk_event_to_hid). Das Dropdown bleibt deshalb daneben stehen, -damit ein falsch erkanntes Zeichen von Hand korrigiert werden kann. +was das fokussierte Fenster erreicht -- Win+L, Strg+Alt+Entf und aehnliche +vom System abgefangene Kombinationen also nicht. + +Welche physische Taste gemeint ist, loest versapad_data.tk_event_to_hid() +ueber den Scan-Code des aktiven Layouts auf (versapad_keylayout), nicht +ueber das erzeugte Zeichen -- sonst landet auf deutschem Layout jedes Y auf +der Z-Taste des Boards und AeOeUe/#/+ sind gar nicht erfassbar. Das Dropdown +daneben zeigt dieselbe Taste unter ihrem Layout-Namen und bleibt als +Korrekturmoeglichkeit stehen. """ import tkinter as tk from tkinter import colorchooser, ttk @@ -192,10 +197,18 @@ class _ModalDialog(tk.Toplevel): # ── Positionierung + Ablauf ─────────────────────────────────────── def _center_on_parent(self): - """Mittig ueber dem Elternfenster, aber immer vollstaendig auf dem - Bildschirm. Ohne das oeffnet Tk jeden Toplevel bei +0+0, also bei - jeder bearbeiteten Taste erneut in der Bildschirmecke -- weit weg - vom Hauptfenster, in dem gerade geklickt wurde.""" + """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: @@ -204,10 +217,8 @@ class _ModalDialog(tk.Toplevel): except tk.TclError: px = py = 0 pw, ph = self.winfo_screenwidth(), self.winfo_screenheight() - x = px + (pw - width) // 2 - y = py + (ph - height) // 3 # etwas oberhalb der Mitte wirkt ruhiger - x = max(0, min(x, self.winfo_screenwidth() - width)) - y = max(0, min(y, self.winfo_screenheight() - height)) + 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): From e1e3d0e466ac8acc782b1e4d1e76c3304b0fc2b9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 29 Aug 2026 01:02:07 +0200 Subject: [PATCH 14/15] Bring back the native title bar so the app has a taskbar entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das randlose Fenster (overrideredirect) kostet unter Windows zwingend den Taskleisten-Eintrag. Nachruesten liesse er sich nur per SetWindowLongW (WS_EX_APPWINDOW) -- genau der Aufruf, der laut AGENTS.md schon AV-Fehlalarme ausgeloest hat. Der User hat sich deshalb fuer die native Titelleiste entschieden. Damit kommen Taskleiste, Alt+Tab, Aero-Snap sowie Ziehen und Groessenaendern am Rahmen nativ zurueck, und die Eigenbau-Loesungen dafuer entfallen ersatzlos: ziehbare Kopfzeile, Anfasser unten rechts (ersetzt durch self.minsize()), eigene ✕/—-Knoepfe und der -Handler, der Minimieren ins Tray umgeleitet hat. Schliessen legt weiterhin ins Tray statt zu beenden (wie die offizielle VersaGUI). Minimieren geht jetzt normal in die Taskleiste, und Escape minimiert ebenfalls statt ins Tray zu legen -- mit Taskleisten-Eintrag waere "verschwindet spurlos" die unangenehmere Ueberraschung. Co-Authored-By: Claude Opus 5 --- desktop_viewer.py | 102 ++++++++++++++-------------------------------- 1 file changed, 31 insertions(+), 71 deletions(-) diff --git a/desktop_viewer.py b/desktop_viewer.py index b2a4af1..6909ed5 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -56,8 +56,9 @@ WARN_RED = "#e0895a" POLL_MS = 1500 CARD_W, CARD_H = 150, 114 -# Untergrenze beim Ziehen am Anfasser -- knapp unter der Groesse, die das -# 4x5-Grid + Encoder mindestens brauchen, damit nichts abgeschnitten wird. +# Untergrenze fuers Verkleinern (self.minsize) -- knapp unter der Groesse, +# die das 4x5-Grid + Encoder mindestens brauchen, damit nichts +# abgeschnitten wird. MIN_W, MIN_H = 700, 500 SERIAL_POLL_S = 1.5 SERIAL_IDLE_S = 3.0 @@ -130,8 +131,8 @@ class VersaPadViewer(tk.Tk): self.configure(bg=BG) # Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile # fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114) - # reichten die alten 760px nicht mehr: Encoder und der Groessen- - # Anfasser lagen unterhalb des Fensterrands und waren unerreichbar. + # reichten die alten 760px nicht mehr: der Encoder-Bereich lag + # unterhalb des Fensterrands. Wer CARD_H aendert, muss hier mit. self.geometry("790x960") self.profile = 0 self._mtimes = {} @@ -165,32 +166,23 @@ class VersaPadViewer(tk.Tk): ) self._tray_icon.run_detached() - # Titelleiste ausgeblendet (reines Tk `overrideredirect`, KEINE - # ctypes/WinAPI-Fenstertricks -- siehe Speicher-Notiz "keine - # Selbstversteck-Fenstertricks": das Nachruesten eines Taskleisten- - # Icons per SetWindowLongW hat frueher AV-Fehlalarme ausgeloest). - # Ersatz fuer die fehlende Systemleiste: Kopfzeile ist ziehbar, und - # die Buttons rechts uebernehmen Minimieren/Schliessen (beides ins - # Tray, wie vorher schon das X der echten Titelleiste). - self.overrideredirect(True) + # Normales Fenster MIT Windows-Titelleiste. Bis 2026-08-28 lief es + # randlos (`overrideredirect(True)`) -- das kostet unter Windows aber + # zwingend den Taskleisten-Eintrag, und der wurde ausdruecklich + # gebraucht. Nachruesten liesse er sich nur per `SetWindowLongW` + # (WS_EX_APPWINDOW) -- genau der Aufruf, der laut Speicher-Notiz + # "keine Selbstversteck-Fenstertricks" schon AV-Fehlalarme + # ausgeloest hat und deshalb nicht in Frage kommt. Mit der echten + # Titelleiste kommen Taskleiste, Alt+Tab, Aero-Snap, Ziehen und + # Groessenaendern am Rahmen nativ zurueck; die Eigenbau-Loesungen + # dafuer (ziehbare Kopfzeile, Anfasser unten rechts, eigene + # ✕/—-Knoepfe) sind damit ersatzlos entfallen. + self.minsize(MIN_W, MIN_H) self.toggles_row = header = tk.Frame(self, bg=BG) header.pack(fill="x", padx=20, pady=(10, 6)) - title = tk.Label(header, text="VersaPad", bg=BG, fg=TEXT, - font=("Segoe UI", 11, "bold")) - title.pack(side="left", anchor="w", padx=(0, 16)) - - for widget in (header, title): - widget.bind("", self._start_move) - widget.bind("", self._on_move) - - for text in ("✕", "—"): - btn = tk.Label(header, text=text, bg=BG, fg=TEXT_DIM, - font=("Segoe UI", 11), cursor="hand2", padx=6) - btn.pack(side="right") - btn.bind("", lambda e: self._hide_to_tray()) - btn.bind("", lambda e, b=btn: b.configure(fg=TEXT)) - btn.bind("", lambda e, b=btn: b.configure(fg=TEXT_DIM)) + 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) @@ -264,29 +256,18 @@ class VersaPadViewer(tk.Tk): self.enc_frame = tk.Frame(self, bg=BG) self.enc_frame.pack(fill="x", padx=20) - # Fusszeile + Anfasser zum Groessenaendern: mit ausgeblendeter - # Titelleiste (overrideredirect) entfernt Windows auch die - # Fensterraender, an denen man sonst zieht -- ohne diesen Griff - # liesse sich das Fenster gar nicht mehr skalieren. footer_row = tk.Frame(self, bg=BG) footer_row.pack(fill="x", padx=20, pady=(14, 8)) self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) self.footer.pack(side="left", anchor="w") - # Der Griff haengt per place() an der FENSTER-Ecke, nicht am Ende des - # gepackten Inhalts: sonst wandert er mit dem Inhalt aus dem Bild, - # sobald das Fenster kleiner als der Inhalt ist -- also genau dann, - # wenn man ihn zum Vergroessern braucht. - grip = tk.Label(self, text="◢", bg=BG, fg=TEXT_DIM, - font=("Segoe UI", 11), cursor="sizing") - grip.place(relx=1.0, rely=1.0, anchor="se", x=-3, y=-1) - grip.bind("", self._start_resize) - grip.bind("", self._on_resize) - grip.bind("", lambda e: grip.configure(fg=TEXT)) - grip.bind("", lambda e: grip.configure(fg=TEXT_DIM)) - + # Schliessen legt weiterhin ins Tray statt zu beenden (wie die + # offizielle VersaGUI -- das Programm soll im Hintergrund + # weiterlaufen). Minimieren geht jetzt aber ganz normal in die + # Taskleiste; frueher hat ein -Handler auch das ins Tray + # umgeleitet, was ohne Taskleisten-Eintrag sinnvoll war und mit + # einem das Fenster nur unauffindbar machen wuerde. self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) - self.bind("", self._on_unmap) self._bind_shortcuts() self._serial_thread.start() @@ -388,25 +369,6 @@ class VersaPadViewer(tk.Tk): def _on_toggle_topmost(self): self.attributes("-topmost", self.always_on_top.get()) - # ── Fenster ziehen (Ersatz fuer die ausgeblendete Titelleiste) ── - - def _start_move(self, event): - self._drag_origin = (event.x_root - self.winfo_x(), event.y_root - self.winfo_y()) - - def _on_move(self, event): - dx, dy = self._drag_origin - self.geometry(f"+{event.x_root - dx}+{event.y_root - dy}") - - def _start_resize(self, event): - self._resize_origin = (event.x_root, event.y_root, - self.winfo_width(), self.winfo_height()) - - def _on_resize(self, event): - x0, y0, w0, h0 = self._resize_origin - width = max(MIN_W, w0 + (event.x_root - x0)) - height = max(MIN_H, h0 + (event.y_root - y0)) - self.geometry(f"{width}x{height}") - # ── Live-Sync mit dem Board ──────────────────────────────────── def _on_toggle_sync(self): @@ -452,13 +414,7 @@ class VersaPadViewer(tk.Tk): text = SERIAL_STATUS_TEXT.get(error, error or "Fehler") self.sync_status.configure(text=text, fg=WARN_RED) - # ── Tray-Icon (kein Taskleisten-Eintrag beim Minimieren, wie VersaGUI) ── - - def _on_unmap(self, event): - """Minimieren faengt Windows normalerweise als Taskleisten-Icon ab -- - wir wollen stattdessen: Fenster komplett weg, nur noch Tray-Icon.""" - if event.widget is self and self.state() == "iconic": - self._hide_to_tray() + # ── Tray-Icon (Schliessen beendet nicht, wie bei der VersaGUI) ── def _hide_to_tray(self): self.withdraw() @@ -636,7 +592,11 @@ class VersaPadViewer(tk.Tk): haelt dessen grab_set() die Tastatur -- die Kuerzel hier koennen ihm also nicht dazwischenfunken.""" for seq, handler in ( - ("", lambda e: self._hide_to_tray()), + # Minimieren statt ins Tray: seit das Fenster eine Titelleiste + # und damit einen Taskleisten-Eintrag hat, waere "verschwindet + # spurlos" die unangenehmere Ueberraschung. Ins Tray legt + # weiterhin das ✕ der Titelleiste. + ("", lambda e: self.iconify()), ("", lambda e: self._rename_tab(self.profile)), ("", lambda e: self._toggle_editing_shortcut()), ("", lambda e: self._toggle_editing_shortcut()), From 78e9640dec5e842ab77895f1f58c02463fdd26e7 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 29 Aug 2026 01:02:18 +0200 Subject: [PATCH 15/15] Document the layout fix and the return of the title bar AGENTS.md: die WinAPI-Regel praeziser gefasst -- verboten bleiben Fenstermanipulation und globale Hooks, passive Layout-Abfragen sind es nicht (und die offizielle VersaGUI macht dasselbe). Dazu die neue Aufloesungsreihenfolge samt Warnung, nicht auf "Zeichen auswerten" zurueckzubauen, die Kollisionsregel bei Tastennamen, der Mehrmonitor-Fix und die Folgen der Titelleiste (welche Eigenbauten dadurch entfallen sind und warum randlos nicht ohne Ruecksprache zurueckkommt). README.md und docs/architecture.md entsprechend: Position statt Zeichen, layoutrichtige Beschriftungen inkl. der Anzeigeaenderung an bestehenden Belegungen, Taskleisten-Eintrag statt randlosem Fenster. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 112 ++++++++++++++++++++++++++++++++----------- README.md | 37 +++++++------- docs/architecture.md | 64 ++++++++++++++++++------- 3 files changed, 152 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4971069..aab3b1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,13 @@ Nutzerorientierte Einführung: [`README.md`](README.md). `read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz). +- `versapad_keylayout.py` (seit 2026-08-29) — passive Abfragen ans aktive + Windows-Tastaturlayout: `hid_for_vk()` (Virtual-Key → Scan-Code → HID über + `MapVirtualKeyW`) und `key_name()` (Beschriftung über `GetKeyNameTextW`). + Optional: auf Nicht-Windows/ohne ctypes bleibt `AVAILABLE` False und + `versapad_data` faellt auf seine US-Naeherung zurueck. Zur Abgrenzung + gegen die WinAPI-Verbote siehe Domaenenregeln. + **UI:** - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi (Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln), @@ -235,24 +242,47 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. `vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf `default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer ALLE 3 Profile prüfen, nicht nur das gemeldete. -- **Randloses Fenster (seit 2026-08-15):** Die Titelleiste ist per reinem Tk - `overrideredirect(True)` ausgeblendet -- **niemals** per ctypes/WinAPI - nachhelfen (kein `SetWindowLongW`/`ShowWindow` auf das eigene Fenster), - siehe Speicher-Notiz "keine Selbstversteck-Fenstertricks": genau diese - Kombination hat in einem anderen Projekt Bitdefender-Fehlalarme - ausgelöst. Konsequenzen, die mitgebaut werden müssen: kein - Taskleisten-Eintrag (Rückweg nur über das Tray-Icon), kein Ziehen am - Rahmen (Kopfzeile ist deshalb per `` verschiebbar), keine - System-Buttons (eigene `✕`/`—` in der Kopfzeile, beide legen ins Tray) - und **keine Resize-Ränder**. -- **Der Größen-Anfasser hängt per `place()` an der Fensterecke, nicht - gepackt am Ende des Inhalts.** Gepackt verschwindet er, sobald das Fenster - kleiner als der Inhalt ist -- also exakt dann, wenn man ihn zum - Vergrößern bräuchte. `MIN_W/MIN_H` begrenzen das Verkleinern. +- **WinAPI: was verboten bleibt und was nicht.** Verboten sind + **Fenstermanipulation am eigenen Fenster** (`SetWindowLongW`, + `ShowWindow`) und **globale Eingabehooks** (`SetWindowsHookEx`) — genau + diese Kombination hat laut Speicher-Notiz „keine Selbstversteck- + Fenstertricks" in einem anderen Projekt Bitdefender-Fehlalarme ausgeloest. + Erlaubt und seit 2026-08-29 in Benutzung sind **passive Layout-Abfragen** + (`MapVirtualKeyW`, `GetKeyNameTextW` in `versapad_keylayout.py`): sie + lesen nur die Tastaturbelegung, fassen weder Fenster noch Prozesse noch + den Eingabestrom an; die offizielle VersaGUI (C#) benutzt + `GetKeyNameText()` fuer denselben Zweck. Die Grenze verlaeuft also nicht + bei „ctypes", sondern bei „greift ins System ein". +- **Normales Fenster mit Titelleiste (seit 2026-08-29).** Von 2026-08-15 + bis dahin lief das Fenster randlos (`overrideredirect(True)`) — das kostet + unter Windows zwingend den Taskleisten-Eintrag. Als der ausdruecklich + gebraucht wurde, gab es nur drei Wege: Titelleiste zurueck, + `SetWindowLongW`+WS_EX_APPWINDOW (siehe Verbot oben) oder ein + unsichtbares Proxy-Fenster. Der User hat sich fuer die Titelleiste + entschieden. Damit sind ersatzlos entfallen: ziehbare Kopfzeile + (`_start_move`/`_on_move`), Groessen-Anfasser unten rechts + (`_start_resize`/`_on_resize`, ersetzt durch `self.minsize()`), die + eigenen `✕`/`—`-Knoepfe und der ``-Handler, der Minimieren ins Tray + umgeleitet hat. **Wer das Fenster wieder randlos machen will, nimmt dem + User den Taskleisten-Eintrag weg** — nicht ohne Ruecksprache. +- Schliessen (`✕`) legt weiterhin ins Tray statt zu beenden (wie die + offizielle VersaGUI, das Programm laeuft im Hintergrund weiter), + Minimieren geht jetzt aber ganz normal in die Taskleiste. `Escape` + minimiert ebenfalls, statt wie frueher ins Tray zu legen — mit + Taskleisten-Eintrag waere „verschwindet spurlos" die unangenehmere + Ueberraschung. - Fensterhöhe muss zum Karteninhalt passen: seit die Karten eine Notizzeile haben (`CARD_H` 84 → 114) braucht das Fenster ~960px, sonst liegen Encoder-Bereich und Fußzeile unterhalb des Rands. Wer `CARD_H` ändert, muss `geometry()` mit anpassen. +- **Dialogposition wird gegen das ELTERNFENSTER begrenzt, nie gegen + `winfo_screenwidth()` (2026-08-29):** Tk meldet dort nur den + Hauptbildschirm. Die erste Fassung hat damit geklemmt — lag das + Hauptfenster auf einem zweiten Monitor, zog genau diese Begrenzung den + Dialog zurueck an den Rand des ersten. Passt der Dialog nicht ins + Elternfenster (der Makro-Schritte-Dialog ist breiter als der + Action-Dialog), wird er an dessen linker oberer Ecke ausgerichtet statt + zentriert. - **Dialoge öffnen über dem Hauptfenster, nicht in der Bildschirmecke (2026-08-28):** `_ModalDialog` baut sich `withdraw()`n auf, positioniert sich in `run()` per `_center_on_parent()` und wird erst dann @@ -290,6 +320,23 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. 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 @@ -300,19 +347,30 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. 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. -- Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich - aktive Windows-Tastaturlayout (die echte VersaGUI löst das über - `GetKeyNameText()`, das bilden wir ohne WinAPI-Call nicht nach). - **Dieselbe Näherung gilt für die Tastendruck-Erkennung:** HID-Keycodes - sind physische US-Tastenpositionen, Tk liefert aber nur keysym/VK-Code des - aktiven Layouts — die Position (Scan-Code) wäre dafür nötig und ist ohne - WinAPI nicht zu bekommen. Auf deutschem Layout landen Y und Z deshalb - vertauscht auf dem Board, und die einzige echte Keysym-Kollision (`minus`: - US-Position 0x2D vs. deutsche Position 0x38) ist bewusst zugunsten der - US-Position aufgelöst, damit Erkennung und Dropdown-Beschriftung dasselbe - sagen. Bei gehaltenem Shift zählt zuerst der VK-Code, weil der keysym dann - das verschobene Zeichen ist (deutsch: Shift+7 → `slash`, was sonst - fälschlich auf Taste 0x38 zeigen würde). +- **Tastenbeschriftungen kommen vom aktiven Layout** (seit 2026-08-29). + `versapad_data.hid_key_name()` fragt fuer Zeichentasten + `GetKeyNameTextW` (deutsch: HID 0x1C → „Z", 0x34 → „ä"); fuer alles + andere bleiben die gepflegten deutschen Namen aus `_SPECIAL_KEYS` + („Enter", „Bild↑", „Num5") — die lesen sich besser als das, was Windows + liefert („EINGABE", „4 (ZEHNERTASTATUR)"). Ohne Layout-Abfrage + (Nicht-Windows) faellt alles auf die alte US-Naeherung zurueck. + Konsequenzen, die man kennen muss: + - Bestehende Belegungen aendern ihre **Anzeige**, nicht ihre Daten. Wer + frueher im Dropdown „Z" gewaehlt hat, bekam 0x1D — das steht so in der + Config und zeigt jetzt wahrheitsgemaess „Y", weil es auf dieser Tastatur + ein Y tippt. Das ist keine Regression, sondern der sichtbar gewordene + Altfehler. + - Namen sind Schluessel (Dropdown, `hid_key_code_for_name()`) und muessen + eindeutig bleiben. Es gibt echte Kollisionen: auf deutschem Layout heisst + HID 0x31 schlicht „#" — den Namen trug bisher HID 0x32. Der Layoutname + gewinnt, der verdraengte US-Name wird als „# (US-Layout)" gekennzeichnet + statt verworfen, damit die Taste ansprechbar bleibt. + - `hid_key_code_for_name()` akzeptiert weiterhin beide Schreibweisen, bei + Kollision gewinnt das Layout. Fuer den MCP-Server heisst das: + `set_button_key(key="Z")` trifft die Taste, die auf dieser Tastatur ein + Z tippt (0x1C), nicht mehr die US-Position 0x1D. + - Ein Layoutwechsel zur Laufzeit wird nicht bemerkt (Namen werden einmal + ermittelt und behalten). - Tk-Aufrufe (`self.after()`, Widget-Konfiguration) NIE direkt aus einem Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in diff --git a/README.md b/README.md index bcbb275..0c84f10 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,15 @@ falls gewünscht. 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). Läuft über das Dialogfenster, nicht über - einen System-Hook — vom System abgefangene Kombinationen (Win+L, - Strg+Alt+Entf) kommen deshalb nicht an, und das Dropdown bleibt zum - Nachkorrigieren daneben stehen + drücken (Modifier inklusive). Erkannt wird die *physische* Taste, nicht + das Zeichen — Y/Z, ÄÖÜ, `#`, `+` und `ß` landen also richtig auf dem + Board, auch auf deutschem Layout. Läuft über das Dialogfenster, nicht + über einen System-Hook: vom System abgefangene Kombinationen (Win+L, + Strg+Alt+Entf) kommen nicht an +- **Layoutrichtige Tastennamen** — Beschriftungen kommen vom aktiven + Windows-Layout (`Strg+Z` heißt auf deutscher Tastatur auch `Strg+Z`, und + `ä`/`ö`/`ü` heißen so). Bestehende Belegungen ändern dadurch ihre + Anzeige, nicht ihre Funktion - **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte Makro-Tabelle vom Board. Schritte einzeln erfassen oder die ganze Folge am Stück aufnehmen („⏺ Folge aufnehmen"). Die Slot-Auswahl listet alle 32 @@ -59,14 +64,13 @@ falls gewünscht. Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten) - **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie die offizielle VersaGUI -- **Randloses Fenster** — ohne Windows-Titelleiste, dafür kompakter Kopf - (Modus-Checkboxen direkt neben dem Titel). Verschieben durch Ziehen an - der Kopfzeile, Größe ändern am Anfasser unten rechts, `✕`/`—` legen ins - Tray. Einen Taskleisten-Eintrag gibt es dadurch nicht — das Fenster kommt - über das Tray-Icon zurück. +- **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` ins Tray. + Taste unter dem Mauszeiger kopieren/einfügen, `Esc` minimieren. ## Voraussetzungen @@ -123,7 +127,8 @@ automatisch zuerst nach `%TEMP%` und baut nur dort. | Datei | Zweck | |---|---| -| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text | +| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text, Tastendruck → HID-Keycode | +| `versapad_keylayout.py` | Abfragen ans aktive Windows-Tastaturlayout: physische Tastenposition und Tastenname (optional, nur Windows) | | `versapad_protocol.py` | Binäres NVM-Layout des Boards (740B Config + 512B Makros), CRC16 — pack/unpack | | `versapad_serial.py` | Serial-Client: liest/schreibt Config + Makros per 8-Byte-Paket-Protokoll | | `versapad_combined.py` | Ein-Datei-Format für alle 3 Profile + Makros + lokale Profilnamen | @@ -187,12 +192,10 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`. - Die Tastendruck-Erkennung läuft bewusst über das Dialogfenster statt über einen globalen WinAPI-Hook — vom System abgefangene Kombinationen (Win+L, Strg+Alt+Entf) erreichen das Fenster nie und lassen sich so nicht erfassen -- Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich - aktive Tastatur-Layout. Das betrifft auch die Tastendruck-Erkennung: - HID-Keycodes sind physische US-Tastenpositionen, erkennbar ist ohne - WinAPI aber nur das Zeichen des aktiven Layouts — auf deutschem Layout - landen Y und Z deshalb vertauscht auf dem Board. Das Ergebnis steht immer - sichtbar im Dropdown und lässt sich dort korrigieren +- Ein Wechsel des Tastaturlayouts im laufenden Programm wird nicht bemerkt + (die Tastennamen werden einmal beim ersten Zugriff ermittelt) — Neustart + hilft. Unter Nicht-Windows fällt die Beschriftung auf eine + US-Layout-Näherung zurück - Unsignierte `.exe` — kann von Antivirus/Smart App Control blockiert werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert es aber nicht diff --git a/docs/architecture.md b/docs/architecture.md index c16e23f..66424cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -155,27 +155,57 @@ Drei Checkboxen, unabhängig voneinander: | 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…“. | -### Grenzen der Tastendruck-Erkennung +### Tastendruck-Erkennung: Position statt Zeichen Die Erkennung im Bearbeiten-Dialog nutzt ausschließlich Tk-Events des -fokussierten Fensters. Daraus folgt zweierlei, und beides ist bewusst so: +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. -1. **Nur was das Fenster erreicht, wird erkannt.** Win+L, Strg+Alt+Entf und - andere vom Betriebssystem abgefangene Kombinationen kommen nie an. Ein - globaler `SetWindowsHookEx`-Hook würde sie sehen, ist aber ausgeschlossen - (AV-Fehlalarm-Risiko, siehe `AGENTS.md`). -2. **Die Zuordnung ist eine US-Layout-Näherung.** HID-Keycodes bezeichnen - physische Tastenpositionen des US-Layouts; Tk liefert nur `keysym` und - Windows-Virtual-Key-Code, beide vom *aktiven* Layout abgeleitet. Die - physische Position (Scan-Code) wäre nötig, um das exakt aufzulösen, und - ist ohne WinAPI-Aufruf nicht verfügbar. Praktische Folge auf deutschem - Layout: Y und Z landen vertauscht auf dem Board. Das Ergebnis wird immer - ins Dropdown und in die Modifier-Checkboxen geschrieben und ist dort - korrigierbar — die Erkennung ersetzt die manuelle Auswahl nicht, sie - beschleunigt sie nur. +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: -Details der Zuordnungstabellen: `versapad_data.tk_event_to_hid()` und die -`_TK_*`/`_WIN_VK_TO_HID`-Dicts darüber. +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