Compare commits

..

6 commits

Author SHA1 Message Date
83429363c1 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.
2026-08-14 23:19:13 +02:00
7d40fdaa60 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.
2026-08-14 23:18:32 +02:00
09fbd6ad96 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.
2026-08-14 23:18:08 +02:00
82c3fd0056 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.
2026-08-14 23:17:38 +02:00
5d14bdd826 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.
2026-08-14 23:17:11 +02:00
13c3745479 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.
2026-08-14 23:15:25 +02:00
14 changed files with 901 additions and 134 deletions

4
.gitignore vendored
View file

@ -3,3 +3,7 @@ __pycache__/
*.log *.log
build/ build/
dist/ dist/
# Nutzer-Config -- landet neben der Installation (versapad_data.app_dir()),
# beim Start aus dem Quellcode also direkt hier im Projektordner
versapad_config_all.json

8
.mcp.json Normal file
View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"versapad": {
"command": "py",
"args": ["C:\\Users\\Julian\\Documents\\__CODE\\VersaGUI-py\\versapad_mcp_server.py"]
}
}
}

View file

@ -16,6 +16,12 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
- `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/ - `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/
Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie
(`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`. (`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`.
`app_dir()` liefert das Verzeichnis für die eigene Config
(`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations-
ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten.
`CONFIG_PATHS` bleibt bewusst auf dem OneDrive-Desktop hartkodiert (Lese-
Interop mit einem JSON-Export der offiziellen VersaGUI, kein von diesem
Tool geschriebenes Format, siehe dort).
- `server.py` — stdlib `http.server`, generiert HTML pro Request neu, - `server.py` — stdlib `http.server`, generiert HTML pro Request neu,
Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend, Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend,
kein Programmiermodus (bewusst einfach gehalten). kein Programmiermodus (bewusst einfach gehalten).
@ -39,8 +45,14 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
liefert `READ_STATUS` schlicht Timeout, kein Absturz. liefert `READ_STATUS` schlicht Timeout, kein Absturz.
- `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros + - `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros +
**nur lokal gespeicherte** Profilnamen), Default-Pfad **nur lokal gespeicherte** Profilnamen), Default-Pfad
`~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll: `versapad_data.app_dir()\versapad_config_all.json` (neben der
`CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den kompletten 740B-Block. Installation, nicht mehr hartkodiert auf einer bestimmten Maschine).
Passt zum Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer
den kompletten 740B-Block. `load_or_fetch()` legt bei fehlender Datei
automatisch eine neue an — erst Versuch per Serial vom Board, sonst als
leere `default_combined()` (kein Board noetig fuer die Erstbenutzung).
`read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer
Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz).
**UI:** **UI:**
- `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi
@ -50,14 +62,35 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
`MacroStepsDialog`) für den Programmiermodus. `MacroStepsDialog`) für den Programmiermodus.
**MCP-Server:** **MCP-Server:**
- `versapad_mcp_server.py` — registriert als User-Scope-MCP-Server - `versapad_mcp_server.py` — registriert als projektgebundener MCP-Server
"versapad" (`claude mcp add -s user versapad -- <python> versapad_mcp_server.py`). "versapad" über `.mcp.json` im Projektordner (Alternative:
`claude mcp add -s user versapad -- <python> versapad_mcp_server.py`).
Nutzt MCP-SDK v2 (Paket `mcp`, Klasse `mcp.server.mcpserver.MCPServer` Nutzt MCP-SDK v2 (Paket `mcp`, Klasse `mcp.server.mcpserver.MCPServer`
**nicht** `FastMCP` aus `mcp.server.fastmcp`, das existiert in dieser **nicht** `FastMCP` aus `mcp.server.fastmcp`, das existiert in dieser
SDK-Version nicht mehr, wurde umbenannt). `@mcp.tool()`-dekorierte SDK-Version nicht mehr, wurde umbenannt). `@mcp.tool()`-dekorierte
Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei
älteren FastMCP-Versionen). Tool-Liste: siehe README oder älteren FastMCP-Versionen). Tool-Liste: siehe README oder
`MCP_INFO_TEXT` in `desktop_viewer.py`. `MCP_INFO_TEXT` in `desktop_viewer.py`.
- **Board-Serial-Tools schliessen den Link nach jedem Aufruf** (`get_board_status`,
`load_from_board`, `write_to_board``finally: _link.close()`). Grund:
`VersaPadLink` schliesst nie von selbst, ein einzelner Aufruf hätte sonst
den exklusiven COM-Port dauerhaft für den Rest des MCP-Serverprozesses
blockiert und Live-Sync/VersaGUI/den nächsten Aufruf mit "busy" ausgesperrt
(am 2026-08-14 live so aufgetreten, siehe unten).
- **Bug beobachtet 2026-08-14:** In diesem Agenten-Environment (Claude-Code-
VSCode-Extension) können mehrere unabhängige `versapad_mcp_server.py`-
Prozesse gleichzeitig laufen (bis zu 8 beobachtet, vermutlich durch
wiederholte Tool-Ladevorgänge/Reconnects innerhalb einer Session) — jeder
mit eigenem, nicht geteiltem In-Memory-State (`_state["combined"]`).
Konkret beobachtet: `set_button_*`/`set_macro` + `save_local()` liefen
korrekt auf einem Prozess, ein späterer `write_to_board()`-Aufruf landete
aber auf einem anderen (frischen, leeren) Prozess und schrieb versehentlich
eine leere Default-Config aufs Board, trotz `{"ok": true}`-Antwort. Fix:
vor `write_to_board()` immer erst `load_local()` (liest die Datei frisch
von der Platte, unabhängig davon welcher Prozess antwortet), und nach
jedem Schreibvorgang mit `load_from_board()` + `get_profile()` gegenlesen
statt dem ACK allein zu vertrauen — genau dieses Verify-Pattern hat den
Fehler hier live aufgedeckt.
Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code
nachschlagen — die Dateien sind klein genug, dass eine separate nachschlagen — die Dateien sind klein genug, dass eine separate
@ -93,6 +126,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` vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json`
zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine
Bearbeitung "verschwunden". Bearbeitung "verschwunden".
- **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 - **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und
`desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus `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 hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es
@ -181,8 +242,6 @@ selbst vorgegeben (`SAction.data`), dort beibehalten statt umzubenennen.
## Deferred Work ## Deferred Work
- Volle `docs/`-Baumstruktur (siehe Dokumentation und Verifikation unten —
Projektgröße rechtfertigt das aktuell nicht, kein DB-/API-Dienst)
- Board-seitiges Umschalten des aktiven Profils per Button in der GUI — - Board-seitiges Umschalten des aktiven Profils per Button in der GUI —
explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only
- Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im - Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im
@ -201,13 +260,16 @@ Nicht an diesen Punkten arbeiten, ohne dass der User es explizit anfragt.
- `README.md` ist der Einstiegspunkt (Installation, Nutzung, Architektur- - `README.md` ist der Einstiegspunkt (Installation, Nutzung, Architektur-
Überblick). Überblick).
- `AGENTS.md` (diese Datei) ist die agentenseitige Quelle der Wahrheit für - `AGENTS.md` (diese Datei) ist die agentenseitige Quelle der Wahrheit für
Domänenregeln und Architekturgrenzen — jede Session aktualisieren, die Domänenregeln, Architekturgrenzen und Bug-Historie — jede Session
daran etwas ändert oder etwas Wichtiges lernt. aktualisieren, die daran etwas ändert oder etwas Wichtiges lernt.
- Größeneinschätzung nach Projekt-Dokumentationsstandard: kleines/mittleres - `docs/` (seit 2026-08-14, auf expliziten User-Wunsch) enthält die
Tool ohne eigene Datenbank und ohne persistenten API-Dienst (der menschenlesbare Referenzdoku: `architecture.md` (Schichten, Prozess-/
Browser-Server ist ein einfacher lokaler Lese-Viewer, kein Nebenläufigkeitsmodell, Config-Speicherort), `data-model.md` (JSON-
Mehrbenutzer-Backend) → `README.md` + `AGENTS.md` sind Pflicht und Formate, binäres NVM-Layout, Geometrie, Enums), `protocol.md`
vorhanden, ein voller `docs/`-Baum ist nicht angemessen. (Serial-Wire-Protokoll). Bug-Historie/Domänenregeln bleiben bewusst nur
in `AGENTS.md`, nicht dupliziert in `docs/`. Bei Änderungen am
Binärformat/Protokoll/Datenmodell `docs/data-model.md` bzw.
`docs/protocol.md` mitpflegen.
Prüfungen vor einem Commit an Binärformat/Protokoll: Prüfungen vor einem Commit an Binärformat/Protokoll:
```bash ```bash

View file

@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein
echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original, echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene
Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte Config-Datei (`versapad_config_all.json`) liegt automatisch neben der
Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`, Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu
`versapad_combined.DEFAULT_PATH`) — für einen anderen Rechner dort anpassen. angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale*
Lese-Interop mit den JSON-Exports der offiziellen VersaGUI
(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine
hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen,
falls gewünscht.
## Features ## Features
@ -37,15 +41,18 @@ Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`,
## Voraussetzungen ## Voraussetzungen
- Python 3.11 oder neuer - Python 3.11 oder neuer
- Pakete: `pyserial` (Board-Kommunikation), `pystray` + `pillow` - Alle Pakete stehen in `requirements.txt` (`pyserial` fürs Board,
(Tray-Icon/Desktop-App), optional `mcp` (nur für den MCP-Server) `pystray` + `pillow` fürs Tray-Icon/Desktop-Fenster, `mcp` für den
MCP-Server, `pyinstaller` fürs `.exe`-Bauen):
```bash ```bash
pip install pyserial pystray pillow mcp pip install -r requirements.txt
``` ```
Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek — Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek —
keine zusätzlichen Pakete nötig. keine zusätzlichen Pakete nötig. `build_and_deploy.ps1` installiert
`requirements.txt` beim Bauen automatisch selbst — ein manuelles
`pip install` vorher ist dafür nicht nötig.
## Starten ## Starten
@ -65,8 +72,15 @@ Maschine/Python-Version unterschiedlich). Selbst bauen:
.\build_and_deploy.ps1 .\build_and_deploy.ps1
``` ```
Das Skript baut mit PyInstaller (`--onedir --windowed`, eigenes Icon) und Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus
kopiert das Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. `requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann
mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das
Ergebnis nach `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 **Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen
Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert
PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und
@ -75,8 +89,6 @@ das Nachladen der Bundle-DLLs von einem Netzwerkpfad, ohne jede
Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb
automatisch zuerst nach `%TEMP%` und baut nur dort. automatisch zuerst nach `%TEMP%` und baut nur dort.
Voraussetzung: `pip install pyinstaller` zusätzlich zu den obigen Paketen.
## Aufbau ## Aufbau
| Datei | Zweck | | Datei | Zweck |
@ -94,19 +106,32 @@ Das Binärformat (`versapad_protocol.py`) ist 1:1 aus den
VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
(Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC).
Config-Dateien liegen standardmäßig auf dem Desktop Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile +
(`versapad_config1/2/3.json` für den reinen Lesemodus, Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der
`versapad_config_all.json` für den Programmiermodus — Pfade sind aktuell Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem
hartkodiert für eine bestimmte Windows-Maschine, siehe `CONFIG_PATHS` in Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B.
`versapad_data.py` bzw. `DEFAULT_PATH` in `versapad_combined.py`). frische Installation), wird sie automatisch angelegt — per Serial vom
Board, falls eins angeschlossen ist, sonst als leere Default-Config.
Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich
per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder
Programmiermodus) oder `rename_profile()` (MCP) ändern.
Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool
geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export
der offiziellen VersaGUI (C#/.NET) — Pfad aktuell hartkodiert auf den
OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in
`versapad_data.py`.
## MCP-Server ## MCP-Server
`versapad_mcp_server.py` macht die Config per Tool-Aufruf statt Hand-JSON `versapad_mcp_server.py` macht die Config per Tool-Aufruf statt Hand-JSON
programmierbar — nutzbar von jeder MCP-fähigen KI-Anwendung (Claude Code, programmierbar — nutzbar von jeder MCP-fähigen KI-Anwendung (Claude Code,
Claude Desktop, andere). In der MCP-Server-Liste der jeweiligen Anwendung Claude Desktop, andere). Für Claude Code liegt bereits eine projektgebundene
eintragen: Kommando `python`/`py`, Argument der Pfad zu [`.mcp.json`](.mcp.json) im Repo (Server "versapad", Kommando `py
`versapad_mcp_server.py`. versapad_mcp_server.py`) — beim Öffnen des Projekts wird sie automatisch
zum Verbinden angeboten. Für andere Anwendungen oder user-scope-Registrierung
in der jeweiligen MCP-Server-Liste eintragen: Kommando `python`/`py`,
Argument der Pfad zu `versapad_mcp_server.py`.
Werkzeuge (Auszug): `list_profiles`, `get_profile`, `get_macro`, Werkzeuge (Auszug): `list_profiles`, `get_profile`, `get_macro`,
`get_board_status` (lesen) · `set_button_key`/`_consumer`/`_macro`/ `get_board_status` (lesen) · `set_button_key`/`_consumer`/`_macro`/
@ -131,10 +156,22 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`.
werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert
es aber nicht es aber nicht
## Dokumentation
Ausführlichere technische Doku im [`docs/`](docs/)-Ordner:
- [`docs/architecture.md`](docs/architecture.md) — Schichtenmodell,
Prozessmodell, Modi, Config-Speicherort, Nebenläufigkeit
- [`docs/data-model.md`](docs/data-model.md) — JSON-Formate (kombiniert +
Legacy), binäres NVM-Layout, Geometrie, Enums
- [`docs/protocol.md`](docs/protocol.md) — Serial-Wire-Protokoll (Befehle,
Events, Paketformat, CRC16)
## Weiterentwicklung ## Weiterentwicklung
Tiefere technische Notizen (Protokoll-Details, bekannte Stolpersteine beim Agentenseitige Notizen (Domänenregeln, bekannte Bugs und ihre Fixes,
Bauen, Design-Entscheidungen) stehen in [`AGENTS.md`](AGENTS.md). Implementierungsdisziplin, Design-Entscheidungen) stehen in
[`AGENTS.md`](AGENTS.md).
## Lizenz / Herkunft ## Lizenz / Herkunft

View file

@ -1,5 +1,5 @@
# Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis
# nach lokal (C:\Users\<user>\AppData\Local\VersaPadViewer). # nach $projectDir\dist\VersaPadViewer.
# #
# WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf
# dem Netzlaufwerk (Z:\Git\...): # dem Netzlaufwerk (Z:\Git\...):
@ -12,35 +12,87 @@
# SMB-Share, kombiniert mit dem eh schon langen Projektpfad. # SMB-Share, kombiniert mit dem eh schon langen Projektpfad.
# #
# Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach # Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach
# das fertige Bundle nach %LOCALAPPDATA% kopieren. Z: wird nur zum Lesen der # das fertige Bundle nach $projectDir\dist kopieren. Z: wird nur zum Lesen
# Quelldateien angefasst. # der Quelldateien angefasst.
#
# Fehlerverhalten: Bei Doppelklick im Explorer schliesst sich das Fenster
# sofort nach Skriptende -- ohne Pause waeren Fehlermeldungen unsichtbar
# ("stirbt ohne jede Fehlermeldung"). Deshalb: alles in try/catch, im
# Fehlerfall UND am Ende eine Pause, ausser bei -NoPause (fuer CI/Automation).
param(
[switch]$NoPause
)
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$projectDir = $PSScriptRoot $projectDir = $PSScriptRoot
$buildSrc = "$env:TEMP\versapad_build_src" $buildSrc = "$env:TEMP\versapad_build_src"
$localDir = "$env:LOCALAPPDATA\VersaPadViewer" $localDir = "$projectDir\dist\VersaPadViewer"
$requirementsFile = "$projectDir\requirements.txt"
if (Test-Path $buildSrc) { function Assert-LastExitCode([string]$step) {
Remove-Item $buildSrc -Recurse -Force 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 { try {
py -m PyInstaller --windowed --onedir --name VersaPadViewer ` Write-Host "Pruefe Python-Installation..."
--icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` py --version
desktop_viewer.py --noconfirm Assert-LastExitCode "'py --version' (ist Python installiert und im PATH?)"
} finally {
Pop-Location
}
if (Test-Path $localDir) { if (-not (Test-Path $requirementsFile)) {
Remove-Item $localDir -Recurse -Force throw "requirements.txt nicht gefunden unter $requirementsFile"
} }
Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse Write-Host "Installiere/aktualisiere benoetigte Pakete aus requirements.txt..."
Remove-Item $buildSrc -Recurse -Force 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
}

View file

@ -167,8 +167,8 @@ class VersaPadViewer(tk.Tk):
self.tabs = tk.Frame(self, bg=BG) self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(12, 10)) self.tabs.pack(fill="x", padx=20, pady=(12, 10))
self.tab_buttons = {} self.tab_buttons = {}
for p in sorted(vp.PROFILE_NAMES): for p in range(vp.NUM_PROFILES):
btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT, 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") font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
btn.pack(side="left", padx=(0, 8)) btn.pack(side="left", padx=(0, 8))
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True)) btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
@ -242,21 +242,49 @@ class VersaPadViewer(tk.Tk):
self._render() self._render()
def _update_tab_labels(self): 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(): for p, btn in self.tab_buttons.items():
if self.editing.get() and self.combined: btn.configure(text=names[p])
btn.configure(text=self.combined["profile_names"][p])
else:
btn.configure(text=vp.PROFILE_NAMES[p])
def _rename_tab(self, profile): def _rename_tab(self, profile):
if not self.editing.get() or self.combined is None: """Profilname per Doppelklick auf den Tab umbenennen -- geht in
return jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische
current = self.combined["profile_names"][profile] 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):", name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
initialvalue=current, parent=self) initialvalue=current, parent=self)
if name: if not name:
self.combined["profile_names"][profile] = name return
self._update_tab_labels() 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): def _show_mcp_info(self):
win = tk.Toplevel(self) win = tk.Toplevel(self)
@ -526,16 +554,19 @@ class VersaPadViewer(tk.Tk):
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen. sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
Fehlt sie (z.B. versehentlich geloescht) und ist Live-Sync gerade aus Fehlt sie (z.B. versehentlich geloescht, oder frische Installation
(COM-Port frei), wird sie automatisch per Serial vom Board neu ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird
aufgebaut und als neuer Cache gespeichert -- das Board ist die sie automatisch angelegt -- per Serial vom Board, oder als leere
eigentliche Quelle der Wahrheit, kein Datei-Handling von Hand mehr Default-Config, wenn auch kein Board erreichbar ist (siehe
noetig. Nur wenn das nicht klappt (Board nicht erreichbar, Live-Sync versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz
haelt den Port), weicht es zuletzt auf die klassischen ohne vorhandene Config sofort benutzbar, kein Datei-Handling von
versapad_config{1,2,3}.json aus.""" Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den
if os.path.exists(vcomb.DEFAULT_PATH): COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die
klassischen versapad_config{1,2,3}.json (Export der offiziellen
VersaGUI) aus."""
if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get():
try: try:
data = vcomb.load_file(vcomb.DEFAULT_PATH) data = vcomb.load_or_fetch(link=self._link)
raw = data["profiles"][self.profile] raw = data["profiles"][self.profile]
cfg = vp.annotate_profile({ cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]], "buttons": [dict(b) for b in raw["buttons"]],
@ -544,18 +575,6 @@ class VersaPadViewer(tk.Tk):
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
except (KeyError, IndexError, ValueError): except (KeyError, IndexError, ValueError):
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen 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: try:
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}" return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
except FileNotFoundError as e: except FileNotFoundError as e:
@ -564,6 +583,7 @@ class VersaPadViewer(tk.Tk):
raise FileNotFoundError(f"{e}{hint}") from e raise FileNotFoundError(f"{e}{hint}") from e
def _render(self): def _render(self):
self._update_tab_labels()
for w in self.grid_frame.winfo_children(): for w in self.grid_frame.winfo_children():
w.destroy() w.destroy()
for w in self.enc_frame.winfo_children(): for w in self.enc_frame.winfo_children():

185
docs/architecture.md Normal file
View file

@ -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
`<meta http-equiv="refresh">`. Rein lesend, kein eigener Zustand zwischen
Requests, daher nie „veraltet" im Sinne von In-Memory-Staleness.
### Binär-/Serial-Schicht
- **`versapad_protocol.py`** — pack/unpack für `SDeviceConfig` (740B, alle
3 Profile) und `SMacroTable` (512B, 32 Slots), plus CRC16. 1:1 aus den
Firmware-Structs übernommen, siehe [`data-model.md`](data-model.md).
- **`versapad_serial.py`** — `VersaPadLink`: öffnet bei Bedarf den COM-Port
(per VID/PID-Erkennung), spricht das 8-Byte-Paket-Protokoll, siehe
[`protocol.md`](protocol.md). **Schließt die Verbindung nicht von selbst**
nach einem Befehl — Aufrufer müssen das selbst tun, wenn sie den Port
nicht dauerhaft blockieren wollen (siehe „Nebenläufigkeit" unten).
- **`versapad_combined.py`** — das Ein-Datei-Format: alle 3 Profile +
Makro-Tabelle + lokale Profilnamen in einer JSON
(`versapad_config_all.json`). Bindeglied zwischen den JSON-Strukturen und
den Binärblobs aus `versapad_protocol.py`. Zentrale Funktionen:
- `load_or_fetch()` — bevorzugt die lokale Datei, baut sie bei Bedarf
automatisch neu auf (erst Board-Versuch, sonst leere Default-Config).
- `save_file()`/`load_file()` — reines Lesen/Schreiben der JSON.
- `fetch_from_board()`/`to_binary()` — Konvertierung zu/von den
Binärblobs für Board-Lese-/Schreibvorgänge.
- `read_profile_names()` — liest nur die Profilnamen, ohne Board-Zugriff
(für Tab-Beschriftungen im Nur-Lese-Modus).
### UI
- **`desktop_viewer.py`** — Tkinter-Fenster mit drei unabhängig
umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit
MCP-Doku.
- **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`,
`MacroStepsDialog`) für den Programmiermodus.
### MCP-Server
- **`versapad_mcp_server.py`** — registriert als projektgebundener
MCP-Server "versapad" (`.mcp.json`) oder wahlweise user-scope
(`claude mcp add -s user versapad -- <python> versapad_mcp_server.py`).
Hält einen eigenen In-Memory-Zustand (`_state["combined"]`,
unabhängig von jeder laufenden GUI), der explizit per `save_local()` /
`load_local()` mit der Datei bzw. `load_from_board()` / `write_to_board()`
mit dem Board synchronisiert wird. Board-Serial-Tools schließen die
Verbindung nach jedem Aufruf wieder (siehe unten).
## Config-Speicherort
`versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene
Config-Datei (`versapad_combined.DEFAULT_PATH` =
`app_dir()/versapad_config_all.json`):
- **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).

205
docs/data-model.md Normal file
View file

@ -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).

136
docs/protocol.md Normal file
View file

@ -0,0 +1,136 @@
# Serial-Wire-Protokoll
Beschreibt, wie `versapad_serial.VersaPadLink` mit dem Board spricht. 1:1
aus `VersaGUI/src/Protocol.cs` und `VersaMCU/doc/07_serial_protocol.md`
übernommen (siehe „Existing-Codebase-Regel“ in
[`AGENTS.md`](../AGENTS.md) — bei Änderungen gegen die Firmware-/
C#-Quellen abgleichen, nicht aus dem Gedächtnis rekonstruieren). Für die
Bedeutung der übertragenen Bytes (Config/Makro-Layout) siehe
[`data-model.md`](data-model.md).
## Transport
- USB-CDC (virtueller COM-Port), 115200 Baud.
- Board-Erkennung über USB VID:PID `239A:0042` (`versapad_serial.find_port()`
durchsucht `serial.tools.list_ports.comports()`).
- Der Port ist **exklusiv** — siehe „Nebenläufigkeit“ in
[`architecture.md`](architecture.md).
- Jedes Paket ist exakt **8 Byte**:
| Byte | Bedeutung |
|---|---|
| `[0]` | Command-/Event-ID |
| `[1]` | je nach Befehl: Chunk-Index, Chunk-Anzahl, oder ungenutzt |
| `[2..7]` | Payload, 6 Byte (`PAYLOAD_SIZE`) |
## Befehle (Host → Board)
| Konstante | Wert | Zweck |
|---|---|---|
| `CMD_READ_STATUS` | `0x06` | Leichtgewichtiger Status-Poll (aktives Profil) |
| `CMD_CONFIG_BEGIN` | `0x10` | Start eines Config-Schreibvorgangs |
| `CMD_CONFIG_DATA` | `0x11` | Ein Config-Datenpaket |
| `CMD_CONFIG_COMMIT` | `0x12` | Config committen (NVM-Schreiben nach Validierung) |
| `CMD_CONFIG_READ` | `0x13` | Komplette Config vom Board anfordern |
| `CMD_MACRO_BEGIN` | `0x20` | Start eines Makro-Schreibvorgangs |
| `CMD_MACRO_DATA` | `0x21` | Ein Makro-Datenpaket |
| `CMD_MACRO_COMMIT` | `0x22` | Makros committen |
| `CMD_MACRO_READ` | `0x23` | Komplette Makro-Tabelle vom Board anfordern |
## Events (Board → Host)
| Konstante | Wert | Zweck |
|---|---|---|
| `EVT_STATUS` | `0x86` | Antwort auf `CMD_READ_STATUS` |
| `EVT_CONFIG_ACK` | `0x90` | Config-Commit erfolgreich |
| `EVT_CONFIG_NACK` | `0x91` | Config-Commit abgelehnt (Validierung fehlgeschlagen) |
| `EVT_CONFIG_BEGIN` | `0x92` | Board beginnt, Config-Dump zu senden |
| `EVT_CONFIG_DATA` | `0x93` | Ein Config-Datenpaket (Antwort auf `CMD_CONFIG_READ`) |
| `EVT_CONFIG_END` | `0x94` | Config-Dump vollständig |
| `EVT_MACRO_ACK` | `0x95` | Makro-Commit erfolgreich |
| `EVT_MACRO_BEGIN` | `0x96` | Board beginnt, Makro-Dump zu senden |
| `EVT_MACRO_DATA` | `0x97` | Ein Makro-Datenpaket |
| `EVT_MACRO_END` | `0x98` | Makro-Dump vollständig |
| `EVT_MACRO_NACK` | `0x99` | Makro-Commit abgelehnt |
## Ablauf: Lesen (`CONFIG_READ` / `MACRO_READ`)
Genutzt von `read_full_config()` (740B, ⌈740/6⌉ = 124 Datenpakete) und
`read_macros()` (512B, ⌈512/6⌉ = 86 Datenpakete). Deadline 3s.
```
Host → [CMD_CONFIG_READ, 0, 0,0,0,0,0,0]
Board → [EVT_CONFIG_BEGIN, ...]
Board → [EVT_CONFIG_DATA, chunk_idx=0, <=6B Payload]
Board → [EVT_CONFIG_DATA, chunk_idx=1, <=6B Payload]
... (124 Pakete insgesamt für Config, 86 für Makros)
Board → [EVT_CONFIG_END, ...]
```
`chunk_idx` (Byte `[1]`) bestimmt den Ziel-Offset im Empfangspuffer:
`offset = chunk_idx * 6`. Kommt `EVT_END`, ohne dass zuvor `EVT_BEGIN`
gesehen wurde, gilt das als Timeout (unvollständige/verpasste Antwort).
## Ablauf: Schreiben (`CONFIG_BEGIN/DATA/COMMIT` bzw. `MACRO_*`)
Genutzt von `write_full_config()`/`write_macros()`. Deadline 5s. Anzahl
Chunks maximal 255 (ein Byte) — bei größeren Blobs schlägt der Aufruf mit
`"too_large"` fehl, bevor überhaupt gesendet wird.
```
Host → [CMD_CONFIG_BEGIN, chunk_count, 0,0,0,0,0,0]
Host → [CMD_CONFIG_DATA, 0, <=6B Payload (zero-padded)]
Host → [CMD_CONFIG_DATA, 1, <=6B Payload]
... (ein Paket je Chunk)
Host → [CMD_CONFIG_COMMIT, 0, 0,0,0,0,0,0]
Board → [EVT_CONFIG_ACK, ...] -- oder EVT_CONFIG_NACK bei fehlgeschlagener Validierung
```
**Sicherheitsnetz:** Die Firmware prüft Magic/Version/CRC (Config) bzw.
Keycode-Bereich (Makros) **vor** jedem NVM-Schreiben und antwortet sonst
nur mit NACK — ein fehlerhafter Schreibversuch kann das Board laut
Firmware-Design nicht in einen inkonsistenten Zustand bringen, siehe
`nvm_config_validate()` in den Firmware-Quellen.
## Ablauf: Leichtgewichtiger Status-Poll (`READ_STATUS`)
Genutzt von `read_active_profile()` für kontinuierliches Live-Sync-Polling
(alle 1,5s). Deadline 1s.
```
Host → [CMD_READ_STATUS, 0, 0,0,0,0,0,0]
Board → [EVT_STATUS, active_profile (0-2), ...]
```
Ein einzelnes Antwortpaket statt eines vollen `CONFIG_READ`-Dumps (124
Pakete) — Letzterer blockiert die Firmware in `poll_vendor()` lang genug,
dass laufende LED-Pulse-Animationen sichtbar stottern. Ältere Firmware
ohne `CMD_READ_STATUS` antwortet einfach gar nicht → sauberer Timeout,
kein Absturz (siehe `AGENTS.md`, Eintrag „4215323“ in der Historie).
## Verbindungsaufbau (`VersaPadLink._ensure_open()`)
1. Board per VID/PID finden (`find_port()`).
2. `serial.Serial(port, 115200, timeout=0.5)` öffnen.
3. DTR auf `True` setzen, 0,2s warten (Board braucht kurz, bis es nach dem
Öffnen/DTR-Toggle wieder reagiert).
4. Input-Buffer leeren.
**Wichtig:** `VersaPadLink` schließt die Verbindung nicht automatisch nach
einem Befehl — nur bei `serial.SerialException` (Fehlerfall) oder
explizitem `.close()`-Aufruf. Aufrufer, die den exklusiven Port nicht
dauerhaft blockieren wollen, müssen selbst schließen (siehe
`versapad_mcp_server.py`, das nach jedem Board-Tool-Aufruf `_link.close()`
in einem `finally`-Block aufruft).
## Fehlerzustände (`VersaPadLink.last_error`)
| Wert | Bedeutung |
|---|---|
| `None` | kein Fehler |
| `"no_pyserial"` | Paket `pyserial` nicht installiert |
| `"not_found"` | kein Gerät mit passender VID/PID gefunden |
| `"busy"` | Port gefunden, aber Öffnen fehlgeschlagen (von woanders gehalten — VersaGUI, Live-Sync, ein anderer Prozess) |
| `"timeout"` | keine (gültige) Antwort innerhalb der Deadline |
| `"nack"` | Board hat den Commit explizit abgelehnt (Validierung fehlgeschlagen) |
| `"too_large"` | zu sendender Blob braucht mehr als 255 Chunks |

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
pyserial
pystray
pillow
mcp
pyinstaller

View file

@ -98,8 +98,8 @@ def render_page(profile):
"encoders": [dict(e) for e in raw["encoders"]], "encoders": [dict(e) for e in raw["encoders"]],
}) })
tabs = "".join( tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>' f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(combined["profile_names"][p])}</a>'
for p in sorted(vp.PROFILE_NAMES) for p in range(vp.NUM_PROFILES)
) )
cells = "".join(render_cell(b) for b in cfg["buttons"]) cells = "".join(render_cell(b) for b in cfg["buttons"])
encoders = "".join(render_encoder(e) for e in cfg["encoders"]) encoders = "".join(render_encoder(e) for e in cfg["encoders"])
@ -130,7 +130,7 @@ class Handler(BaseHTTPRequestHandler):
profile = int(query.get("profile", ["0"])[0]) profile = int(query.get("profile", ["0"])[0])
except ValueError: except ValueError:
profile = 0 profile = 0
if profile not in vp.PROFILE_NAMES: if not (0 <= profile < vp.NUM_PROFILES):
profile = 0 profile = 0
try: try:
body = render_page(profile).encode("utf-8") body = render_page(profile).encode("utf-8")

View file

@ -19,7 +19,7 @@ import versapad_data as vp
import versapad_protocol as proto import versapad_protocol as proto
import versapad_serial as vs import versapad_serial as vs
DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json") DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json")
DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
@ -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): def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None):
"""Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich """Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich
geloescht), wird sie automatisch per Serial vom Board neu aufgebaut und geloescht, oder frische Installation ohne jede Config), wird sie
als neuer Cache gespeichert, statt einen Fehler zu werfen -- das Board automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das
behaelt die Config dauerhaft im NVM, die Desktop-JSON ist nur ein behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache
Lesecache dafuer und muss nicht von Hand gepflegt werden. Ist das Board dafuer), und falls auch das Board nicht erreichbar ist (nicht
nicht erreichbar (nicht verbunden, COM-Port belegt), wirft es verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite)
RuntimeError mit Klartext-Ursache -- Aufrufer entscheidet, ob es einen als leere Default-Config (vgl. default_combined()) -- damit ist das
weiteren Fallback gibt (z.B. alte Einzel-JSONs).""" Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit
einem Fehler zu blockieren."""
if os.path.exists(path): if os.path.exists(path):
return load_file(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: try:
save_file(combined, path) save_file(combined, path)
except OSError: 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 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)

View file

@ -7,19 +7,35 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
""" """
import json import json
import os 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 = { CONFIG_PATHS = {
0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"), 0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"),
1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"), 1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"),
2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"), 2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"),
} }
PROFILE_NAMES = {
0: "Profil 0 Windows",
1: "Profil 1 Fusion 360",
2: "Profil 2 BricsCAD",
}
# index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge) # index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge)
GRID_COLS = 4 GRID_COLS = 4
GRID_ROWS = 5 GRID_ROWS = 5

View file

@ -116,9 +116,15 @@ def get_macro(slot: int) -> dict:
def get_board_status() -> dict: def get_board_status() -> dict:
"""Prueft per Serial, ob das Board erreichbar ist und welches Profil dort """Prueft per Serial, ob das Board erreichbar ist und welches Profil dort
gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der
Tkinter-Viewer den COM-Port gerade halten.""" Tkinter-Viewer den COM-Port gerade halten. Schliesst die Verbindung
profile = _link.read_active_profile() danach wieder (siehe write_to_board() fuer den Grund) -- der Port
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} 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) ─────────────────────────────────────── # ── 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 """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die
kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade
von VersaGUI/dem Tkinter-Viewer gehalten wird.""" von VersaGUI/dem Tkinter-Viewer gehalten wird. Schliesst die Verbindung
raw_cfg = _link.read_full_config() danach wieder (siehe write_to_board() fuer den Grund)."""
if raw_cfg is None: try:
raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") raw_cfg = _link.read_full_config()
raw_macros = _link.read_macros() if raw_cfg is None:
if raw_macros is None: raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}")
raise RuntimeError(f"Makros 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) cfg_dict = vproto.unpack_config(raw_cfg)
if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]):
raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)")
macro_slots = vproto.unpack_macros(raw_macros) macro_slots = vproto.unpack_macros(raw_macros)
names = _cfg()["profile_names"] names = _cfg()["profile_names"]
_state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
return list_profiles() return list_profiles()
finally:
_link.close()
@mcp.tool() @mcp.tool()
@ -322,13 +332,21 @@ def write_to_board() -> dict:
"""Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs """Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs
Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft
Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit
NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.""" NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.
cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) Schliesst die Verbindung danach wieder -- VersaPadLink haelt den Port
if not _link.write_full_config(cfg_bytes): sonst dauerhaft offen (kein automatisches Schliessen nach einem Befehl),
raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") was Live-Sync/VersaGUI/den naechsten MCP-Aufruf sonst dauerhaft mit
if not _link.write_macros(macro_bytes): "busy" blockieren wuerde, obwohl der eigentliche Vorgang laengst fertig
raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") ist -- der Port ist exklusiv (siehe versapad_serial.py)."""
return {"ok": True} 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__": if __name__ == "__main__":