From 13c37454797fe825ffe6d12c64e2b32f2d0f676d Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:15:25 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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 |