Compare commits
6 commits
540ce5b1eb
...
83429363c1
| Author | SHA1 | Date | |
|---|---|---|---|
| 83429363c1 | |||
| 7d40fdaa60 | |||
| 09fbd6ad96 | |||
| 82c3fd0056 | |||
| 5d14bdd826 | |||
| 13c3745479 |
14 changed files with 901 additions and 134 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -3,3 +3,7 @@ __pycache__/
|
||||||
*.log
|
*.log
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Nutzer-Config -- landet neben der Installation (versapad_data.app_dir()),
|
||||||
|
# beim Start aus dem Quellcode also direkt hier im Projektordner
|
||||||
|
versapad_config_all.json
|
||||||
|
|
|
||||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"versapad": {
|
||||||
|
"command": "py",
|
||||||
|
"args": ["C:\\Users\\Julian\\Documents\\__CODE\\VersaGUI-py\\versapad_mcp_server.py"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
AGENTS.md
88
AGENTS.md
|
|
@ -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
|
||||||
|
|
|
||||||
81
README.md
81
README.md
|
|
@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein
|
||||||
echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
|
echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
|
||||||
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
|
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
|
||||||
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
|
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
|
||||||
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die
|
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene
|
||||||
Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte
|
Config-Datei (`versapad_config_all.json`) liegt automatisch 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
Write-Host "Pruefe Python-Installation..."
|
||||||
|
py --version
|
||||||
|
Assert-LastExitCode "'py --version' (ist Python installiert und im PATH?)"
|
||||||
|
|
||||||
|
if (-not (Test-Path $requirementsFile)) {
|
||||||
|
throw "requirements.txt nicht gefunden unter $requirementsFile"
|
||||||
|
}
|
||||||
|
Write-Host "Installiere/aktualisiere benoetigte Pakete aus requirements.txt..."
|
||||||
|
py -m pip install --quiet --disable-pip-version-check -r $requirementsFile
|
||||||
|
Assert-LastExitCode "Paketinstallation (pip install -r requirements.txt)"
|
||||||
|
|
||||||
|
if (Test-Path $buildSrc) {
|
||||||
|
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 `
|
py -m PyInstaller --windowed --onedir --name VersaPadViewer `
|
||||||
--icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." `
|
--icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." `
|
||||||
desktop_viewer.py --noconfirm
|
desktop_viewer.py --noconfirm
|
||||||
} finally {
|
Assert-LastExitCode "PyInstaller-Build"
|
||||||
|
} finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Test-Path $localDir) {
|
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
|
Remove-Item $localDir -Recurse -Force
|
||||||
}
|
}
|
||||||
Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse
|
Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse
|
||||||
Remove-Item $buildSrc -Recurse -Force
|
Remove-Item $buildSrc -Recurse -Force
|
||||||
|
|
||||||
Write-Host "Fertig: $localDir\VersaPadViewer.exe"
|
Write-Host ""
|
||||||
|
Write-Host "Fertig: $localDir\VersaPadViewer.exe" -ForegroundColor Green
|
||||||
|
Wait-ForKeyIfInteractive
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "FEHLER: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Wait-ForKeyIfInteractive
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,20 +242,48 @@ class VersaPadViewer(tk.Tk):
|
||||||
self._render()
|
self._render()
|
||||||
|
|
||||||
def _update_tab_labels(self):
|
def _update_tab_labels(self):
|
||||||
for p, btn in self.tab_buttons.items():
|
"""Profilnamen kommen immer aus der kombinierten JSON (nicht mehr
|
||||||
|
hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst
|
||||||
|
per schlankem Datei-Read (kein Board-Zugriff, siehe
|
||||||
|
versapad_combined.profile_names())."""
|
||||||
if self.editing.get() and self.combined:
|
if self.editing.get() and self.combined:
|
||||||
btn.configure(text=self.combined["profile_names"][p])
|
names = self.combined["profile_names"]
|
||||||
else:
|
else:
|
||||||
btn.configure(text=vp.PROFILE_NAMES[p])
|
names = vcomb.read_profile_names()
|
||||||
|
for p, btn in self.tab_buttons.items():
|
||||||
|
btn.configure(text=names[p])
|
||||||
|
|
||||||
def _rename_tab(self, profile):
|
def _rename_tab(self, profile):
|
||||||
if not self.editing.get() or self.combined is None:
|
"""Profilname per Doppelklick auf den Tab umbenennen -- geht in
|
||||||
|
jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische
|
||||||
|
Domänenregeln). Im Programmiermodus wird der bereits geladene
|
||||||
|
In-Memory-State direkt bearbeitet + autosaved; sonst wird die
|
||||||
|
kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf
|
||||||
|
automatisch an, siehe versapad_combined.load_or_fetch())."""
|
||||||
|
if self.editing.get() and self.combined is not None:
|
||||||
|
combined = self.combined
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
combined = vcomb.load_or_fetch()
|
||||||
|
except (OSError, ValueError, KeyError) as e:
|
||||||
|
messagebox.showerror("Umbenennen fehlgeschlagen", str(e))
|
||||||
return
|
return
|
||||||
current = self.combined["profile_names"][profile]
|
|
||||||
|
current = combined["profile_names"][profile]
|
||||||
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
|
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
|
||||||
initialvalue=current, parent=self)
|
initialvalue=current, parent=self)
|
||||||
if name:
|
if not name:
|
||||||
self.combined["profile_names"][profile] = name
|
return
|
||||||
|
combined["profile_names"][profile] = name
|
||||||
|
|
||||||
|
if combined is self.combined:
|
||||||
|
self._autosave_combined()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
vcomb.save_file(combined, vcomb.DEFAULT_PATH)
|
||||||
|
except OSError as e:
|
||||||
|
messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}")
|
||||||
|
return
|
||||||
self._update_tab_labels()
|
self._update_tab_labels()
|
||||||
|
|
||||||
def _show_mcp_info(self):
|
def _show_mcp_info(self):
|
||||||
|
|
@ -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
185
docs/architecture.md
Normal 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
205
docs/data-model.md
Normal 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
136
docs/protocol.md
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
# Serial-Wire-Protokoll
|
||||||
|
|
||||||
|
Beschreibt, wie `versapad_serial.VersaPadLink` mit dem Board spricht. 1:1
|
||||||
|
aus `VersaGUI/src/Protocol.cs` und `VersaMCU/doc/07_serial_protocol.md`
|
||||||
|
übernommen (siehe „Existing-Codebase-Regel“ in
|
||||||
|
[`AGENTS.md`](../AGENTS.md) — bei Änderungen gegen die Firmware-/
|
||||||
|
C#-Quellen abgleichen, nicht aus dem Gedächtnis rekonstruieren). Für die
|
||||||
|
Bedeutung der übertragenen Bytes (Config/Makro-Layout) siehe
|
||||||
|
[`data-model.md`](data-model.md).
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
- USB-CDC (virtueller COM-Port), 115200 Baud.
|
||||||
|
- Board-Erkennung über USB VID:PID `239A:0042` (`versapad_serial.find_port()`
|
||||||
|
durchsucht `serial.tools.list_ports.comports()`).
|
||||||
|
- Der Port ist **exklusiv** — siehe „Nebenläufigkeit“ in
|
||||||
|
[`architecture.md`](architecture.md).
|
||||||
|
- Jedes Paket ist exakt **8 Byte**:
|
||||||
|
|
||||||
|
| Byte | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `[0]` | Command-/Event-ID |
|
||||||
|
| `[1]` | je nach Befehl: Chunk-Index, Chunk-Anzahl, oder ungenutzt |
|
||||||
|
| `[2..7]` | Payload, 6 Byte (`PAYLOAD_SIZE`) |
|
||||||
|
|
||||||
|
## Befehle (Host → Board)
|
||||||
|
|
||||||
|
| Konstante | Wert | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `CMD_READ_STATUS` | `0x06` | Leichtgewichtiger Status-Poll (aktives Profil) |
|
||||||
|
| `CMD_CONFIG_BEGIN` | `0x10` | Start eines Config-Schreibvorgangs |
|
||||||
|
| `CMD_CONFIG_DATA` | `0x11` | Ein Config-Datenpaket |
|
||||||
|
| `CMD_CONFIG_COMMIT` | `0x12` | Config committen (NVM-Schreiben nach Validierung) |
|
||||||
|
| `CMD_CONFIG_READ` | `0x13` | Komplette Config vom Board anfordern |
|
||||||
|
| `CMD_MACRO_BEGIN` | `0x20` | Start eines Makro-Schreibvorgangs |
|
||||||
|
| `CMD_MACRO_DATA` | `0x21` | Ein Makro-Datenpaket |
|
||||||
|
| `CMD_MACRO_COMMIT` | `0x22` | Makros committen |
|
||||||
|
| `CMD_MACRO_READ` | `0x23` | Komplette Makro-Tabelle vom Board anfordern |
|
||||||
|
|
||||||
|
## Events (Board → Host)
|
||||||
|
|
||||||
|
| Konstante | Wert | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `EVT_STATUS` | `0x86` | Antwort auf `CMD_READ_STATUS` |
|
||||||
|
| `EVT_CONFIG_ACK` | `0x90` | Config-Commit erfolgreich |
|
||||||
|
| `EVT_CONFIG_NACK` | `0x91` | Config-Commit abgelehnt (Validierung fehlgeschlagen) |
|
||||||
|
| `EVT_CONFIG_BEGIN` | `0x92` | Board beginnt, Config-Dump zu senden |
|
||||||
|
| `EVT_CONFIG_DATA` | `0x93` | Ein Config-Datenpaket (Antwort auf `CMD_CONFIG_READ`) |
|
||||||
|
| `EVT_CONFIG_END` | `0x94` | Config-Dump vollständig |
|
||||||
|
| `EVT_MACRO_ACK` | `0x95` | Makro-Commit erfolgreich |
|
||||||
|
| `EVT_MACRO_BEGIN` | `0x96` | Board beginnt, Makro-Dump zu senden |
|
||||||
|
| `EVT_MACRO_DATA` | `0x97` | Ein Makro-Datenpaket |
|
||||||
|
| `EVT_MACRO_END` | `0x98` | Makro-Dump vollständig |
|
||||||
|
| `EVT_MACRO_NACK` | `0x99` | Makro-Commit abgelehnt |
|
||||||
|
|
||||||
|
## Ablauf: Lesen (`CONFIG_READ` / `MACRO_READ`)
|
||||||
|
|
||||||
|
Genutzt von `read_full_config()` (740B, ⌈740/6⌉ = 124 Datenpakete) und
|
||||||
|
`read_macros()` (512B, ⌈512/6⌉ = 86 Datenpakete). Deadline 3s.
|
||||||
|
|
||||||
|
```
|
||||||
|
Host → [CMD_CONFIG_READ, 0, 0,0,0,0,0,0]
|
||||||
|
Board → [EVT_CONFIG_BEGIN, ...]
|
||||||
|
Board → [EVT_CONFIG_DATA, chunk_idx=0, <=6B Payload]
|
||||||
|
Board → [EVT_CONFIG_DATA, chunk_idx=1, <=6B Payload]
|
||||||
|
... (124 Pakete insgesamt für Config, 86 für Makros)
|
||||||
|
Board → [EVT_CONFIG_END, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
`chunk_idx` (Byte `[1]`) bestimmt den Ziel-Offset im Empfangspuffer:
|
||||||
|
`offset = chunk_idx * 6`. Kommt `EVT_END`, ohne dass zuvor `EVT_BEGIN`
|
||||||
|
gesehen wurde, gilt das als Timeout (unvollständige/verpasste Antwort).
|
||||||
|
|
||||||
|
## Ablauf: Schreiben (`CONFIG_BEGIN/DATA/COMMIT` bzw. `MACRO_*`)
|
||||||
|
|
||||||
|
Genutzt von `write_full_config()`/`write_macros()`. Deadline 5s. Anzahl
|
||||||
|
Chunks maximal 255 (ein Byte) — bei größeren Blobs schlägt der Aufruf mit
|
||||||
|
`"too_large"` fehl, bevor überhaupt gesendet wird.
|
||||||
|
|
||||||
|
```
|
||||||
|
Host → [CMD_CONFIG_BEGIN, chunk_count, 0,0,0,0,0,0]
|
||||||
|
Host → [CMD_CONFIG_DATA, 0, <=6B Payload (zero-padded)]
|
||||||
|
Host → [CMD_CONFIG_DATA, 1, <=6B Payload]
|
||||||
|
... (ein Paket je Chunk)
|
||||||
|
Host → [CMD_CONFIG_COMMIT, 0, 0,0,0,0,0,0]
|
||||||
|
Board → [EVT_CONFIG_ACK, ...] -- oder EVT_CONFIG_NACK bei fehlgeschlagener Validierung
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sicherheitsnetz:** Die Firmware prüft Magic/Version/CRC (Config) bzw.
|
||||||
|
Keycode-Bereich (Makros) **vor** jedem NVM-Schreiben und antwortet sonst
|
||||||
|
nur mit NACK — ein fehlerhafter Schreibversuch kann das Board laut
|
||||||
|
Firmware-Design nicht in einen inkonsistenten Zustand bringen, siehe
|
||||||
|
`nvm_config_validate()` in den Firmware-Quellen.
|
||||||
|
|
||||||
|
## Ablauf: Leichtgewichtiger Status-Poll (`READ_STATUS`)
|
||||||
|
|
||||||
|
Genutzt von `read_active_profile()` für kontinuierliches Live-Sync-Polling
|
||||||
|
(alle 1,5s). Deadline 1s.
|
||||||
|
|
||||||
|
```
|
||||||
|
Host → [CMD_READ_STATUS, 0, 0,0,0,0,0,0]
|
||||||
|
Board → [EVT_STATUS, active_profile (0-2), ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein einzelnes Antwortpaket statt eines vollen `CONFIG_READ`-Dumps (124
|
||||||
|
Pakete) — Letzterer blockiert die Firmware in `poll_vendor()` lang genug,
|
||||||
|
dass laufende LED-Pulse-Animationen sichtbar stottern. Ältere Firmware
|
||||||
|
ohne `CMD_READ_STATUS` antwortet einfach gar nicht → sauberer Timeout,
|
||||||
|
kein Absturz (siehe `AGENTS.md`, Eintrag „4215323“ in der Historie).
|
||||||
|
|
||||||
|
## Verbindungsaufbau (`VersaPadLink._ensure_open()`)
|
||||||
|
|
||||||
|
1. Board per VID/PID finden (`find_port()`).
|
||||||
|
2. `serial.Serial(port, 115200, timeout=0.5)` öffnen.
|
||||||
|
3. DTR auf `True` setzen, 0,2s warten (Board braucht kurz, bis es nach dem
|
||||||
|
Öffnen/DTR-Toggle wieder reagiert).
|
||||||
|
4. Input-Buffer leeren.
|
||||||
|
|
||||||
|
**Wichtig:** `VersaPadLink` schließt die Verbindung nicht automatisch nach
|
||||||
|
einem Befehl — nur bei `serial.SerialException` (Fehlerfall) oder
|
||||||
|
explizitem `.close()`-Aufruf. Aufrufer, die den exklusiven Port nicht
|
||||||
|
dauerhaft blockieren wollen, müssen selbst schließen (siehe
|
||||||
|
`versapad_mcp_server.py`, das nach jedem Board-Tool-Aufruf `_link.close()`
|
||||||
|
in einem `finally`-Block aufruft).
|
||||||
|
|
||||||
|
## Fehlerzustände (`VersaPadLink.last_error`)
|
||||||
|
|
||||||
|
| Wert | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `None` | kein Fehler |
|
||||||
|
| `"no_pyserial"` | Paket `pyserial` nicht installiert |
|
||||||
|
| `"not_found"` | kein Gerät mit passender VID/PID gefunden |
|
||||||
|
| `"busy"` | Port gefunden, aber Öffnen fehlgeschlagen (von woanders gehalten — VersaGUI, Live-Sync, ein anderer Prozess) |
|
||||||
|
| `"timeout"` | keine (gültige) Antwort innerhalb der Deadline |
|
||||||
|
| `"nack"` | Board hat den Commit explizit abgelehnt (Validierung fehlgeschlagen) |
|
||||||
|
| `"too_large"` | zu sendender Blob braucht mehr als 255 Chunks |
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pyserial
|
||||||
|
pystray
|
||||||
|
pillow
|
||||||
|
mcp
|
||||||
|
pyinstaller
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
try:
|
||||||
combined = fetch_from_board(link=link, profile_names=profile_names)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
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()
|
profile = _link.read_active_profile()
|
||||||
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error}
|
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error}
|
||||||
|
finally:
|
||||||
|
_link.close()
|
||||||
|
|
||||||
|
|
||||||
# ── Buttons (20 pro Profil, MX-Matrix) ───────────────────────────────────────
|
# ── Buttons (20 pro Profil, MX-Matrix) ───────────────────────────────────────
|
||||||
|
|
@ -299,7 +305,9 @@ 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
|
||||||
|
danach wieder (siehe write_to_board() fuer den Grund)."""
|
||||||
|
try:
|
||||||
raw_cfg = _link.read_full_config()
|
raw_cfg = _link.read_full_config()
|
||||||
if raw_cfg is None:
|
if raw_cfg is None:
|
||||||
raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}")
|
raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}")
|
||||||
|
|
@ -315,6 +323,8 @@ def load_from_board() -> dict:
|
||||||
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.
|
||||||
|
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())
|
cfg_bytes, macro_bytes = vcomb.to_binary(_cfg())
|
||||||
if not _link.write_full_config(cfg_bytes):
|
if not _link.write_full_config(cfg_bytes):
|
||||||
raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}")
|
raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}")
|
||||||
if not _link.write_macros(macro_bytes):
|
if not _link.write_macros(macro_bytes):
|
||||||
raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}")
|
raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
finally:
|
||||||
|
_link.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue