diff --git a/AGENTS.md b/AGENTS.md index 4afe1a9..1d3a001 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,9 +93,8 @@ Nutzerorientierte Einführung: [`README.md`](README.md). Fehler hier live aufgedeckt. Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code -nachschlagen — die Dateien sind klein genug, dass eine separate -`docs/current-architecture.md` hier keinen Mehrwert hätte (siehe -Dokumentation und Verifikation unten für die Größeneinschätzung). +nachschlagen. Menschenlesbare Referenzdoku (Architektur, Datenmodell, +Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. ## 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 String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie 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 gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten 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` zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine 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 Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb 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 (`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 + 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 die Config UND war kein Board erreichbar, blockierte das Tool mit einer 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"]` bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der 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 `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 @@ -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 `default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer ALLE 3 Profile prüfen, nicht nur das gemeldete. +- **Randloses Fenster (seit 2026-08-15):** Die Titelleiste ist per reinem Tk + `overrideredirect(True)` ausgeblendet -- **niemals** per ctypes/WinAPI + nachhelfen (kein `SetWindowLongW`/`ShowWindow` auf das eigene Fenster), + siehe Speicher-Notiz "keine Selbstversteck-Fenstertricks": genau diese + Kombination hat in einem anderen Projekt Bitdefender-Fehlalarme + ausgelöst. Konsequenzen, die mitgebaut werden müssen: kein + Taskleisten-Eintrag (Rückweg nur über das Tray-Icon), kein Ziehen am + Rahmen (Kopfzeile ist deshalb per `` verschiebbar), keine + System-Buttons (eigene `✕`/`—` in der Kopfzeile, beide legen ins Tray) + und **keine Resize-Ränder**. +- **Der Größen-Anfasser hängt per `place()` an der Fensterecke, nicht + gepackt am Ende des Inhalts.** Gepackt verschwindet er, sobald das Fenster + kleiner als der Inhalt ist -- also exakt dann, wenn man ihn zum + Vergrößern bräuchte. `MIN_W/MIN_H` begrenzen das Verkleinern. +- 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 Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm 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 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 Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`, diff --git a/README.md b/README.md index c6af12c..29e1ea0 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original, inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene -Config-Datei (`versapad_config_all.json`) liegt automatisch neben der -Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu -angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* +Config-Datei (`versapad_config_all.json`) liegt automatisch unter +`%APPDATA%\VersaPadViewer\` (siehe „Aufbau" unten) und wird bei Bedarf +automatisch neu angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* Lese-Interop mit den JSON-Exports der offiziellen VersaGUI (`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen, @@ -33,10 +33,19 @@ falls gewünscht. oder als Datei speichern - **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte 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 Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten) - **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie die offizielle VersaGUI +- **Randloses Fenster** — ohne Windows-Titelleiste, dafür kompakter Kopf + (Modus-Checkboxen direkt neben dem Titel). Verschieben durch Ziehen an + der Kopfzeile, Größe ändern am Anfasser unten rechts, `✕`/`—` legen ins + Tray. Einen Taskleisten-Eintrag gibt es dadurch nicht — das Fenster kommt + über das Tray-Icon zurück. ## Voraussetzungen @@ -75,7 +84,7 @@ Maschine/Python-Version unterschiedlich). Selbst bauen: Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus `requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das -Ergebnis nach `dist\VersaPadViewer\VersaPadViewer.exe` im Projektordner. +Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. 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` @@ -107,14 +116,20 @@ VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile + -Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der -Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem -Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B. -frische Installation), wird sie automatisch angelegt — per Serial vom +Makros + lokale Profilnamen + Notizen, siehe `versapad_combined.py`) liegt +unter `%APPDATA%\VersaPadViewer\` (`versapad_data.app_dir()`), also +**getrennt vom Installationsordner** und unabhängig davon, ob die `.exe` +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. 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. +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 geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export diff --git a/action_dialog.py b/action_dialog.py index 09a9f0d..6a5ccf2 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -124,7 +124,9 @@ class MacroStepsDialog(_ModalDialog): class ActionEditDialog(_ModalDialog): """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): super().__init__(parent, title) @@ -134,8 +136,15 @@ class ActionEditDialog(_ModalDialog): self.led = None 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( - 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"]) type_frame = tk.Frame(self, bg=BG2) @@ -320,6 +329,7 @@ class ActionEditDialog(_ModalDialog): else: action = {"type": "None", "data": 0} + action["note"] = self._note_var.get() self.action = action if self._led is not None: r, g, b = self._led_color diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index e0617e0..0accd2c 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -1,5 +1,8 @@ # 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 # dem Netzlaufwerk (Z:\Git\...): @@ -27,7 +30,7 @@ param( $ErrorActionPreference = "Stop" $projectDir = $PSScriptRoot $buildSrc = "$env:TEMP\versapad_build_src" -$localDir = "$projectDir\dist\VersaPadViewer" +$localDir = "$env:LOCALAPPDATA\VersaPadViewer" $requirementsFile = "$projectDir\requirements.txt" 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." } + # 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) { Remove-Item $localDir -Recurse -Force } 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 Write-Host "" diff --git a/desktop_viewer.py b/desktop_viewer.py index 81b1805..a27e191 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -54,7 +54,10 @@ OK_GREEN = "#3ecf6e" WARN_RED = "#e0895a" 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_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_none(profile, index) 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'): set_encoder_key / _consumer / _macro / _profile_switch / _none(...) + set_encoder_note(profile, index, field, note) Makro: set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...] @@ -122,7 +127,11 @@ class VersaPadViewer(tk.Tk): super().__init__() self.title("VersaPad Steuermatrix") 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._mtimes = {} self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt @@ -149,55 +158,66 @@ class VersaPadViewer(tk.Tk): ) self._tray_icon.run_detached() - header = tk.Frame(self, bg=BG) - header.pack(fill="x", padx=20, pady=(18, 4)) - tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT, - font=("Segoe UI", 15, "bold")).pack(anchor="w") - self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG, - fg=TEXT_DIM, font=("Segoe UI", 9)) - self.header_sub.pack(anchor="w") + # Titelleiste ausgeblendet (reines Tk `overrideredirect`, KEINE + # ctypes/WinAPI-Fenstertricks -- siehe Speicher-Notiz "keine + # Selbstversteck-Fenstertricks": das Nachruesten eines Taskleisten- + # Icons per SetWindowLongW hat frueher AV-Fehlalarme ausgeloest). + # Ersatz fuer die fehlende Systemleiste: Kopfzeile ist ziehbar, und + # die Buttons rechts uebernehmen Minimieren/Schliessen (beides ins + # Tray, wie vorher schon das X der echten Titelleiste). + self.overrideredirect(True) - info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14), - cursor="hand2") - info_btn.place(relx=1.0, x=0, y=-4, anchor="ne") + self.toggles_row = header = tk.Frame(self, bg=BG) + header.pack(fill="x", padx=20, pady=(10, 6)) + title = tk.Label(header, text="VersaPad", bg=BG, fg=TEXT, + font=("Segoe UI", 11, "bold")) + title.pack(side="left", anchor="w", padx=(0, 16)) + + for widget in (header, title): + widget.bind("", self._start_move) + widget.bind("", self._on_move) + + for text in ("✕", "—"): + btn = tk.Label(header, text=text, bg=BG, fg=TEXT_DIM, + font=("Segoe UI", 11), cursor="hand2", padx=6) + btn.pack(side="right") + btn.bind("", lambda e: self._hide_to_tray()) + btn.bind("", lambda e, b=btn: b.configure(fg=TEXT)) + btn.bind("", lambda e, b=btn: b.configure(fg=TEXT_DIM)) + + 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("", lambda e: self._show_mcp_info()) info_btn.bind("", lambda e: info_btn.configure(fg=ACCENT)) info_btn.bind("", lambda e: info_btn.configure(fg=TEXT_DIM)) - self.tabs = tk.Frame(self, bg=BG) - self.tabs.pack(fill="x", padx=20, pady=(12, 10)) - 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("", lambda e, prof=p: self.set_profile(prof, manual=True)) - btn.bind("", 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)) + # Modus-Umschalter direkt neben der Ueberschrift statt in eigener + # Zeile -- spart eine komplette Zeile Fensterhoehe. 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, - 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_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) - self.sync_status.pack(side="left", padx=(8, 20)) + self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) + self.sync_status.pack(side="left", padx=(4, 14)) 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, - activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) - self.edit_check.pack(side="left", padx=(0, 20)) + activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8)) + self.edit_check.pack(side="left", padx=(0, 14)) self.always_on_top = tk.BooleanVar(value=False) 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, - activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) + activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8)) 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) for text, cmd in ( ("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, activebackground=ACCENT, activeforeground="#fff", relief="flat", - padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) - self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) + padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6)) + self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) self.prog_status.pack(side="left", padx=(8, 0)) # 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("", lambda e, prof=p: self.set_profile(prof, manual=True)) + btn.bind("", lambda e, prof=p: self._rename_tab(prof)) + self.tab_buttons[p] = btn + 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, font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8)) self.enc_frame = tk.Frame(self, bg=BG) self.enc_frame.pack(fill="x", padx=20) - self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) - self.footer.pack(anchor="w", padx=20, pady=(20, 10)) + # Fusszeile + Anfasser zum Groessenaendern: mit ausgeblendeter + # Titelleiste (overrideredirect) entfernt Windows auch die + # Fensterraender, an denen man sonst zieht -- ohne diesen Griff + # liesse sich das Fenster gar nicht mehr skalieren. + footer_row = tk.Frame(self, bg=BG) + footer_row.pack(fill="x", padx=20, pady=(14, 8)) + self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) + self.footer.pack(side="left", anchor="w") + + # Der Griff haengt per place() an der FENSTER-Ecke, nicht am Ende des + # gepackten Inhalts: sonst wandert er mit dem Inhalt aus dem Bild, + # sobald das Fenster kleiner als der Inhalt ist -- also genau dann, + # wenn man ihn zum Vergroessern braucht. + grip = tk.Label(self, text="◢", bg=BG, fg=TEXT_DIM, + font=("Segoe UI", 11), cursor="sizing") + grip.place(relx=1.0, rely=1.0, anchor="se", x=-3, y=-1) + grip.bind("", self._start_resize) + grip.bind("", self._on_resize) + grip.bind("", lambda e: grip.configure(fg=TEXT)) + grip.bind("", lambda e: grip.configure(fg=TEXT_DIM)) self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) self.bind("", self._on_unmap) @@ -325,6 +376,25 @@ class VersaPadViewer(tk.Tk): def _on_toggle_topmost(self): self.attributes("-topmost", self.always_on_top.get()) + # ── Fenster ziehen (Ersatz fuer die ausgeblendete Titelleiste) ── + + def _start_move(self, event): + self._drag_origin = (event.x_root - self.winfo_x(), event.y_root - self.winfo_y()) + + def _on_move(self, event): + dx, dy = self._drag_origin + self.geometry(f"+{event.x_root - dx}+{event.y_root - dy}") + + def _start_resize(self, event): + self._resize_origin = (event.x_root, event.y_root, + self.winfo_width(), self.winfo_height()) + + def _on_resize(self, event): + x0, y0, w0, h0 = self._resize_origin + width = max(MIN_W, w0 + (event.x_root - x0)) + height = max(MIN_H, h0 + (event.y_root - y0)) + self.geometry(f"{width}x{height}") + # ── Live-Sync mit dem Board ──────────────────────────────────── def _on_toggle_sync(self): @@ -426,12 +496,10 @@ class VersaPadViewer(tk.Tk): self.combined = vcomb.default_combined() else: self.combined = vcomb.default_combined() - self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs) - self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten") + self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row) else: self.sync_check.configure(state="normal") self.prog_row.pack_forget() - self.header_sub.configure(text="pollt Config-JSONs alle 1.5s") self._update_tab_labels() self._render() @@ -460,7 +528,9 @@ class VersaPadViewer(tk.Tk): macro_slots = vproto.unpack_macros(raw_macros) 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._status("vom Board geladen", True) 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, font=("Segoe UI", 10, "normal" if empty else "bold"), 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"]) tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM, 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: card.configure(cursor="hand2") @@ -661,17 +737,31 @@ class VersaPadViewer(tk.Tk): inner.pack(fill="both", expand=True, padx=10, pady=8) 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)) - for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]), - ("CCW", "ccw", enc["ccw_label"])): + for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]), + ("CW", "cw", enc["cw_label"], enc["cw_note"]), + ("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])): row = tk.Frame(inner, bg=CARD_BG) - row.pack(fill="x", pady=1) - tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left") - tk.Label(row, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right") + row.pack(fill="x", pady=(1, 4)) + top = tk.Frame(row, bg=CARD_BG) + 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: row.configure(cursor="hand2") handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl) row.bind("", handler) - for child in row.winfo_children(): + top.bind("", handler) + if note_label is not None: + note_label.bind("", handler) + for child in top.winfo_children(): child.bind("", handler) diff --git a/docs/architecture.md b/docs/architecture.md index 142fc34..0c5dfc0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,13 +110,18 @@ auf. `versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene 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 — - 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). +Bewusst **nicht** das Installationsverzeichnis (`%LOCALAPPDATA%\ +VersaPadViewer`, wo die `.exe` liegt): `build_and_deploy.ps1` räumt das +Zielverzeichnis vor jedem Deploy komplett ab, läge die Config dort, würde +jeder Rebuild sie mitlöschen (genau das ist am 2026-08-15 passiert, siehe +`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 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 (`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 +`%LOCALAPPDATA%\VersaPadViewer\`. `--onedir` statt `--onefile`, um +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 Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details: diff --git a/docs/data-model.md b/docs/data-model.md index 88d160f..3491f11 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -50,6 +50,16 @@ siehe unten): | `0x04` | Alt | | `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 Pro MX-Button (nicht pro Encoder — Encoder haben keine eigene LED): @@ -100,7 +110,7 @@ kompletten 740B-Block, nie nur ein Profil. "buttons": [ { "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, "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 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. +Schreibpfad benutzt wird. Dasselbe gilt für `note` in jeder `Action` +(siehe oben). ## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`) diff --git a/server.py b/server.py index 9188d64..238d13c 100644 --- a/server.py +++ b/server.py @@ -38,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } .grid { display: grid; grid-template-columns: repeat(4, 140px); - grid-template-rows: repeat(5, 76px); + grid-template-rows: repeat(5, 104px); grid-auto-flow: column; gap: 10px; 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 .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.empty .label { color: #4a4d58; font-weight: 400; } 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; } .enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; } .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 .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; } """ @@ -73,20 +75,31 @@ def render_cell(btn): grid_col, grid_row = btn["col"] + 1, btn["row"] + 1 style = f"grid-column:{grid_col}; grid-row:{grid_row};" label = html.escape(btn["label"]) if btn["label"] else "—" + note = html.escape(btn["note"]) if btn.get("note") else "" return f"""
#{btn['index']}
{label}
+
{note}
{"" if empty else anim}
""" +def _encoder_row(key, label, note): + label = html.escape(label) or "—" + note_html = f'
{html.escape(note)}
' if note else "" + return f"""
{key}{label}
{note_html}""" + + 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"""
Encoder {enc['index']}
-
Druck{html.escape(enc['sw_label']) or '—'}
-
CW{html.escape(enc['cw_label']) or '—'}
-
CCW{html.escape(enc['ccw_label']) or '—'}
+ {rows}
""" diff --git a/versapad_combined.py b/versapad_combined.py index aad8f48..da1f145 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -25,10 +25,10 @@ DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] 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, "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)] return {"buttons": buttons, "encoders": encoders} @@ -61,17 +61,54 @@ def default_combined(): def from_binary(config_dict, macro_slots, profile_names=None): """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 { "active_profile": config_dict["active_profile"], "global_brightness": config_dict["global_brightness"], "enc_sensitivity": config_dict["enc_sensitivity"], "profile_names": profile_names or list(DEFAULT_NAMES), - "profiles": config_dict["profiles"], + "profiles": profiles, "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): """-> (config_bytes[740], macro_bytes[512])""" config_bytes = proto.pack_config({ @@ -85,6 +122,7 @@ def to_binary(combined): 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: json.dump(combined, f, indent=2, ensure_ascii=False) return path diff --git a/versapad_data.py b/versapad_data.py index 0e147c6..45be287 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -7,21 +7,35 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. """ import json import os -import sys NUM_PROFILES = 3 +APP_NAME = "VersaPadViewer" + 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__)) + siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung + abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und + unter jedem Benutzer. + + Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die + gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen + dieselbe Datei sehen, sonst laufen zwei Configs auseinander und + 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 @@ -206,15 +220,20 @@ def button_grid_position(index): def annotate_profile(cfg): - """Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus - einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt.""" + """Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob + cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck) + oder aus dem kombinierten Programmiermodus-State kommt.""" for b in cfg["buttons"]: b["label"] = action_label(b["action"]) + b["note"] = b["action"].get("note", "") b["col"], b["row"] = button_grid_position(b["index"]) for e in cfg["encoders"]: e["sw_label"] = action_label(e["sw"]) + e["sw_note"] = e["sw"].get("note", "") e["cw_label"] = action_label(e["cw"]) + e["cw_note"] = e["cw"].get("note", "") e["ccw_label"] = action_label(e["ccw"]) + e["ccw_note"] = e["ccw"].get("note", "") return cfg diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 52e644f..5a262c7 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -61,7 +61,17 @@ def _profile_switch_data(target): 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 ──────────────────────────────────────────────────────────────────── @@ -116,10 +126,10 @@ def get_macro(slot: int) -> dict: def get_board_status() -> dict: """Prueft per Serial, ob das Board erreichbar ist und welches Profil dort gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der - Tkinter-Viewer den COM-Port gerade halten. 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.""" + Tkinter-Viewer den COM-Port gerade halten. Gibt den COM-Port danach + sofort wieder frei (kein dauerhaft offen gehaltener Serial-Handle -- + sonst blockiert dieser Prozess andere Tools/Viewer mit "busy", bis er + beendet wird).""" try: profile = _link.read_active_profile() 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) keycode = vp.hid_key_code_for_name(key) 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"]) @@ -148,7 +158,7 @@ def set_button_consumer(profile: int, index: int, consumer: str) -> dict: 'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'.""" btn = _find(_profile(profile)["buttons"], index) 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"]) @@ -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 mit set_macro() befuellen.""" 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"]) @@ -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: """Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2.""" 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"]) @mcp.tool() 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["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"]) @@ -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) keycode = vp.hid_key_code_for_name(key) 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]) @@ -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: """Belegt eine Encoder-Aktion mit einer Medientaste.""" 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]) @@ -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: """Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31).""" 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]) @@ -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 Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern.""" 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]) @mcp.tool() 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] = {"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]) @@ -303,10 +336,10 @@ def load_local(path: str = None) -> dict: @mcp.tool() def load_from_board() -> dict: """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und - ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die - kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade - von VersaGUI/dem Tkinter-Viewer gehalten wird. Schliesst die Verbindung - danach wieder (siehe write_to_board() fuer den Grund).""" + ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben + erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der + COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den + COM-Port danach sofort wieder frei (siehe get_board_status()).""" try: raw_cfg = _link.read_full_config() if raw_cfg is None: @@ -321,7 +354,9 @@ def load_from_board() -> dict: macro_slots = vproto.unpack_macros(raw_macros) 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() finally: _link.close() @@ -333,11 +368,7 @@ def write_to_board() -> dict: Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port. - 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).""" + Gibt den COM-Port danach sofort wieder frei (siehe get_board_status()).""" try: cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) if not _link.write_full_config(cfg_bytes):