Merge branch 'main' into dev/jappel

Reconciles two independent lines of work: main cherry-picked and then
extended dev/jappel's build-script/config-portability/rename-tab/
COM-port fixes, additionally fixing a config-loss bug (rebuild wiped
the config when it lived in the install dir -- moved to roaming
%APPDATA% instead) and adding free-text notes per action plus a
frameless resizable window. dev/jappel keeps its .mcp.json registration
and docs/ reference tree, which main deliberately left out.

Conflict resolutions favored main's versions where the two sides solved
the same problem (config location, build deploy target, COM-port
release, tab rename) since main's fixes were validated against a real
rebuild-wipes-config incident. docs/architecture.md and
docs/data-model.md updated to describe the resulting %APPDATA% config
path and the note field.
This commit is contained in:
Julian Appel 2026-08-15 18:16:00 +02:00
commit 189ec01e68
11 changed files with 470 additions and 125 deletions

View file

@ -93,9 +93,8 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
Fehler hier live aufgedeckt. Fehler hier live aufgedeckt.
Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code
nachschlagen — die Dateien sind klein genug, dass eine separate nachschlagen. Menschenlesbare Referenzdoku (Architektur, Datenmodell,
`docs/current-architecture.md` hier keinen Mehrwert hätte (siehe Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten.
Dokumentation und Verifikation unten für die Größeneinschätzung).
## Kritische Domänenregeln ## Kritische Domänenregeln
@ -114,6 +113,18 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
(`profile_names`) — die Firmware-Structs haben keinen Platz für einen (`profile_names`) — die Firmware-Structs haben keinen Platz für einen
String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie
landen nie aufs Board, egal welcher Schreibpfad benutzt wird. landen nie aufs Board, egal welcher Schreibpfad benutzt wird.
- **Notizen** (freier Text je Button/Encoder-Aktion, seit 2026-08-15) sind
aus demselben Grund rein lokal: leben im `"note"`-Feld JEDER Action
(`{"type","data","note"}`), nicht in einer separaten Struktur. `to_binary`/
`pack_config` ignorieren das Feld beim Schreiben (liest nur type/data),
`from_binary` liefert frisch vom Board immer `note=""` (Board kennt keine
Notizen) — `versapad_combined.merge_notes(neu, alt)` kopiert bestehende
Notizen nach jedem `load_from_board()`/`fetch_from_board()` zurück, sonst
gingen sie bei jedem Board-Refresh verloren. Action-Typ wechseln
(`set_button_key` etc.) darf die Notiz NICHT loeschen (baut die neue
Action ueber `_replace_action()`/`dlg.action["note"]` mit der alten Notiz),
nur `set_button_note()`/`set_encoder_note()`/das Notiz-Feld im
Programmiermodus-Dialog aendern sie gezielt.
- Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich - Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich
gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten
gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im
@ -126,6 +137,28 @@ 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".
- **Config-Pfad darf NIE im Installationsverzeichnis liegen (2026-08-15,
Datenverlust-Bug):** `build_and_deploy.ps1` räumt sein Zielverzeichnis vor
jedem Deploy komplett ab (`Remove-Item $localDir -Recurse -Force`). Nachdem
`app_dir()` am selben Tag auf genau dieses Verzeichnis gezeigt hatte, hat
**jeder Rebuild die Nutzer-Config mitgelöscht**; die App hat sie danach
kommentarlos leer vom Board neu aufgebaut (`load_or_fetch()`
`fetch_from_board()`), wodurch alle Notizen weg waren. Besonders tückisch:
Board-Bindings und LEDs sahen danach völlig normal aus, nur die rein
lokalen Felder (Notizen, Profilnamen) fehlten -- der Schaden ist also
unsichtbar, wenn man nur aufs Grid schaut. Fix: `app_dir()` liefert jetzt
`%APPDATA%\VersaPadViewer` (Roaming), getrennt vom Installationsordner in
`%LOCALAPPDATA%`; zusätzlich rettet `build_and_deploy.ps1` vorgefundene
`versapad_config*.json` aus dem Zielverzeichnis über den Deploy hinweg
(für Altinstallationen). **Regel für künftige Änderungen: Programm- und
Datenverzeichnis nie zusammenlegen, egal wie praktisch "alles an einem
Ort" klingt.**
- **Nur EIN Config-Pfad, unabhängig vom Startweg (2026-08-15):** `app_dir()`
hat bewusst KEINEN `sys.frozen`-Zweig mehr. Vorher sah die gebaute `.exe`
eine andere Datei als der aus dem Quellcode gestartete MCP-Server, was zu
zwei auseinanderlaufenden Configs führte (die `.exe` zeigte ein anderes
Profil 0 als der MCP-Server meldete). Wer hier wieder nach Startweg
unterscheidet, baut denselben Bug erneut ein.
- **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim - **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim
Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei
fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei
@ -140,7 +173,10 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
Funktion) liefert bei der gebauten `.exe` deren Installationsordner Funktion) liefert bei der gebauten `.exe` deren Installationsordner
(`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`- (`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`-
Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben
der laufenden Installation. `versapad_data.CONFIG_PATHS` (Lese-Interop der laufenden Installation. **Diese app_dir()-Variante ist seit
2026-08-15 überholt und war aktiv schädlich, siehe oben "Config-Pfad
darf nie im Installationsverzeichnis liegen".**
`versapad_data.CONFIG_PATHS` (Lese-Interop
mit der C#-VersaGUI) bleibt bewusst auf dem Desktop, siehe oben. (3) Fehlte 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 die Config UND war kein Board erreichbar, blockierte das Tool mit einer
Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei
@ -154,6 +190,18 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]` Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]`
bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der
Wahrheit für alle Frontends. Wahrheit für alle Frontends.
- **Herkunft der drei Punkte oben + Tab-Umbenennen-Fix:** ursprünglich per
Cherry-Pick aus Julian Appels `dev/jappel`-Branch auf `main` übernommen
(der sich zeitgleich mit dem eigenen COM-Port-Fix entwickelt hatte, kein
Fork-Sync-Automatismus -- manuell gegengelesen). Auf `main` bewusst NICHT
übernommen wurde damals der `dist/VersaPadViewer`-Build-Zielpfad (statt
`%LOCALAPPDATA%\VersaPadViewer`) sowie `.mcp.json` und die `docs/*.md`-
Referenzdateien. **2026-08-15: `main` wurde zurück in `dev/jappel`
gemerged**, damit landen beide Linien wieder in einem Branch -- inklusive
`.mcp.json`, `docs/*.md` und dem `%LOCALAPPDATA%`-Zielpfad (der hat sich
im Merge durchgesetzt, siehe Config-Pfad-Bullets oben). Der Notes-Feature-
Teil dieser Historie ist der Grund, warum `docs/data-model.md` das
`"note"`-Feld nachträglich braucht.
- **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und - **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
@ -180,6 +228,24 @@ Dokumentation und Verifikation unten für die Größeneinschätzung).
`vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf `vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf
`default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer `default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer
ALLE 3 Profile prüfen, nicht nur das gemeldete. ALLE 3 Profile prüfen, nicht nur das gemeldete.
- **Randloses Fenster (seit 2026-08-15):** Die Titelleiste ist per reinem Tk
`overrideredirect(True)` ausgeblendet -- **niemals** per ctypes/WinAPI
nachhelfen (kein `SetWindowLongW`/`ShowWindow` auf das eigene Fenster),
siehe Speicher-Notiz "keine Selbstversteck-Fenstertricks": genau diese
Kombination hat in einem anderen Projekt Bitdefender-Fehlalarme
ausgelöst. Konsequenzen, die mitgebaut werden müssen: kein
Taskleisten-Eintrag (Rückweg nur über das Tray-Icon), kein Ziehen am
Rahmen (Kopfzeile ist deshalb per `<B1-Motion>` verschiebbar), keine
System-Buttons (eigene `✕`/`—` in der Kopfzeile, beide legen ins Tray)
und **keine Resize-Ränder**.
- **Der Größen-Anfasser hängt per `place()` an der Fensterecke, nicht
gepackt am Ende des Inhalts.** Gepackt verschwindet er, sobald das Fenster
kleiner als der Inhalt ist -- also exakt dann, wenn man ihn zum
Vergrößern bräuchte. `MIN_W/MIN_H` begrenzen das Verkleinern.
- Fensterhöhe muss zum Karteninhalt passen: seit die Karten eine Notizzeile
haben (`CARD_H` 84 → 114) braucht das Fenster ~960px, sonst liegen
Encoder-Bereich und Fußzeile unterhalb des Rands. Wer `CARD_H` ändert,
muss `geometry()` mit anpassen.
- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein - HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein
Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm
wie bei den Fensterverstecktricks in anderen Projekten zu riskieren). wie bei den Fensterverstecktricks in anderen Projekten zu riskieren).
@ -230,6 +296,28 @@ Byte-Identität), nicht nur gegen Beispieldaten. Vor jedem Schreibvorgang
aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor
echte Änderungen geschrieben werden. echte Änderungen geschrieben werden.
**Änderungen an der Config immer aus der Sicht prüfen, die die laufende App
hat (2026-08-15 gelernt):** Claudes Bash- und PowerShell-Werkzeuge sehen
unter `C:\Users\chris\AppData\...` teilweise *unterschiedliche* Dateien —
derselbe Pfad lieferte gleichzeitig 29826 Bytes (Bash/MCP-Server) und
28455 Bytes (PowerShell/`.exe`). Konkret heißt das: **Config-Schreibvorgänge
über den versapad-MCP-Server (`save_local()`) erreichen die installierte
`.exe` nicht zuverlässig.** Das hat eine Fehlersuche über viele Runden
verschleppt, weil "Notizen sind in der Datei" (Bash) und "App zeigt keine
Notizen" gleichzeitig stimmten. Vorgehen:
- Config-Dateien, die die installierte App lesen soll, über **PowerShell**
schreiben/prüfen (`Get-Item`, dort ausgeführtes `python`), nicht über Bash.
- Bei "Änderung wirkt nicht"-Symptomen als Erstes Dateigröße/mtime aus
*beiden* Sichten vergleichen, bevor Code-Ursachen gesucht werden.
- `Z:\Git\...` (SMB-Share) ist von diesem Effekt nicht betroffen — Quellcode
und Build verhalten sich normal.
Verlässlich zum Ziel führt bei solchen Widersprüchen ein temporäres
Diagnose-Modul, das im gebauten Bundle mitläuft und `app_dir()`,
`DEFAULT_PATH`, `os.stat()` und den tatsächlich geladenen JSON-Inhalt in eine
Datei schreibt — damit war die Ursache in einem Durchlauf sichtbar, nachdem
Vermutungen mehrfach danebenlagen.
## Naming ## Naming
Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`, Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`,

View file

@ -12,9 +12,9 @@ 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 eigene Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene
Config-Datei (`versapad_config_all.json`) liegt automatisch neben der Config-Datei (`versapad_config_all.json`) liegt automatisch unter
Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu `%APPDATA%\VersaPadViewer\` (siehe „Aufbau" unten) und wird bei Bedarf
angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* automatisch neu angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale*
Lese-Interop mit den JSON-Exports der offiziellen VersaGUI Lese-Interop mit den JSON-Exports der offiziellen VersaGUI
(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine (`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine
hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen, hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen,
@ -33,10 +33,19 @@ falls gewünscht.
oder als Datei speichern oder als Datei speichern
- **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte - **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte
Makro-Tabelle vom Board Makro-Tabelle vom Board
- **Notizen** — freier Text pro Button/Encoder-Aktion, was sie tatsächlich
tut (z.B. "Speichern in Fusion 360"), zusätzlich zur automatischen
Beschriftung ("Strg+S"). Rein lokal wie Profilnamen, geht nie aufs Board,
bleibt beim Tastenwechsel und beim "Vom Board laden" erhalten
- **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per - **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per
Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten) Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten)
- **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie - **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie
die offizielle VersaGUI die offizielle VersaGUI
- **Randloses Fenster** — ohne Windows-Titelleiste, dafür kompakter Kopf
(Modus-Checkboxen direkt neben dem Titel). Verschieben durch Ziehen an
der Kopfzeile, Größe ändern am Anfasser unten rechts, `✕`/`—` legen ins
Tray. Einen Taskleisten-Eintrag gibt es dadurch nicht — das Fenster kommt
über das Tray-Icon zurück.
## Voraussetzungen ## Voraussetzungen
@ -75,7 +84,7 @@ Maschine/Python-Version unterschiedlich). Selbst bauen:
Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus
`requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann `requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann
mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das
Ergebnis nach `dist\VersaPadViewer\VersaPadViewer.exe` im Projektordner. Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`.
Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das
Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich
bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause` bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause`
@ -107,14 +116,20 @@ 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).
Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile + Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile +
Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der Makros + lokale Profilnamen + Notizen, siehe `versapad_combined.py`) liegt
Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem unter `%APPDATA%\VersaPadViewer\` (`versapad_data.app_dir()`), also
Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B. **getrennt vom Installationsordner** und unabhängig davon, ob die `.exe`
frische Installation), wird sie automatisch angelegt — per Serial vom oder der Quellcode gestartet wurde. Beides ist Absicht: das Build-Skript
räumt sein Zielverzeichnis vor jedem Deploy komplett ab (läge die Config
dort, würde jeder Rebuild sie löschen), und ein vom Startweg abhängiger
Pfad hatte zu zwei auseinanderlaufenden Configs geführt. Fehlt die Datei
(z.B. frische Installation), wird sie automatisch angelegt — per Serial vom
Board, falls eins angeschlossen ist, sonst als leere Default-Config. Board, falls eins angeschlossen ist, sonst als leere Default-Config.
Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich
per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder
Programmiermodus) oder `rename_profile()` (MCP) ändern. Programmiermodus) oder `rename_profile()` (MCP) ändern. Notizen ebenso —
im Bearbeiten-Dialog des Programmiermodus oder per
`set_button_note()`/`set_encoder_note()` (MCP).
Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool
geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export

View file

@ -124,7 +124,9 @@ class MacroStepsDialog(_ModalDialog):
class ActionEditDialog(_ModalDialog): class ActionEditDialog(_ModalDialog):
"""Ergebnis in self.action / self.led nach run()==True. led bleibt None """Ergebnis in self.action / self.led nach run()==True. led bleibt None
wenn led_in None war (Encoder -- keine eigene Farbe).""" wenn led_in None war (Encoder -- keine eigene Farbe). self.action enthaelt
immer ein "note"-Feld (freie Notiz, was die Aktion tut -- rein lokal wie
Profilnamen, geht nie aufs Board)."""
def __init__(self, parent, title, action, led, macros): def __init__(self, parent, title, action, led, macros):
super().__init__(parent, title) super().__init__(parent, title)
@ -134,8 +136,15 @@ class ActionEditDialog(_ModalDialog):
self.led = None self.led = None
row = 0 row = 0
note_row = tk.Frame(self, bg=BG2)
note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1
tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
self._note_var = tk.StringVar(value=action.get("note", ""))
tk.Entry(note_row, textvariable=self._note_var, width=36, bg=BG, fg=TEXT,
insertbackground=TEXT, relief="flat").pack(side="left", padx=6)
tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid( tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid(
row=row, column=0, sticky="w", padx=12, pady=(12, 4)); row += 1 row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1
self._type_var = tk.StringVar(value=action["type"]) self._type_var = tk.StringVar(value=action["type"])
type_frame = tk.Frame(self, bg=BG2) type_frame = tk.Frame(self, bg=BG2)
@ -320,6 +329,7 @@ class ActionEditDialog(_ModalDialog):
else: else:
action = {"type": "None", "data": 0} action = {"type": "None", "data": 0}
action["note"] = self._note_var.get()
self.action = action self.action = action
if self._led is not None: if self._led is not None:
r, g, b = self._led_color r, g, b = self._led_color

View file

@ -1,5 +1,8 @@
# Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis
# nach $projectDir\dist\VersaPadViewer. # nach %LOCALAPPDATA%\VersaPadViewer (nicht $projectDir\dist wie im
# jappel-Upstream -- hier bereits die tatsaechlich installierte/genutzte
# Instanz, siehe Speicher-Notiz "VersaPad Viewer Tool"; ein Pfadwechsel
# wuerde bestehende Verknuepfungen/Autostart-Eintraege brechen).
# #
# WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf
# dem Netzlaufwerk (Z:\Git\...): # dem Netzlaufwerk (Z:\Git\...):
@ -27,7 +30,7 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$projectDir = $PSScriptRoot $projectDir = $PSScriptRoot
$buildSrc = "$env:TEMP\versapad_build_src" $buildSrc = "$env:TEMP\versapad_build_src"
$localDir = "$projectDir\dist\VersaPadViewer" $localDir = "$env:LOCALAPPDATA\VersaPadViewer"
$requirementsFile = "$projectDir\requirements.txt" $requirementsFile = "$projectDir\requirements.txt"
function Assert-LastExitCode([string]$step) { function Assert-LastExitCode([string]$step) {
@ -80,10 +83,29 @@ try {
throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen." throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen."
} }
# Zielverzeichnis abraeumen, aber NIE Nutzerdaten mitloeschen: die Config
# liegt zwar inzwischen woanders (Roaming-AppData, siehe
# versapad_data.app_dir()), aeltere Installationen haben sie aber noch
# hier liegen -- ohne diese Sicherung loescht jeder Rebuild sie mit
# (am 2026-08-15 genau so passiert, alle Notizen weg).
$userData = Get-ChildItem $localDir -Filter "versapad_config*.json" -File -ErrorAction SilentlyContinue
$rescued = @()
foreach ($f in $userData) {
$tmp = Join-Path $env:TEMP $f.Name
Copy-Item $f.FullName $tmp -Force
$rescued += @{ Tmp = $tmp; Name = $f.Name }
Write-Host "Nutzerdatei gesichert: $($f.Name)" -ForegroundColor Yellow
}
if (Test-Path $localDir) { if (Test-Path $localDir) {
Remove-Item $localDir -Recurse -Force Remove-Item $localDir -Recurse -Force
} }
Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse
foreach ($r in $rescued) {
Copy-Item $r.Tmp (Join-Path $localDir $r.Name) -Force
Remove-Item $r.Tmp -Force
}
Remove-Item $buildSrc -Recurse -Force Remove-Item $buildSrc -Recurse -Force
Write-Host "" Write-Host ""

View file

@ -54,7 +54,10 @@ OK_GREEN = "#3ecf6e"
WARN_RED = "#e0895a" WARN_RED = "#e0895a"
POLL_MS = 1500 POLL_MS = 1500
CARD_W, CARD_H = 150, 84 CARD_W, CARD_H = 150, 114
# Untergrenze beim Ziehen am Anfasser -- knapp unter der Groesse, die das
# 4x5-Grid + Encoder mindestens brauchen, damit nichts abgeschnitten wird.
MIN_W, MIN_H = 700, 500
SERIAL_POLL_S = 1.5 SERIAL_POLL_S = 1.5
SERIAL_IDLE_S = 3.0 SERIAL_IDLE_S = 3.0
@ -97,9 +100,11 @@ Buttons setzen (profile 0-2, index 0-19):
set_button_profile_switch(profile, index, target) set_button_profile_switch(profile, index, target)
set_button_none(profile, index) set_button_none(profile, index)
set_button_led(profile, index, r, g, b, anim, period_ms) set_button_led(profile, index, r, g, b, anim, period_ms)
set_button_note(profile, index, note) freie Notiz, was der Button tut
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'): Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
set_encoder_key / _consumer / _macro / _profile_switch / _none(...) set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
set_encoder_note(profile, index, field, note)
Makro: Makro:
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...] set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
@ -122,7 +127,11 @@ class VersaPadViewer(tk.Tk):
super().__init__() super().__init__()
self.title("VersaPad Steuermatrix") self.title("VersaPad Steuermatrix")
self.configure(bg=BG) self.configure(bg=BG)
self.geometry("760x760") # Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile
# fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114)
# reichten die alten 760px nicht mehr: Encoder und der Groessen-
# Anfasser lagen unterhalb des Fensterrands und waren unerreichbar.
self.geometry("790x960")
self.profile = 0 self.profile = 0
self._mtimes = {} self._mtimes = {}
self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt
@ -149,55 +158,66 @@ class VersaPadViewer(tk.Tk):
) )
self._tray_icon.run_detached() self._tray_icon.run_detached()
header = tk.Frame(self, bg=BG) # Titelleiste ausgeblendet (reines Tk `overrideredirect`, KEINE
header.pack(fill="x", padx=20, pady=(18, 4)) # ctypes/WinAPI-Fenstertricks -- siehe Speicher-Notiz "keine
tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT, # Selbstversteck-Fenstertricks": das Nachruesten eines Taskleisten-
font=("Segoe UI", 15, "bold")).pack(anchor="w") # Icons per SetWindowLongW hat frueher AV-Fehlalarme ausgeloest).
self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG, # Ersatz fuer die fehlende Systemleiste: Kopfzeile ist ziehbar, und
fg=TEXT_DIM, font=("Segoe UI", 9)) # die Buttons rechts uebernehmen Minimieren/Schliessen (beides ins
self.header_sub.pack(anchor="w") # Tray, wie vorher schon das X der echten Titelleiste).
self.overrideredirect(True)
info_btn = tk.Label(header, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14), self.toggles_row = header = tk.Frame(self, bg=BG)
cursor="hand2") header.pack(fill="x", padx=20, pady=(10, 6))
info_btn.place(relx=1.0, x=0, y=-4, anchor="ne") title = tk.Label(header, text="VersaPad", bg=BG, fg=TEXT,
font=("Segoe UI", 11, "bold"))
title.pack(side="left", anchor="w", padx=(0, 16))
for widget in (header, title):
widget.bind("<Button-1>", self._start_move)
widget.bind("<B1-Motion>", self._on_move)
for text in ("", ""):
btn = tk.Label(header, text=text, bg=BG, fg=TEXT_DIM,
font=("Segoe UI", 11), cursor="hand2", padx=6)
btn.pack(side="right")
btn.bind("<Button-1>", lambda e: self._hide_to_tray())
btn.bind("<Enter>", lambda e, b=btn: b.configure(fg=TEXT))
btn.bind("<Leave>", lambda e, b=btn: b.configure(fg=TEXT_DIM))
info_btn = tk.Label(header, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12),
cursor="hand2", padx=6)
info_btn.pack(side="right", padx=(0, 8))
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info()) info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT)) info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM)) info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
self.tabs = tk.Frame(self, bg=BG) # Modus-Umschalter direkt neben der Ueberschrift statt in eigener
self.tabs.pack(fill="x", padx=20, pady=(12, 10)) # Zeile -- spart eine komplette Zeile Fensterhoehe.
self.tab_buttons = {}
for p in range(vp.NUM_PROFILES):
btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT,
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
btn.pack(side="left", padx=(0, 8))
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
self.tab_buttons[p] = btn
toggles_row = tk.Frame(self, bg=BG)
toggles_row.pack(fill="x", padx=20, pady=(0, 6))
self.sync_check = tk.Checkbutton( self.sync_check = tk.Checkbutton(
toggles_row, text="Live-Sync mit Board", variable=self.live_sync, header, text="Live-Sync", variable=self.live_sync,
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG, command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
self.sync_check.pack(side="left") self.sync_check.pack(side="left")
self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.sync_status.pack(side="left", padx=(8, 20)) self.sync_status.pack(side="left", padx=(4, 14))
self.edit_check = tk.Checkbutton( self.edit_check = tk.Checkbutton(
toggles_row, text="Programmiermodus", variable=self.editing, header, text="Programmiermodus", variable=self.editing,
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG, command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
self.edit_check.pack(side="left", padx=(0, 20)) self.edit_check.pack(side="left", padx=(0, 14))
self.always_on_top = tk.BooleanVar(value=False) self.always_on_top = tk.BooleanVar(value=False)
self.topmost_check = tk.Checkbutton( self.topmost_check = tk.Checkbutton(
toggles_row, text="Immer im Vordergrund", variable=self.always_on_top, header, text="Immer im Vordergrund", variable=self.always_on_top,
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG, command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
self.topmost_check.pack(side="left") self.topmost_check.pack(side="left")
# Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im
# Programmiermodus (siehe _on_toggle_editing), direkt unter den
# Checkboxen statt weit unten zwischen Tabs und Matrix.
self.prog_row = tk.Frame(self, bg=BG) self.prog_row = tk.Frame(self, bg=BG)
for text, cmd in ( for text, cmd in (
("Vom Board laden", self._load_from_board), ("Vom Board laden", self._load_from_board),
@ -207,21 +227,52 @@ class VersaPadViewer(tk.Tk):
): ):
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT, tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
activebackground=ACCENT, activeforeground="#fff", relief="flat", activebackground=ACCENT, activeforeground="#fff", relief="flat",
padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6))
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.prog_status.pack(side="left", padx=(8, 0)) self.prog_status.pack(side="left", padx=(8, 0))
# prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing) # prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing)
# Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am
# Fensterkopf -- naeher an dem, was sie auswaehlen.
self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(8, 8))
self.tab_buttons = {}
for p in range(vp.NUM_PROFILES):
btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT,
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
btn.pack(side="left", padx=(0, 8))
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
self.tab_buttons[p] = btn
self.grid_frame = tk.Frame(self, bg=BG) self.grid_frame = tk.Frame(self, bg=BG)
self.grid_frame.pack(padx=20, pady=(8, 0)) self.grid_frame.pack(padx=20, pady=(0, 0), anchor="w")
tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM, tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM,
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8)) font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8))
self.enc_frame = tk.Frame(self, bg=BG) self.enc_frame = tk.Frame(self, bg=BG)
self.enc_frame.pack(fill="x", padx=20) self.enc_frame.pack(fill="x", padx=20)
self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) # Fusszeile + Anfasser zum Groessenaendern: mit ausgeblendeter
self.footer.pack(anchor="w", padx=20, pady=(20, 10)) # Titelleiste (overrideredirect) entfernt Windows auch die
# Fensterraender, an denen man sonst zieht -- ohne diesen Griff
# liesse sich das Fenster gar nicht mehr skalieren.
footer_row = tk.Frame(self, bg=BG)
footer_row.pack(fill="x", padx=20, pady=(14, 8))
self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.footer.pack(side="left", anchor="w")
# Der Griff haengt per place() an der FENSTER-Ecke, nicht am Ende des
# gepackten Inhalts: sonst wandert er mit dem Inhalt aus dem Bild,
# sobald das Fenster kleiner als der Inhalt ist -- also genau dann,
# wenn man ihn zum Vergroessern braucht.
grip = tk.Label(self, text="", bg=BG, fg=TEXT_DIM,
font=("Segoe UI", 11), cursor="sizing")
grip.place(relx=1.0, rely=1.0, anchor="se", x=-3, y=-1)
grip.bind("<Button-1>", self._start_resize)
grip.bind("<B1-Motion>", self._on_resize)
grip.bind("<Enter>", lambda e: grip.configure(fg=TEXT))
grip.bind("<Leave>", lambda e: grip.configure(fg=TEXT_DIM))
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
self.bind("<Unmap>", self._on_unmap) self.bind("<Unmap>", self._on_unmap)
@ -325,6 +376,25 @@ class VersaPadViewer(tk.Tk):
def _on_toggle_topmost(self): def _on_toggle_topmost(self):
self.attributes("-topmost", self.always_on_top.get()) self.attributes("-topmost", self.always_on_top.get())
# ── Fenster ziehen (Ersatz fuer die ausgeblendete Titelleiste) ──
def _start_move(self, event):
self._drag_origin = (event.x_root - self.winfo_x(), event.y_root - self.winfo_y())
def _on_move(self, event):
dx, dy = self._drag_origin
self.geometry(f"+{event.x_root - dx}+{event.y_root - dy}")
def _start_resize(self, event):
self._resize_origin = (event.x_root, event.y_root,
self.winfo_width(), self.winfo_height())
def _on_resize(self, event):
x0, y0, w0, h0 = self._resize_origin
width = max(MIN_W, w0 + (event.x_root - x0))
height = max(MIN_H, h0 + (event.y_root - y0))
self.geometry(f"{width}x{height}")
# ── Live-Sync mit dem Board ──────────────────────────────────── # ── Live-Sync mit dem Board ────────────────────────────────────
def _on_toggle_sync(self): def _on_toggle_sync(self):
@ -426,12 +496,10 @@ class VersaPadViewer(tk.Tk):
self.combined = vcomb.default_combined() self.combined = vcomb.default_combined()
else: else:
self.combined = vcomb.default_combined() self.combined = vcomb.default_combined()
self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs) self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row)
self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten")
else: else:
self.sync_check.configure(state="normal") self.sync_check.configure(state="normal")
self.prog_row.pack_forget() self.prog_row.pack_forget()
self.header_sub.configure(text="pollt Config-JSONs alle 1.5s")
self._update_tab_labels() self._update_tab_labels()
self._render() self._render()
@ -460,7 +528,9 @@ class VersaPadViewer(tk.Tk):
macro_slots = vproto.unpack_macros(raw_macros) macro_slots = vproto.unpack_macros(raw_macros)
names = self.combined["profile_names"] if self.combined else None names = self.combined["profile_names"] if self.combined else None
self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) previous = self.combined
self.combined = vcomb.merge_notes(
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
self.profile = self.combined["active_profile"] self.profile = self.combined["active_profile"]
self._status("vom Board geladen", True) self._status("vom Board geladen", True)
self._update_tab_labels() self._update_tab_labels()
@ -639,12 +709,18 @@ class VersaPadViewer(tk.Tk):
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT, tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
font=("Segoe UI", 10, "normal" if empty else "bold"), font=("Segoe UI", 10, "normal" if empty else "bold"),
wraplength=CARD_W - 20, justify="left", anchor="nw").place( wraplength=CARD_W - 20, justify="left", anchor="nw").place(
x=10, y=26, width=CARD_W - 20, height=36) x=10, y=24, width=CARD_W - 20, height=28)
# Notiz bekommt den ganzen Rest der Karte (bis zur Animationszeile) --
# bei 34px wurden laengere Notizen mitten im Wort abgeschnitten.
tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place(
x=10, y=53, width=CARD_W - 20, height=CARD_H - 69)
anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"]) anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"])
tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM, tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 7), anchor="sw").place( font=("Segoe UI", 7), anchor="sw").place(
x=10, y=CARD_H - 20, width=CARD_W - 20, height=14) x=10, y=CARD_H - 16, width=CARD_W - 20, height=13)
if editable: if editable:
card.configure(cursor="hand2") card.configure(cursor="hand2")
@ -661,17 +737,31 @@ class VersaPadViewer(tk.Tk):
inner.pack(fill="both", expand=True, padx=10, pady=8) inner.pack(fill="both", expand=True, padx=10, pady=8)
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM, tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4)) font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))
for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]), for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]),
("CCW", "ccw", enc["ccw_label"])): ("CW", "cw", enc["cw_label"], enc["cw_note"]),
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
row = tk.Frame(inner, bg=CARD_BG) row = tk.Frame(inner, bg=CARD_BG)
row.pack(fill="x", pady=1) row.pack(fill="x", pady=(1, 4))
tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left") top = tk.Frame(row, bg=CARD_BG)
tk.Label(row, text=val or "", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right") top.pack(fill="x")
tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
tk.Label(top, text=val or "", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
# Notizzeile nur anlegen, wenn es wirklich eine Notiz gibt --
# ein leeres Label belegt sonst pro Encoder-Aktion eine Zeile
# Hoehe (12x im Fenster) und schiebt die Fusszeile aus dem Bild.
note_label = None
if note:
note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7),
wraplength=150, justify="left", anchor="w")
note_label.pack(fill="x")
if editable: if editable:
row.configure(cursor="hand2") row.configure(cursor="hand2")
handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl) handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl)
row.bind("<Button-1>", handler) row.bind("<Button-1>", handler)
for child in row.winfo_children(): top.bind("<Button-1>", handler)
if note_label is not None:
note_label.bind("<Button-1>", handler)
for child in top.winfo_children():
child.bind("<Button-1>", handler) child.bind("<Button-1>", handler)

View file

@ -110,13 +110,18 @@ auf.
`versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene `versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene
Config-Datei (`versapad_combined.DEFAULT_PATH` = Config-Datei (`versapad_combined.DEFAULT_PATH` =
`app_dir()/versapad_config_all.json`): `app_dir()/versapad_config_all.json`): `%APPDATA%\VersaPadViewer`
(Roaming-AppData), unabhängig davon, ob die gebaute `.exe` oder der
Quellcode gestartet wurde (kein `sys.frozen`-Zweig).
- **Gebaute `.exe`** (PyInstaller `--onedir`): `sys.executable`s Ordner — Bewusst **nicht** das Installationsverzeichnis (`%LOCALAPPDATA%\
die Config liegt also neben `VersaPadViewer.exe`, in welchem VersaPadViewer`, wo die `.exe` liegt): `build_and_deploy.ps1` räumt das
Installationsverzeichnis sie auch liegt. Zielverzeichnis vor jedem Deploy komplett ab, läge die Config dort, würde
- **Start aus dem Quellcode** (`py desktop_viewer.py`, `py server.py`, jeder Rebuild sie mitlöschen (genau das ist am 2026-08-15 passiert, siehe
`py versapad_mcp_server.py`): der Projektordner (`__file__`-Verzeichnis). `AGENTS.md`). Ebenso bewusst **nicht** vom Startweg abhängig — sonst sähe
die `.exe` eine andere Datei als ein aus dem Quellcode gestarteter
`versapad_mcp_server.py`, und Änderungen aus dem einen Weg wären im
anderen unsichtbar (ebenfalls am 2026-08-15 beobachtet).
Fehlt die Datei, legt `load_or_fetch()` sie automatisch an — zuerst per Fehlt die Datei, legt `load_or_fetch()` sie automatisch an — zuerst per
Serial-Versuch vom Board (das ist die eigentliche Quelle der Wahrheit, die Serial-Versuch vom Board (das ist die eigentliche Quelle der Wahrheit, die
@ -177,8 +182,11 @@ statt dem ACK allein zu vertrauen. Details siehe „Bug beobachtet
`build_and_deploy.ps1` installiert `requirements.txt` selbst `build_and_deploy.ps1` installiert `requirements.txt` selbst
(`pip install -r`), baut mit PyInstaller (`--onedir --windowed`, nur (`pip install -r`), baut mit PyInstaller (`--onedir --windowed`, nur
`desktop_viewer.py` wird gebündelt) und kopiert das Ergebnis nach `desktop_viewer.py` wird gebündelt) und kopiert das Ergebnis nach
`dist\VersaPadViewer\` im Projektordner. `--onedir` statt `--onefile`, um `%LOCALAPPDATA%\VersaPadViewer\`. `--onedir` statt `--onefile`, um
AV-Fehlalarme zu verringern. Läuft komplett in try/catch mit AV-Fehlalarme zu verringern. Räumt das Zielverzeichnis vor dem Kopieren
komplett ab, rettet dabei aber zuvor gefundene `versapad_config*.json`
(Altinstallationen, bei denen die Config noch im Installationsordner
liegt) über den Deploy hinweg. Läuft komplett in try/catch mit
Exit-Code-Prüfung und pausiert am Ende (Erfolg wie Fehler) auf Exit-Code-Prüfung und pausiert am Ende (Erfolg wie Fehler) auf
Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem
Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details: Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details:

View file

@ -50,6 +50,16 @@ siehe unten):
| `0x04` | Alt | | `0x04` | Alt |
| `0x08` | Win | | `0x08` | Win |
Jede `Action` trägt zusätzlich ein optionales `note`-Feld (freier Text,
z.B. `"Speichern in Fusion 360"`) — **rein lokal**, wie `profile_names`
(siehe unten): kein Platz dafür in `SAction` (3 Byte, komplett verplant),
`to_binary()`/`pack_config()` ignorieren das Feld beim Schreiben ans
Board, `from_binary()` liefert frisch vom Board immer `note=""`.
`versapad_combined.merge_notes(neu, alt)` kopiert bestehende Notizen nach
jedem `load_from_board()`/`fetch_from_board()` zurück, sonst gingen sie
bei jedem Board-Refresh verloren. Editierbar per Programmiermodus-Dialog
oder MCP (`set_button_note()`/`set_encoder_note()`).
## LED ## LED
Pro MX-Button (nicht pro Encoder — Encoder haben keine eigene LED): Pro MX-Button (nicht pro Encoder — Encoder haben keine eigene LED):
@ -100,7 +110,7 @@ kompletten 740B-Block, nie nur ein Profil.
"buttons": [ "buttons": [
{ {
"index": 0, "index": 0,
"action": { "type": "HidKey", "data": 30 }, "action": { "type": "HidKey", "data": 30, "note": "Speichern in Fusion 360" },
"led": { "r": 80, "g": 40, "b": 0, "brightness": 255, "led": { "r": 80, "g": 40, "b": 0, "brightness": 255,
"anim": "Static", "period_ms": 4000 } "anim": "Static", "period_ms": 4000 }
} }
@ -128,7 +138,8 @@ kompletten 740B-Block, nie nur ein Profil.
Profilnamen (`profile_names`) sind **rein lokal** — die Firmware-Structs Profilnamen (`profile_names`) sind **rein lokal** — die Firmware-Structs
haben keinen Platz für einen String (Header exakt 32B, jedes Profil exakt haben keinen Platz für einen String (Header exakt 32B, jedes Profil exakt
236B, alles verplant), sie landen nie aufs Board, egal welcher 236B, alles verplant), sie landen nie aufs Board, egal welcher
Schreibpfad benutzt wird. Schreibpfad benutzt wird. Dasselbe gilt für `note` in jeder `Action`
(siehe oben).
## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`) ## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`)

View file

@ -38,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 140px); grid-template-columns: repeat(4, 140px);
grid-template-rows: repeat(5, 76px); grid-template-rows: repeat(5, 104px);
grid-auto-flow: column; grid-auto-flow: column;
gap: 10px; gap: 10px;
margin-bottom: 36px; margin-bottom: 36px;
@ -53,15 +53,17 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
} }
.cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; } .cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; }
.cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; } .cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; }
.cell .note { font-size: 11px; color: #8a8d98; margin-top: 4px; word-break: break-word; }
.cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; } .cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; }
.cell.empty .label { color: #4a4d58; font-weight: 400; } .cell.empty .label { color: #4a4d58; font-weight: 400; }
h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; } h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; }
.encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; } .encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; }
.enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; } .enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; }
.enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; } .enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; }
.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; } .enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0 0; }
.enc .row .k { color: #8a8d98; } .enc .row .k { color: #8a8d98; }
.enc .row .v { font-weight: 500; text-align: right; } .enc .row .v { font-weight: 500; text-align: right; }
.enc .note { font-size: 11px; color: #6a6d78; padding-bottom: 6px; word-break: break-word; }
footer { margin-top: 40px; color: #6a6d78; font-size: 12px; } footer { margin-top: 40px; color: #6a6d78; font-size: 12px; }
""" """
@ -73,20 +75,31 @@ def render_cell(btn):
grid_col, grid_row = btn["col"] + 1, btn["row"] + 1 grid_col, grid_row = btn["col"] + 1, btn["row"] + 1
style = f"grid-column:{grid_col}; grid-row:{grid_row};" style = f"grid-column:{grid_col}; grid-row:{grid_row};"
label = html.escape(btn["label"]) if btn["label"] else "" label = html.escape(btn["label"]) if btn["label"] else ""
note = html.escape(btn["note"]) if btn.get("note") else ""
return f"""<div class="{css_class}" style="{style}"> return f"""<div class="{css_class}" style="{style}">
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div> <div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
<div class="idx">#{btn['index']}</div> <div class="idx">#{btn['index']}</div>
<div class="label">{label}</div> <div class="label">{label}</div>
<div class="note">{note}</div>
<div class="anim">{"" if empty else anim}</div> <div class="anim">{"" if empty else anim}</div>
</div>""" </div>"""
def _encoder_row(key, label, note):
label = html.escape(label) or ""
note_html = f'<div class="note">{html.escape(note)}</div>' if note else ""
return f"""<div class="row"><span class="k">{key}</span><span class="v">{label}</span></div>{note_html}"""
def render_encoder(enc): def render_encoder(enc):
rows = (
_encoder_row("Druck", enc["sw_label"], enc["sw_note"])
+ _encoder_row("CW", enc["cw_label"], enc["cw_note"])
+ _encoder_row("CCW", enc["ccw_label"], enc["ccw_note"])
)
return f"""<div class="enc"> return f"""<div class="enc">
<div class="idx">Encoder {enc['index']}</div> <div class="idx">Encoder {enc['index']}</div>
<div class="row"><span class="k">Druck</span><span class="v">{html.escape(enc['sw_label']) or ''}</span></div> {rows}
<div class="row"><span class="k">CW</span><span class="v">{html.escape(enc['cw_label']) or ''}</span></div>
<div class="row"><span class="k">CCW</span><span class="v">{html.escape(enc['ccw_label']) or ''}</span></div>
</div>""" </div>"""

View file

@ -25,10 +25,10 @@ DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
def _empty_profile(): def _empty_profile():
buttons = [{"index": i, "action": {"type": "None", "data": 0}, buttons = [{"index": i, "action": {"type": "None", "data": 0, "note": ""},
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255, "led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
"anim": "Static", "period_ms": 4000}} for i in range(20)] "anim": "Static", "period_ms": 4000}} for i in range(20)]
none = {"type": "None", "data": 0} none = {"type": "None", "data": 0, "note": ""}
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)] encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
return {"buttons": buttons, "encoders": encoders} return {"buttons": buttons, "encoders": encoders}
@ -61,17 +61,54 @@ def default_combined():
def from_binary(config_dict, macro_slots, profile_names=None): def from_binary(config_dict, macro_slots, profile_names=None):
"""config_dict: Ergebnis von versapad_protocol.unpack_config(). """config_dict: Ergebnis von versapad_protocol.unpack_config().
macro_slots: Ergebnis von versapad_protocol.unpack_macros().""" macro_slots: Ergebnis von versapad_protocol.unpack_macros(). Actions
kommen frisch vom Board ohne "note" (die Firmware kennt keine Notizen,
siehe merge_notes()) -- hier nur mit leerem Default versehen, damit das
Feld ueberall verlaesslich existiert."""
profiles = config_dict["profiles"]
for profile in profiles:
for b in profile["buttons"]:
b["action"].setdefault("note", "")
for e in profile["encoders"]:
for field in ("sw", "cw", "ccw"):
e[field].setdefault("note", "")
return { return {
"active_profile": config_dict["active_profile"], "active_profile": config_dict["active_profile"],
"global_brightness": config_dict["global_brightness"], "global_brightness": config_dict["global_brightness"],
"enc_sensitivity": config_dict["enc_sensitivity"], "enc_sensitivity": config_dict["enc_sensitivity"],
"profile_names": profile_names or list(DEFAULT_NAMES), "profile_names": profile_names or list(DEFAULT_NAMES),
"profiles": config_dict["profiles"], "profiles": profiles,
"macros": macro_slots, "macros": macro_slots,
} }
def merge_notes(combined, previous):
"""Kopiert Notizen (Button/Encoder-Aktion) aus einem vorherigen State in
einen frisch vom Board gelesenen State -- wie profile_names sind Notizen
rein lokal und wuerden bei jedem load_from_board()/fetch_from_board()
sonst verloren gehen, weil die Firmware sie nicht kennt. previous=None
(z.B. allererstes Laden) -> nichts zu tun, combined unveraendert
zurueckgegeben. Aendert combined in-place und gibt es zurueck."""
if not previous:
return combined
for p_idx, profile in enumerate(combined["profiles"]):
if p_idx >= len(previous["profiles"]):
continue
prev_profile = previous["profiles"][p_idx]
prev_buttons = {b["index"]: b for b in prev_profile.get("buttons", [])}
for b in profile["buttons"]:
prev = prev_buttons.get(b["index"])
if prev:
b["action"]["note"] = prev["action"].get("note", "")
prev_encoders = {e["index"]: e for e in prev_profile.get("encoders", [])}
for e in profile["encoders"]:
prev = prev_encoders.get(e["index"])
if prev:
for field in ("sw", "cw", "ccw"):
e[field]["note"] = prev[field].get("note", "")
return combined
def to_binary(combined): def to_binary(combined):
"""-> (config_bytes[740], macro_bytes[512])""" """-> (config_bytes[740], macro_bytes[512])"""
config_bytes = proto.pack_config({ config_bytes = proto.pack_config({
@ -85,6 +122,7 @@ def to_binary(combined):
def save_file(combined, path=DEFAULT_PATH): def save_file(combined, path=DEFAULT_PATH):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
json.dump(combined, f, indent=2, ensure_ascii=False) json.dump(combined, f, indent=2, ensure_ascii=False)
return path return path

View file

@ -7,21 +7,35 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
""" """
import json import json
import os import os
import sys
NUM_PROFILES = 3 NUM_PROFILES = 3
APP_NAME = "VersaPadViewer"
def app_dir(): def app_dir():
"""Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json, """Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json,
siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir) siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung
das Installationsverzeichnis neben der .exe, sonst der Ordner dieses abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und
Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter unter jedem Benutzer.
Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/
installieren, ohne Pfade von Hand anzupassen.""" Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die
if getattr(sys, "frozen", False): gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen
return os.path.dirname(sys.executable) dieselbe Datei sehen, sonst laufen zwei Configs auseinander und
return os.path.dirname(os.path.abspath(__file__)) Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist
am 2026-08-15 passiert -- .exe zeigte ein anderes Profil 0 als der
MCP-Server).
Bewusst auch NICHT das Installationsverzeichnis (%LOCALAPPDATA%\\
VersaPadViewer, wo die .exe liegt): `build_and_deploy.ps1` raeumt das
Zielverzeichnis vor jedem Deploy komplett ab (`Remove-Item -Recurse`) --
laege die Config dort, wuerde JEDER Rebuild die Nutzerdaten mitloeschen
(am 2026-08-15 genau so passiert, alle Notizen weg). Programm- und
Datenverzeichnis bleiben deshalb getrennt: Roaming-AppData fuer die
Config."""
base = (os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA")
or os.path.join(os.path.expanduser("~"), ".local", "share"))
return os.path.join(base, APP_NAME)
# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind # CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind
@ -206,15 +220,20 @@ def button_grid_position(index):
def annotate_profile(cfg): def annotate_profile(cfg):
"""Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus """Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob
einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt.""" cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck)
oder aus dem kombinierten Programmiermodus-State kommt."""
for b in cfg["buttons"]: for b in cfg["buttons"]:
b["label"] = action_label(b["action"]) b["label"] = action_label(b["action"])
b["note"] = b["action"].get("note", "")
b["col"], b["row"] = button_grid_position(b["index"]) b["col"], b["row"] = button_grid_position(b["index"])
for e in cfg["encoders"]: for e in cfg["encoders"]:
e["sw_label"] = action_label(e["sw"]) e["sw_label"] = action_label(e["sw"])
e["sw_note"] = e["sw"].get("note", "")
e["cw_label"] = action_label(e["cw"]) e["cw_label"] = action_label(e["cw"])
e["cw_note"] = e["cw"].get("note", "")
e["ccw_label"] = action_label(e["ccw"]) e["ccw_label"] = action_label(e["ccw"])
e["ccw_note"] = e["ccw"].get("note", "")
return cfg return cfg

View file

@ -61,7 +61,17 @@ def _profile_switch_data(target):
def _describe_action(action): def _describe_action(action):
return {"type": action["type"], "data": action["data"], "label": vp.action_label(action)} return {"type": action["type"], "data": action["data"], "label": vp.action_label(action),
"note": action.get("note", "")}
def _replace_action(old_action, new_type, new_data):
"""Baut eine neue Action mit neuem Typ/Daten, behaelt aber die Notiz vom
vorherigen Stand bei (rein lokal, unabhaengig davon was die Aktion tut --
ein Tastenwechsel soll die Beschreibung 'was der Button macht' nicht
loeschen). Zum Loeschen explizit set_button_note()/set_encoder_note()
mit leerem String."""
return {"type": new_type, "data": new_data, "note": old_action.get("note", "")}
# ── Lesen ──────────────────────────────────────────────────────────────────── # ── Lesen ────────────────────────────────────────────────────────────────────
@ -116,10 +126,10 @@ 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. Schliesst die Verbindung Tkinter-Viewer den COM-Port gerade halten. Gibt den COM-Port danach
danach wieder (siehe write_to_board() fuer den Grund) -- der Port sofort wieder frei (kein dauerhaft offen gehaltener Serial-Handle --
ist exklusiv, ein einzelner Status-Check darf ihn nicht dauerhaft sonst blockiert dieser Prozess andere Tools/Viewer mit "busy", bis er
fuer Live-Sync/VersaGUI blockieren.""" beendet wird)."""
try: 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}
@ -138,7 +148,7 @@ def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = []
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
keycode = vp.hid_key_code_for_name(key) keycode = vp.hid_key_code_for_name(key)
mod_bits = vp.modifier_bits_for_names(modifiers) mod_bits = vp.modifier_bits_for_names(modifiers)
btn["action"] = {"type": "HidKey", "data": (mod_bits << 8) | keycode} btn["action"] = _replace_action(btn["action"], "HidKey", (mod_bits << 8) | keycode)
return _describe_action(btn["action"]) return _describe_action(btn["action"])
@ -148,7 +158,7 @@ def set_button_consumer(profile: int, index: int, consumer: str) -> dict:
'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'.""" 'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'."""
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
cid = vp.consumer_id_for_name(consumer) cid = vp.consumer_id_for_name(consumer)
btn["action"] = {"type": "HidConsumer", "data": cid} btn["action"] = _replace_action(btn["action"], "HidConsumer", cid)
return _describe_action(btn["action"]) return _describe_action(btn["action"])
@ -157,7 +167,7 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
"""Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst """Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst
mit set_macro() befuellen.""" mit set_macro() befuellen."""
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
btn["action"] = {"type": "Macro", "data": slot} btn["action"] = _replace_action(btn["action"], "Macro", slot)
return _describe_action(btn["action"]) return _describe_action(btn["action"])
@ -165,15 +175,27 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
def set_button_profile_switch(profile: int, index: int, target) -> dict: def set_button_profile_switch(profile: int, index: int, target) -> dict:
"""Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2.""" """Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2."""
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
btn["action"] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)} btn["action"] = _replace_action(btn["action"], "ProfileSwitch", _profile_switch_data(target))
return _describe_action(btn["action"]) return _describe_action(btn["action"])
@mcp.tool() @mcp.tool()
def set_button_none(profile: int, index: int) -> dict: def set_button_none(profile: int, index: int) -> dict:
"""Entfernt die Belegung eines MX-Buttons (Action = None).""" """Entfernt die Belegung eines MX-Buttons (Action = None). Notiz bleibt
erhalten -- zum Loeschen set_button_note(profile, index, "")."""
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
btn["action"] = {"type": "None", "data": 0} btn["action"] = _replace_action(btn["action"], "None", 0)
return _describe_action(btn["action"])
@mcp.tool()
def set_button_note(profile: int, index: int, note: str) -> dict:
"""Setzt/aendert die freie Notiz eines MX-Buttons -- was der Button tut,
unabhaengig von der technischen Aktion (z.B. 'Speichern in Fusion 360').
Rein lokal, landet nie aufs Board (wie Profilnamen). Leerer String
loescht die Notiz."""
btn = _find(_profile(profile)["buttons"], index)
btn["action"]["note"] = note
return _describe_action(btn["action"]) return _describe_action(btn["action"])
@ -205,7 +227,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
keycode = vp.hid_key_code_for_name(key) keycode = vp.hid_key_code_for_name(key)
mod_bits = vp.modifier_bits_for_names(modifiers) mod_bits = vp.modifier_bits_for_names(modifiers)
enc[f] = {"type": "HidKey", "data": (mod_bits << 8) | keycode} enc[f] = _replace_action(enc[f], "HidKey", (mod_bits << 8) | keycode)
return _describe_action(enc[f]) return _describe_action(enc[f])
@ -213,7 +235,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict: def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict:
"""Belegt eine Encoder-Aktion mit einer Medientaste.""" """Belegt eine Encoder-Aktion mit einer Medientaste."""
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "HidConsumer", "data": vp.consumer_id_for_name(consumer)} enc[f] = _replace_action(enc[f], "HidConsumer", vp.consumer_id_for_name(consumer))
return _describe_action(enc[f]) return _describe_action(enc[f])
@ -221,7 +243,7 @@ def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) ->
def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict: def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict:
"""Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31).""" """Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31)."""
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "Macro", "data": slot} enc[f] = _replace_action(enc[f], "Macro", slot)
return _describe_action(enc[f]) return _describe_action(enc[f])
@ -231,15 +253,26 @@ def set_encoder_profile_switch(profile: int, index: int, field: str, target) ->
Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der
Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern.""" Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern."""
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)} enc[f] = _replace_action(enc[f], "ProfileSwitch", _profile_switch_data(target))
return _describe_action(enc[f]) return _describe_action(enc[f])
@mcp.tool() @mcp.tool()
def set_encoder_none(profile: int, index: int, field: str) -> dict: def set_encoder_none(profile: int, index: int, field: str) -> dict:
"""Entfernt eine Encoder-Belegung (Action = None).""" """Entfernt eine Encoder-Belegung (Action = None). Notiz bleibt erhalten
-- zum Loeschen set_encoder_note(profile, index, field, "")."""
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
enc[f] = {"type": "None", "data": 0} enc[f] = _replace_action(enc[f], "None", 0)
return _describe_action(enc[f])
@mcp.tool()
def set_encoder_note(profile: int, index: int, field: str, note: str) -> dict:
"""Setzt/aendert die freie Notiz einer Encoder-Aktion (sw/cw/ccw) -- was
sie tut, unabhaengig von der technischen Aktion. Rein lokal, landet nie
aufs Board. Leerer String loescht die Notiz."""
enc, f = _encoder_field(profile, index, field)
enc[f]["note"] = note
return _describe_action(enc[f]) return _describe_action(enc[f])
@ -303,10 +336,10 @@ def load_local(path: str = None) -> dict:
@mcp.tool() @mcp.tool()
def load_from_board() -> dict: def load_from_board() -> dict:
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben
kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der
von VersaGUI/dem Tkinter-Viewer gehalten wird. Schliesst die Verbindung COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den
danach wieder (siehe write_to_board() fuer den Grund).""" COM-Port danach sofort wieder frei (siehe get_board_status())."""
try: try:
raw_cfg = _link.read_full_config() raw_cfg = _link.read_full_config()
if raw_cfg is None: if raw_cfg is None:
@ -321,7 +354,9 @@ def load_from_board() -> dict:
macro_slots = vproto.unpack_macros(raw_macros) macro_slots = vproto.unpack_macros(raw_macros)
names = _cfg()["profile_names"] names = _cfg()["profile_names"]
_state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) previous = _state["combined"]
_state["combined"] = vcomb.merge_notes(
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
return list_profiles() return list_profiles()
finally: finally:
_link.close() _link.close()
@ -333,11 +368,7 @@ def write_to_board() -> dict:
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 Gibt den COM-Port danach sofort wieder frei (siehe get_board_status())."""
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: 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):