From 8ba524c07bb00fe2a3255e7bc0c9c49acda1c122 Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:23:33 +0200 Subject: [PATCH 1/8] Release COM port after each versapad MCP tool call get_board_status()/load_from_board()/write_to_board() kept the serial link open indefinitely after use (VersaPadLink._ensure_open() never closes on its own). Any session that called one of these left the COM port locked for VersaGUI/desktop_viewer/server.py's board fallback until the MCP server process was killed. Close the link in a finally block after each call instead. --- versapad_mcp_server.py | 62 ++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 3149cdb..797ae5e 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -116,9 +116,15 @@ def get_macro(slot: int) -> dict: def get_board_status() -> dict: """Prueft per Serial, ob das Board erreichbar ist und welches Profil dort gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der - Tkinter-Viewer den COM-Port gerade halten.""" - profile = _link.read_active_profile() - return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} + Tkinter-Viewer den COM-Port gerade halten. 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} + finally: + _link.close() # ── Buttons (20 pro Profil, MX-Matrix) ─────────────────────────────────────── @@ -299,22 +305,26 @@ def load_from_board() -> dict: """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade - von VersaGUI/dem Tkinter-Viewer gehalten wird.""" - raw_cfg = _link.read_full_config() - if raw_cfg is None: - raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") - raw_macros = _link.read_macros() - if raw_macros is None: - raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}") + von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den COM-Port danach + sofort wieder frei (siehe get_board_status()).""" + try: + raw_cfg = _link.read_full_config() + if raw_cfg is None: + raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") + raw_macros = _link.read_macros() + if raw_macros is None: + raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}") - cfg_dict = vproto.unpack_config(raw_cfg) - if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): - raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") - macro_slots = vproto.unpack_macros(raw_macros) + cfg_dict = vproto.unpack_config(raw_cfg) + if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): + raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") + macro_slots = vproto.unpack_macros(raw_macros) - names = _cfg()["profile_names"] - _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) - return list_profiles() + names = _cfg()["profile_names"] + _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) + return list_profiles() + finally: + _link.close() @mcp.tool() @@ -322,13 +332,17 @@ def write_to_board() -> dict: """Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit - NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.""" - cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) - if not _link.write_full_config(cfg_bytes): - raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") - if not _link.write_macros(macro_bytes): - raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") - return {"ok": True} + NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port. + 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): + raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") + if not _link.write_macros(macro_bytes): + raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") + return {"ok": True} + finally: + _link.close() if __name__ == "__main__": From b4ad7698ed902453f7e3ec9dd86928b76fda636b Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:17:11 +0200 Subject: [PATCH 2/8] Move config storage next to the install and auto-create it if missing versapad_combined.DEFAULT_PATH was hardcoded to this one machine's OneDrive desktop, which made the tool unusable anywhere else. versapad_data.app_dir() now resolves to the running .exe's own folder when frozen, or the project directory when run from source, and DEFAULT_PATH hangs off that instead. load_or_fetch() previously raised when both the file was missing and the board unreachable, blocking a fresh install with no config and no board attached. It now falls back to an empty default_combined() in that case, so the tool is immediately usable either way. desktop_viewer's _current_profile_view() picks up the same fallback instead of re-implementing a narrower version of it. Also drop the hardcoded PROFILE_NAMES dict, which had drifted out of sync with the profile_names already stored in the combined JSON -- renaming a profile in Programmiermodus never showed up in the read-only/browser views. server.py and desktop_viewer.py now read names from the same JSON everywhere. versapad_data.CONFIG_PATHS (read-only interop with the official C# VersaGUI's JSON export) is intentionally left on the OneDrive desktop -- nothing in this codebase writes there, it's not part of this tool's own config. (cherry picked from commit 5d14bdd826fe2e46128b61e26ee2859961860c2d) --- .gitignore | 4 ++++ AGENTS.md | 44 ++++++++++++++++++++++++++++++++++++-- README.md | 32 +++++++++++++++++++-------- desktop_viewer.py | 51 +++++++++++++++++++++----------------------- server.py | 6 +++--- versapad_combined.py | 39 ++++++++++++++++++++++++--------- versapad_data.py | 28 ++++++++++++++++++------ 7 files changed, 147 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 2a1de3f..6c4e83b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ __pycache__/ *.log build/ dist/ + +# Nutzer-Config -- landet neben der Installation (versapad_data.app_dir()), +# beim Start aus dem Quellcode also direkt hier im Projektordner +versapad_config_all.json diff --git a/AGENTS.md b/AGENTS.md index 0604c07..653ea34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ Nutzerorientierte Einführung: [`README.md`](README.md). - `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/ Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie (`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`. + `app_dir()` liefert das Verzeichnis für die eigene Config + (`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations- + ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten. + `CONFIG_PATHS` bleibt bewusst auf dem OneDrive-Desktop hartkodiert (Lese- + Interop mit einem JSON-Export der offiziellen VersaGUI, kein von diesem + Tool geschriebenes Format, siehe dort). - `server.py` — stdlib `http.server`, generiert HTML pro Request neu, Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend, kein Programmiermodus (bewusst einfach gehalten). @@ -39,8 +45,14 @@ Nutzerorientierte Einführung: [`README.md`](README.md). liefert `READ_STATUS` schlicht Timeout, kein Absturz. - `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros + **nur lokal gespeicherte** Profilnamen), Default-Pfad - `~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll: - `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den kompletten 740B-Block. + `versapad_data.app_dir()\versapad_config_all.json` (neben der + Installation, nicht mehr hartkodiert auf einer bestimmten Maschine). + Passt zum Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer + den kompletten 740B-Block. `load_or_fetch()` legt bei fehlender Datei + automatisch eine neue an — erst Versuch per Serial vom Board, sonst als + leere `default_combined()` (kein Board noetig fuer die Erstbenutzung). + `read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer + Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz). **UI:** - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi @@ -93,6 +105,34 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json` zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine Bearbeitung "verschwunden". +- **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim + Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei + fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei + Doppelklick im Explorer, weil das Fenster sich sofort schließt. Fix: + `requirements.txt` (alle Pakete an einer Stelle, README und Skript nutzen + dieselbe Datei), Skript installiert sie selbst per `pip install -r`, + läuft komplett in try/catch, prüft `$LASTEXITCODE` nach jedem nativen + Aufruf, und pausiert am Ende (Erfolg wie Fehler) auf Tastendruck -- + `-NoPause` für CI/Automation. (2) `versapad_combined.DEFAULT_PATH` war + hartkodiert auf `~\OneDrive\Desktop` einer bestimmten Maschine -- für + andere Nutzer unbrauchbar. Fix: `versapad_data.app_dir()` (neue + Funktion) liefert bei der gebauten `.exe` deren Installationsordner + (`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`- + Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben + der laufenden Installation. `versapad_data.CONFIG_PATHS` (Lese-Interop + mit der C#-VersaGUI) bleibt bewusst auf dem Desktop, siehe oben. (3) Fehlte + die Config UND war kein Board erreichbar, blockierte das Tool mit einer + Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei + Board-Fehlschlag eine leere `default_combined()` an statt `RuntimeError` + zu werfen -- Erstbenutzung ganz ohne vorhandene Config oder Board + funktioniert jetzt. (4) Profilnamen waren zusätzlich hartkodiert in + `versapad_data.PROFILE_NAMES` (Dict, jetzt entfernt, ersetzt durch + `NUM_PROFILES = 3`) und liefen der JSON-`profile_names` parallel -- + Programmiermodus zeigte umbenannte Profile, Lesemodus/Browser-Ansicht + weiterhin die alten Namen. Fix: `server.py` und `desktop_viewer.py` lesen + Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]` + bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der + Wahrheit für alle Frontends. - **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und `desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es diff --git a/README.md b/README.md index ebe6691..b165b4f 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original, inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum -Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die -Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte -Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`, -`versapad_combined.DEFAULT_PATH`) — für einen anderen Rechner dort anpassen. +Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene +Config-Datei (`versapad_config_all.json`) liegt automatisch neben der +Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu +angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* +Lese-Interop mit den JSON-Exports der offiziellen VersaGUI +(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine +hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen, +falls gewünscht. ## Features @@ -94,11 +98,21 @@ Das Binärformat (`versapad_protocol.py`) ist 1:1 aus den VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). -Config-Dateien liegen standardmäßig auf dem Desktop -(`versapad_config1/2/3.json` für den reinen Lesemodus, -`versapad_config_all.json` für den Programmiermodus — Pfade sind aktuell -hartkodiert für eine bestimmte Windows-Maschine, siehe `CONFIG_PATHS` in -`versapad_data.py` bzw. `DEFAULT_PATH` in `versapad_combined.py`). +Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile + +Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der +Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem +Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B. +frische Installation), wird sie automatisch angelegt — per Serial vom +Board, falls eins angeschlossen ist, sonst als leere Default-Config. +Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich +per Doppelklick auf einen Tab (Programmiermodus) oder `rename_profile()` +(MCP) ändern. + +Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool +geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export +der offiziellen VersaGUI (C#/.NET) — Pfad aktuell hartkodiert auf den +OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in +`versapad_data.py`. ## MCP-Server diff --git a/desktop_viewer.py b/desktop_viewer.py index 926337b..54cd76f 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -167,8 +167,8 @@ class VersaPadViewer(tk.Tk): self.tabs = tk.Frame(self, bg=BG) self.tabs.pack(fill="x", padx=20, pady=(12, 10)) self.tab_buttons = {} - for p in sorted(vp.PROFILE_NAMES): - btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT, + for p in range(vp.NUM_PROFILES): + btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2") btn.pack(side="left", padx=(0, 8)) btn.bind("", lambda e, prof=p: self.set_profile(prof, manual=True)) @@ -242,11 +242,16 @@ class VersaPadViewer(tk.Tk): self._render() def _update_tab_labels(self): + """Profilnamen kommen immer aus der kombinierten JSON (nicht mehr + hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst + per schlankem Datei-Read (kein Board-Zugriff, siehe + versapad_combined.profile_names()).""" + if self.editing.get() and self.combined: + names = self.combined["profile_names"] + else: + names = vcomb.read_profile_names() for p, btn in self.tab_buttons.items(): - if self.editing.get() and self.combined: - btn.configure(text=self.combined["profile_names"][p]) - else: - btn.configure(text=vp.PROFILE_NAMES[p]) + btn.configure(text=names[p]) def _rename_tab(self, profile): if not self.editing.get() or self.combined is None: @@ -526,16 +531,19 @@ class VersaPadViewer(tk.Tk): Bevorzugt die kombinierte Datei (versapad_config_all.json), falls vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen. - Fehlt sie (z.B. versehentlich geloescht) und ist Live-Sync gerade aus - (COM-Port frei), wird sie automatisch per Serial vom Board neu - aufgebaut und als neuer Cache gespeichert -- das Board ist die - eigentliche Quelle der Wahrheit, kein Datei-Handling von Hand mehr - noetig. Nur wenn das nicht klappt (Board nicht erreichbar, Live-Sync - haelt den Port), weicht es zuletzt auf die klassischen - versapad_config{1,2,3}.json aus.""" - if os.path.exists(vcomb.DEFAULT_PATH): + Fehlt sie (z.B. versehentlich geloescht, oder frische Installation + ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird + sie automatisch angelegt -- per Serial vom Board, oder als leere + Default-Config, wenn auch kein Board erreichbar ist (siehe + versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz + ohne vorhandene Config sofort benutzbar, kein Datei-Handling von + Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den + COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die + klassischen versapad_config{1,2,3}.json (Export der offiziellen + VersaGUI) aus.""" + if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get(): try: - data = vcomb.load_file(vcomb.DEFAULT_PATH) + data = vcomb.load_or_fetch(link=self._link) raw = data["profiles"][self.profile] cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], @@ -544,18 +552,6 @@ class VersaPadViewer(tk.Tk): return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" except (KeyError, IndexError, ValueError): pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen - elif not self.live_sync.get(): - try: - data = vcomb.fetch_from_board(link=self._link) - vcomb.save_file(data, vcomb.DEFAULT_PATH) - raw = data["profiles"][self.profile] - cfg = vp.annotate_profile({ - "buttons": [dict(b) for b in raw["buttons"]], - "encoders": [dict(e) for e in raw["encoders"]], - }) - return cfg, "Quelle: Board (neu vom Geraet geladen)" - except RuntimeError: - pass # Board nicht erreichbar -- weiter unten ausweichen try: return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}" except FileNotFoundError as e: @@ -564,6 +560,7 @@ class VersaPadViewer(tk.Tk): raise FileNotFoundError(f"{e}{hint}") from e def _render(self): + self._update_tab_labels() for w in self.grid_frame.winfo_children(): w.destroy() for w in self.enc_frame.winfo_children(): diff --git a/server.py b/server.py index d184a88..9188d64 100644 --- a/server.py +++ b/server.py @@ -98,8 +98,8 @@ def render_page(profile): "encoders": [dict(e) for e in raw["encoders"]], }) tabs = "".join( - f'{html.escape(vp.PROFILE_NAMES[p])}' - for p in sorted(vp.PROFILE_NAMES) + f'{html.escape(combined["profile_names"][p])}' + for p in range(vp.NUM_PROFILES) ) cells = "".join(render_cell(b) for b in cfg["buttons"]) encoders = "".join(render_encoder(e) for e in cfg["encoders"]) @@ -130,7 +130,7 @@ class Handler(BaseHTTPRequestHandler): profile = int(query.get("profile", ["0"])[0]) except ValueError: profile = 0 - if profile not in vp.PROFILE_NAMES: + if not (0 <= profile < vp.NUM_PROFILES): profile = 0 try: body = render_page(profile).encode("utf-8") diff --git a/versapad_combined.py b/versapad_combined.py index e6011ae..aad8f48 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -19,7 +19,7 @@ import versapad_data as vp import versapad_protocol as proto import versapad_serial as vs -DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json") +DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json") DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] @@ -126,18 +126,37 @@ def fetch_from_board(link=None, profile_names=None): def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None): """Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich - geloescht), wird sie automatisch per Serial vom Board neu aufgebaut und - als neuer Cache gespeichert, statt einen Fehler zu werfen -- das Board - behaelt die Config dauerhaft im NVM, die Desktop-JSON ist nur ein - Lesecache dafuer und muss nicht von Hand gepflegt werden. Ist das Board - nicht erreichbar (nicht verbunden, COM-Port belegt), wirft es - RuntimeError mit Klartext-Ursache -- Aufrufer entscheidet, ob es einen - weiteren Fallback gibt (z.B. alte Einzel-JSONs).""" + geloescht, oder frische Installation ohne jede Config), wird sie + automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das + behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache + dafuer), und falls auch das Board nicht erreichbar ist (nicht + verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite) + als leere Default-Config (vgl. default_combined()) -- damit ist das + Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit + einem Fehler zu blockieren.""" if os.path.exists(path): return load_file(path) - combined = fetch_from_board(link=link, profile_names=profile_names) + try: + combined = fetch_from_board(link=link, profile_names=profile_names) + except RuntimeError: + combined = default_combined() + if profile_names: + combined["profile_names"] = profile_names try: save_file(combined, path) except OSError: - pass # Board-Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden + pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden return combined + + +def read_profile_names(path=DEFAULT_PATH): + """Nur die (lokalen) Profilnamen lesen, ohne die volle Config zu + brauchen -- fuer Tab-Beschriftungen im Nur-Lese-Modus. Greift bewusst + nicht aufs Board zu (kein COM-Port-Konflikt mit Live-Sync), faellt bei + fehlender/kaputter Datei auf DEFAULT_NAMES zurueck.""" + if os.path.exists(path): + try: + return load_file(path).get("profile_names", list(DEFAULT_NAMES)) + except (OSError, ValueError): + pass + return list(DEFAULT_NAMES) diff --git a/versapad_data.py b/versapad_data.py index f4abcc0..0e147c6 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -7,19 +7,35 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. """ import json import os +import sys +NUM_PROFILES = 3 + + +def app_dir(): + """Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json, + siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir) + das Installationsverzeichnis neben der .exe, sonst der Ordner dieses + Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter + Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/ + installieren, ohne Pfade von Hand anzupassen.""" + if getattr(sys, "frozen", False): + return os.path.dirname(sys.executable) + return os.path.dirname(os.path.abspath(__file__)) + + +# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind +# keine von diesem Tool geschriebenen Dateien, sondern ein Export der +# offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese- +# Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche, +# von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH +# (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop). CONFIG_PATHS = { 0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"), 1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"), 2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"), } -PROFILE_NAMES = { - 0: "Profil 0 – Windows", - 1: "Profil 1 – Fusion 360", - 2: "Profil 2 – BricsCAD", -} - # index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge) GRID_COLS = 4 GRID_ROWS = 5 From 9e21360393322aa42d8adae8d44eeb5b494c992e Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:17:38 +0200 Subject: [PATCH 3/8] Allow renaming profile tabs outside Programmiermodus Double-click-to-rename already existed but silently no-op'd unless Programmiermodus was on, and even there it only updated the in-memory self.combined without saving -- the name was lost unless some later button edit happened to trigger an autosave. Since profile_names lives purely in the combined JSON and never touches the board, there's no reason to gate it behind the heavier editing mode: it now works from any mode, loading/saving the combined file directly (auto-creating it if needed) when not already in an active Programmiermodus session, and always persists immediately. (cherry picked from commit 82c3fd005668a5e015cd9582894b2e909c4ca963) --- README.md | 4 ++-- desktop_viewer.py | 35 +++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b165b4f..3638b6f 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B. frische Installation), wird sie automatisch angelegt — per Serial vom Board, falls eins angeschlossen ist, sonst als leere Default-Config. Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich -per Doppelklick auf einen Tab (Programmiermodus) oder `rename_profile()` -(MCP) ändern. +per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder +Programmiermodus) oder `rename_profile()` (MCP) ändern. Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export diff --git a/desktop_viewer.py b/desktop_viewer.py index 54cd76f..81b1805 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -254,14 +254,37 @@ class VersaPadViewer(tk.Tk): btn.configure(text=names[p]) def _rename_tab(self, profile): - if not self.editing.get() or self.combined is None: - return - current = self.combined["profile_names"][profile] + """Profilname per Doppelklick auf den Tab umbenennen -- geht in + jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische + Domänenregeln). Im Programmiermodus wird der bereits geladene + In-Memory-State direkt bearbeitet + autosaved; sonst wird die + kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf + automatisch an, siehe versapad_combined.load_or_fetch()).""" + if self.editing.get() and self.combined is not None: + combined = self.combined + else: + try: + combined = vcomb.load_or_fetch() + except (OSError, ValueError, KeyError) as e: + messagebox.showerror("Umbenennen fehlgeschlagen", str(e)) + return + + current = combined["profile_names"][profile] name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):", initialvalue=current, parent=self) - if name: - self.combined["profile_names"][profile] = name - self._update_tab_labels() + if not name: + return + combined["profile_names"][profile] = name + + if combined is self.combined: + self._autosave_combined() + else: + try: + vcomb.save_file(combined, vcomb.DEFAULT_PATH) + except OSError as e: + messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}") + return + self._update_tab_labels() def _show_mcp_info(self): win = tk.Toplevel(self) From 02d6c0c5e94a25f4f1443a7d98c5fe88b68dd776 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:15:25 +0200 Subject: [PATCH 4/8] Make build_and_deploy.ps1 self-installing and fail loudly pip install of pyinstaller/pystray/pillow was a separate manual step the README only mentioned in prose, so the script silently died on missing packages -- especially bad on Explorer double-click, where the window closes before any error is visible. Consolidate all dependencies into requirements.txt (also used by README's plain "pip install -r" flow), have the script install it itself, wrap the whole build in try/catch with exit-code checks after every native call, and pause on both success and failure unless -NoPause is passed. Also move the build output from %LOCALAPPDATA% into dist/ next to the script, so it's easy to find and matches the already-gitignored dist/ entry. (cherry picked from commit 13c37454797fe825ffe6d12c64e2b32f2d0f676d) --- README.md | 24 +++++++---- build_and_deploy.ps1 | 98 +++++++++++++++++++++++++++++++++----------- requirements.txt | 5 +++ 3 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 requirements.txt diff --git a/README.md b/README.md index 3638b6f..7a06535 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,18 @@ falls gewünscht. ## Voraussetzungen - Python 3.11 oder neuer -- Pakete: `pyserial` (Board-Kommunikation), `pystray` + `pillow` - (Tray-Icon/Desktop-App), optional `mcp` (nur für den MCP-Server) +- Alle Pakete stehen in `requirements.txt` (`pyserial` fürs Board, + `pystray` + `pillow` fürs Tray-Icon/Desktop-Fenster, `mcp` für den + MCP-Server, `pyinstaller` fürs `.exe`-Bauen): ```bash -pip install pyserial pystray pillow mcp +pip install -r requirements.txt ``` Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek — -keine zusätzlichen Pakete nötig. +keine zusätzlichen Pakete nötig. `build_and_deploy.ps1` installiert +`requirements.txt` beim Bauen automatisch selbst — ein manuelles +`pip install` vorher ist dafür nicht nötig. ## Starten @@ -69,8 +72,15 @@ Maschine/Python-Version unterschiedlich). Selbst bauen: .\build_and_deploy.ps1 ``` -Das Skript baut mit PyInstaller (`--onedir --windowed`, eigenes Icon) und -kopiert das Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. +Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus +`requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann +mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das +Ergebnis nach `dist\VersaPadViewer\VersaPadViewer.exe` im Projektordner. +Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das +Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich +bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause` +unterdrückt das für automatisierte Aufrufe/CI). + **Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und @@ -79,8 +89,6 @@ das Nachladen der Bundle-DLLs von einem Netzwerkpfad, ohne jede Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb automatisch zuerst nach `%TEMP%` und baut nur dort. -Voraussetzung: `pip install pyinstaller` zusätzlich zu den obigen Paketen. - ## Aufbau | Datei | Zweck | diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index 8243798..e0617e0 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -1,5 +1,5 @@ # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis -# nach lokal (C:\Users\\AppData\Local\VersaPadViewer). +# nach $projectDir\dist\VersaPadViewer. # # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf # dem Netzlaufwerk (Z:\Git\...): @@ -12,35 +12,87 @@ # SMB-Share, kombiniert mit dem eh schon langen Projektpfad. # # Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach -# das fertige Bundle nach %LOCALAPPDATA% kopieren. Z: wird nur zum Lesen der -# Quelldateien angefasst. +# das fertige Bundle nach $projectDir\dist kopieren. Z: wird nur zum Lesen +# der Quelldateien angefasst. +# +# Fehlerverhalten: Bei Doppelklick im Explorer schliesst sich das Fenster +# sofort nach Skriptende -- ohne Pause waeren Fehlermeldungen unsichtbar +# ("stirbt ohne jede Fehlermeldung"). Deshalb: alles in try/catch, im +# Fehlerfall UND am Ende eine Pause, ausser bei -NoPause (fuer CI/Automation). + +param( + [switch]$NoPause +) $ErrorActionPreference = "Stop" $projectDir = $PSScriptRoot $buildSrc = "$env:TEMP\versapad_build_src" -$localDir = "$env:LOCALAPPDATA\VersaPadViewer" +$localDir = "$projectDir\dist\VersaPadViewer" +$requirementsFile = "$projectDir\requirements.txt" -if (Test-Path $buildSrc) { - Remove-Item $buildSrc -Recurse -Force +function Assert-LastExitCode([string]$step) { + if ($LASTEXITCODE -ne 0) { + throw "$step ist fehlgeschlagen (Exit-Code $LASTEXITCODE) -- Ausgabe oben pruefen." + } +} + +function Wait-ForKeyIfInteractive { + if ($NoPause) { return } + try { + Read-Host "Taste druecken zum Schliessen" + } catch { + # z.B. nicht-interaktiver Aufruf (stdin nicht verfuegbar) -- einfach ignorieren + } } -New-Item -ItemType Directory -Path $buildSrc | Out-Null -Copy-Item "$projectDir\*.py" -Destination $buildSrc -Copy-Item "$projectDir\icon.ico" -Destination $buildSrc -Copy-Item "$projectDir\icon.png" -Destination $buildSrc -Push-Location $buildSrc try { - py -m PyInstaller --windowed --onedir --name VersaPadViewer ` - --icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` - desktop_viewer.py --noconfirm -} finally { - Pop-Location -} + Write-Host "Pruefe Python-Installation..." + py --version + Assert-LastExitCode "'py --version' (ist Python installiert und im PATH?)" -if (Test-Path $localDir) { - Remove-Item $localDir -Recurse -Force -} -Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse -Remove-Item $buildSrc -Recurse -Force + if (-not (Test-Path $requirementsFile)) { + throw "requirements.txt nicht gefunden unter $requirementsFile" + } + Write-Host "Installiere/aktualisiere benoetigte Pakete aus requirements.txt..." + py -m pip install --quiet --disable-pip-version-check -r $requirementsFile + Assert-LastExitCode "Paketinstallation (pip install -r requirements.txt)" -Write-Host "Fertig: $localDir\VersaPadViewer.exe" + if (Test-Path $buildSrc) { + Remove-Item $buildSrc -Recurse -Force + } + New-Item -ItemType Directory -Path $buildSrc | Out-Null + Copy-Item "$projectDir\*.py" -Destination $buildSrc + Copy-Item "$projectDir\icon.ico" -Destination $buildSrc + Copy-Item "$projectDir\icon.png" -Destination $buildSrc + + Push-Location $buildSrc + try { + Write-Host "Baue mit PyInstaller (--onedir --windowed)..." + py -m PyInstaller --windowed --onedir --name VersaPadViewer ` + --icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` + desktop_viewer.py --noconfirm + Assert-LastExitCode "PyInstaller-Build" + } finally { + Pop-Location + } + + if (-not (Test-Path "$buildSrc\dist\VersaPadViewer\VersaPadViewer.exe")) { + throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen." + } + + if (Test-Path $localDir) { + Remove-Item $localDir -Recurse -Force + } + Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse + Remove-Item $buildSrc -Recurse -Force + + Write-Host "" + Write-Host "Fertig: $localDir\VersaPadViewer.exe" -ForegroundColor Green + Wait-ForKeyIfInteractive +} +catch { + Write-Host "" + Write-Host "FEHLER: $($_.Exception.Message)" -ForegroundColor Red + Wait-ForKeyIfInteractive + exit 1 +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed0027c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pyserial +pystray +pillow +mcp +pyinstaller From 9f0ae127943412bf37ec6edb8dfaba4eb1acbd75 Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:55:41 +0200 Subject: [PATCH 5/8] Keep build_and_deploy.ps1 deploying to %LOCALAPPDATA%, document upstream cherry-picks Cherry-picked commits from Julian Appel's dev/jappel branch changed the default deploy target to $projectDir\dist\VersaPadViewer. Revert just that to %LOCALAPPDATA%\VersaPadViewer, which is where the actually installed/running instance on this machine lives -- switching would orphan the existing install and any shortcuts pointing at it. Document the cherry-pick provenance and what was deliberately left out (.mcp.json's hardcoded path, the docs/ tree) in AGENTS.md. --- AGENTS.md | 18 ++++++++++++++++++ README.md | 2 +- build_and_deploy.ps1 | 7 +++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 653ea34..fcc3487 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,24 @@ 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:** per Cherry-Pick aus + Julian Appels eigenem `dev/jappel`-Branch (`git.jappel.io/jappel/ + VersaGUI-py`) übernommen, der sich zeitgleich mit unserem eigenen + COM-Port-Fix entwickelt hat (kein Fork-Sync-Automatismus -- manuell + gegengelesen und übernommen, siehe Sessionlog). **Eine Abweichung vom + Original:** dort landet der Build jetzt in `$projectDir\dist\ + VersaPadViewer` statt `%LOCALAPPDATA%\VersaPadViewer` -- hier bewusst + NICHT übernommen, weil das die tatsächlich installierte/genutzte Instanz + wäre und bestehende Verknüpfungen sonst ins Leere zeigen würden. + `build_and_deploy.ps1` hier weiterhin `%LOCALAPPDATA%\VersaPadViewer`. + Bestehende `versapad_config_all.json` vom alten Desktop-Pfad wurde einmalig + nach `%LOCALAPPDATA%\VersaPadViewer\` migriert (kopiert, Original bleibt). + Nicht übernommen: die `.mcp.json`-Registrierung (hartkodierter Pfad auf + Julians Maschine, `C:\Users\Julian\...`) -- dafür stattdessen ein Issue in + seinem Repo (git.jappel.io/jappel/VersaGUI-py) angelegt, damit relative/ + portable Pfadauflösung dort nachgezogen werden kann; die drei + `docs/*.md`-Referenzdateien (unser eigener Dokumentationsstandard hält für + dieses Tool bewusst bei README + AGENTS.md ohne vollen `docs/`-Baum). - **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und `desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es diff --git a/README.md b/README.md index 7a06535..9de3204 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,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` diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index e0617e0..bdf1608 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) { From 4b5da69174757d4fca0d56a493c2245927811848 Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:15:36 +0200 Subject: [PATCH 6/8] Add free-text notes per button/encoder action Lets you record what a binding actually does (e.g. "Save in Fusion 360") alongside the auto-generated label ("Strg+S"). Notes live in a new "note" field on every action dict, purely local like profile names -- the firmware struct has no room for strings, and pack_config/unpack_config already only touch type/data so the extra key round-trips harmlessly. Two things had to be handled carefully: changing a button's key/type must not wipe its note (all set_button_*/set_encoder_* setters and the edit dialog now carry the previous note forward), and re-reading from the board must not erase notes either, since the firmware doesn't know about them -- versapad_combined.merge_notes() restores them onto the freshly-fetched state by button/encoder index. Editable via the Programmiermodus dialog (new text field), visible on both the desktop card (grown from 84 to 114px to fit it) and the browser view. MCP server gets set_button_note()/set_encoder_note() so notes can be set programmatically too. --- AGENTS.md | 12 +++++++ README.md | 4 +++ action_dialog.py | 14 +++++++-- desktop_viewer.py | 36 +++++++++++++++------ server.py | 23 +++++++++++--- versapad_combined.py | 45 +++++++++++++++++++++++--- versapad_data.py | 9 ++++-- versapad_mcp_server.py | 71 +++++++++++++++++++++++++++++++----------- 8 files changed, 173 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fcc3487..9fad436 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,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 diff --git a/README.md b/README.md index 9de3204..cd9d373 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ 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 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/desktop_viewer.py b/desktop_viewer.py index 81b1805..d604da5 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -54,7 +54,7 @@ OK_GREEN = "#3ecf6e" WARN_RED = "#e0895a" POLL_MS = 1500 -CARD_W, CARD_H = 150, 84 +CARD_W, CARD_H = 150, 114 SERIAL_POLL_S = 1.5 SERIAL_IDLE_S = 3.0 @@ -97,9 +97,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"]}, ...] @@ -460,7 +462,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 +643,16 @@ 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=26, width=CARD_W - 20, height=30) + + 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=58, width=CARD_W - 20, height=34) 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 - 18, width=CARD_W - 20, height=14) if editable: card.configure(cursor="hand2") @@ -661,17 +669,25 @@ 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") + 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) + note_label.bind("", handler) + for child in top.winfo_children(): child.bind("", handler) 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..ea96a41 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({ diff --git a/versapad_data.py b/versapad_data.py index 0e147c6..8c4380a 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -206,15 +206,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 797ae5e..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 ──────────────────────────────────────────────────────────────────── @@ -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. Gibt den COM-Port danach - sofort wieder frei (siehe get_board_status()).""" + 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() From 91bac353b49edf3e72c283dd9fcdb26d572912ee Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:12:33 +0200 Subject: [PATCH 7/8] Keep user config out of the install dir; frameless window with working resize Three related fixes after the notes feature landed: Config location: build_and_deploy.ps1 wipes its target directory before every deploy, and app_dir() had just been pointed at that same directory -- so every rebuild silently deleted the user's config and the app rebuilt it empty from the board, losing all notes. Config now lives in %APPDATA%\VersaPadViewer (roaming), separate from the install dir, and the build script additionally rescues any versapad_config*.json it finds in the target so legacy installs survive an upgrade. Window chrome: hide the title bar (plain Tk overrideredirect, no ctypes window manipulation) and move the mode checkboxes up next to the title, which reclaims two full rows of header height that the taller note cards had eaten. Removing the title bar also removes the resize borders, so add a grip -- anchored with place() to the window corner rather than packed after the content, which would push it out of view exactly when the window is too small and the grip is needed. Default geometry grown to fit the taller cards, and empty encoder note lines are no longer rendered at all. Note truncation: the note area was a fixed 34px and cut longer notes mid-word; it now takes the remaining card height. --- build_and_deploy.ps1 | 19 +++++ desktop_viewer.py | 170 +++++++++++++++++++++++++++++++------------ versapad_combined.py | 1 + versapad_data.py | 31 ++++++-- 4 files changed, 165 insertions(+), 56 deletions(-) diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index bdf1608..0accd2c 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -83,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 d604da5..a27e191 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -55,6 +55,9 @@ WARN_RED = "#e0895a" POLL_MS = 1500 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 @@ -124,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 @@ -151,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), @@ -209,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) @@ -327,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): @@ -428,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() @@ -643,16 +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=30) + 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=58, width=CARD_W - 20, height=34) + 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 - 18, width=CARD_W - 20, height=14) + x=10, y=CARD_H - 16, width=CARD_W - 20, height=13) if editable: card.configure(cursor="hand2") @@ -678,15 +746,21 @@ class VersaPadViewer(tk.Tk): 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") - 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") + # 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) top.bind("", handler) - note_label.bind("", handler) + if note_label is not None: + note_label.bind("", handler) for child in top.winfo_children(): child.bind("", handler) diff --git a/versapad_combined.py b/versapad_combined.py index ea96a41..da1f145 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -122,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 8c4380a..58c122f 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -11,17 +11,32 @@ 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 From 9464cafaab15a1b2186b554dbe0ce8c647a5db7b Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:19:39 +0200 Subject: [PATCH 8/8] Document today's config-path, window-chrome and tooling gotchas AGENTS.md gains the rules the day's debugging produced: never put the config in the install dir (the build script wipes it, silently costing all local-only fields), never branch app_dir() on sys.frozen (two diverging configs), the consequences that come with a frameless window, and why the resize grip must be placed rather than packed. Also records that Claude's Bash and PowerShell tools can see different files under AppData, which made "the notes are in the file" and "the app shows no notes" both true at once -- with the diagnostic-module approach that finally settled it. README: config now lives in %APPDATA%\VersaPadViewer, plus the frameless window and notes in the feature list. --- AGENTS.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- README.md | 27 +++++++++++++++------- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9fad436..4773d92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,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 @@ -131,7 +153,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 unten "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 @@ -189,6 +214,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). @@ -239,6 +282,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 cd9d373..4ca6982 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, @@ -41,6 +41,11 @@ falls gewünscht. 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 @@ -111,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