From 4215323f3f652766d06189da67b068d477d8867e Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:37:40 +0200 Subject: [PATCH 1/3] Use lightweight READ_STATUS instead of full CONFIG_READ for profile polling Live-Sync polls read_active_profile() every 1.5s, but it was requesting a full 740-byte config dump (~124 chunk packets) just to read one byte out of it. The firmware handles CONFIG_READ synchronously and blocking, which delayed its LED animation update enough to make Pulse/Blink visibly stutter on every poll cycle -- see VersaMCU's doc/07_serial_protocol.md ("READ_STATUS vs. CONFIG_READ fuer Polling") for the root-cause writeup on the firmware side. read_active_profile() now sends VersaMCU's new CMD_READ_STATUS (0x06) and reads back a single EVT_STATUS (0x86) packet instead of driving the chunked dump protocol. Requires the corresponding firmware update (VersaMCU commit "Add lightweight READ_STATUS command..."); older firmware without it just times out gracefully (last_error stays "timeout", no crash). desktop_viewer.py's SERIAL_POLL_S/SERIAL_IDLE_S already sit back at their original 1.5s/3.0s (temporarily raised to 8s as a stopgap before the firmware fix landed) since the expensive dump is gone now. Verified end-to-end against a freshly flashed board: correct profile returned, no more visible LED stutter with Live-Sync on. Co-Authored-By: Claude Sonnet 5 --- versapad_serial.py | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/versapad_serial.py b/versapad_serial.py index 2c795a1..d3eb978 100644 --- a/versapad_serial.py +++ b/versapad_serial.py @@ -37,6 +37,7 @@ CONFIG_SIZE = 740 MACRO_SIZE = 512 ACTIVE_PROFILE_OFFSET = 7 +CMD_READ_STATUS = 0x06 CMD_CONFIG_BEGIN = 0x10 CMD_CONFIG_DATA = 0x11 CMD_CONFIG_COMMIT = 0x12 @@ -46,6 +47,7 @@ CMD_MACRO_DATA = 0x21 CMD_MACRO_COMMIT = 0x22 CMD_MACRO_READ = 0x23 +EVT_STATUS = 0x86 EVT_CONFIG_ACK = 0x90 EVT_CONFIG_NACK = 0x91 EVT_CONFIG_BEGIN = 0x92 @@ -187,16 +189,39 @@ class VersaPadLink: # ── Oeffentliche API ──────────────────────────────────────── - def read_active_profile(self): - """0-2 bei Erfolg, None bei Timeout/Fehler/kein Board.""" - buf = self._read_dump(CMD_CONFIG_READ, EVT_CONFIG_BEGIN, EVT_CONFIG_DATA, EVT_CONFIG_END, CONFIG_SIZE) - if buf is None: + def read_active_profile(self, deadline_s=1.0): + """0-2 bei Erfolg, None bei Timeout/Fehler/kein Board. + + Nutzt CMD_READ_STATUS (1 Antwortpaket) statt eines vollen CONFIG_READ- + Dumps (124 Pakete) -- der volle Dump blockiert die Firmware lange genug, + dass laufende LED-Pulse-Animationen beim Live-Sync-Polling sichtbar + stottern (siehe VersaMCU doc/07_serial_protocol.md, "READ_STATUS vs. + CONFIG_READ fuer Polling"). Für ältere Firmware ohne CMD_READ_STATUS + faellt das Board auf keine Antwort zurueck -> Timeout, kein Absturz.""" + if not self._ensure_open(): return None - profile = buf[ACTIVE_PROFILE_OFFSET] - if profile not in (0, 1, 2): + try: + self.ser.reset_input_buffer() + self.ser.write(bytes([CMD_READ_STATUS, 0, 0, 0, 0, 0, 0, 0])) + + deadline = time.time() + deadline_s + while time.time() < deadline: + raw = self.ser.read(PACKET_SIZE) + if len(raw) < PACKET_SIZE: + continue + if raw[0] == EVT_STATUS: + profile = raw[1] + if profile not in (0, 1, 2): + self.last_error = "timeout" + return None + self.last_error = None + return profile self.last_error = "timeout" return None - return profile + except serial.SerialException: + self.close() + self.last_error = "busy" + return None def read_full_config(self): """740B Rohdaten (alle 3 Profile) oder None.""" From 8a2a73c68da5dce3f572a20cb0f3a15b54b1a2b8 Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:50:52 +0200 Subject: [PATCH 2/3] Fix Programmiermodus default-seed bug wiping other profiles _on_toggle_editing() seeded self.combined from default_combined() on first activation, which reads the stale per-profile JSONs for all 3 profiles instead of the current versapad_config_all.json. Writing to board while only editing one profile silently reverted the other two. Now prefers loading the current combined file, falling back to defaults only if it doesn't exist. Also documents the READ_STATUS polling change and the jappel PR workflow constraint in AGENTS.md. --- AGENTS.md | 31 ++++++++++++++++++++++++++++++- desktop_viewer.py | 8 +++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 05fd16f..fdfc96a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,14 @@ Nutzerorientierte Einführung: [`README.md`](README.md). Makros per 8-Byte-Paket-Protokoll, Board-Identifikation per VID/PID `239A:0042`. Schreiben ist sicher im Sinne von "kann NVM nicht zerlegen" — Firmware prüft Magic/CRC/Keycode-Bereich vor jedem Save, antwortet - sonst nur mit NACK. + sonst nur mit NACK. `read_active_profile()` (für Live-Sync-Polling) + nutzt seit 2026-08-07 `CMD_READ_STATUS`/`EVT_STATUS` (0x06/0x86, ein + Antwortpaket) statt eines vollen `CONFIG_READ`-Dumps (124 Pakete) — + Letzterer blockiert die Firmware in `poll_vendor()` lang genug, dass + laufende LED-Pulse-Animationen sichtbar stottern (Root Cause + Fix in + VersaMCU-Commit "Add lightweight READ_STATUS command..."). **Braucht + entsprechend neue Firmware auf dem Board** — mit altem `versapad`-Firmwarestand + 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: @@ -86,6 +93,18 @@ 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". +- **Bug behoben 2026-08-07:** `_on_toggle_editing()` initialisierte + `self.combined` beim ersten Aktivieren des Programmiermodus mit + `vcomb.default_combined()` — das seedet ALLE 3 Profile aus den alten + Einzel-JSONs `versapad_config{1,2,3}.json`, nicht aus der aktuellen + `versapad_config_all.json` oder vom Board. Ein Klick auf "Zum Board + übertragen" hat dadurch beim Testen alle 3 Profile auf einen veralteten + Stand zurückgesetzt, obwohl nur ein Profil-Tab sichtbar bearbeitet wurde — + der Schaden an den anderen beiden Profilen blieb unbemerkt, bis explizit + jedes Profil einzeln gegengelesen wurde. Fix: lädt jetzt zuerst + `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. - 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). @@ -189,3 +208,13 @@ Committen: prägnante Commit-Message je abgeschlossenem, verifiziertem Arbeitspaket. Nach jedem Push: alle bekannten Remotes prüfen (`origin` auf GitHub, `jappel` auf git.jappel.io) — beide müssen synchron bleiben, siehe Speicher-Notiz "Multi-Remote-Repos synchron halten". + +**PR-Erstellung auf git.jappel.io per API/curl mit Access-Token wird vom +Bash-Classifier geblockt** (Auto-Mode, gilt auch für `git credential fill`), +siehe Speicher-Notiz "Bash-Klassifikator blockt Credential/Auth-Schreibzugriffe". +Branch pushen geht (nutzt den Git-eigenen Credential-Helper, kein Token im +Klartext im Bash-Aufruf), den fertigen PR muss der User über den von Forgejo +nach dem Push ausgegebenen Compare-Link selbst anlegen (oder Claude einen +Token geben, der dann NICHT wiederverwendbar im Bash-Aufruf landen darf, +sondern nur für den einen `curl`-Call — auch das kann der Classifier trotzdem +blocken, dann bleibt nur der manuelle Link). diff --git a/desktop_viewer.py b/desktop_viewer.py index 1b80b6d..008b2c7 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -391,7 +391,13 @@ class VersaPadViewer(tk.Tk): self._on_toggle_sync() self.sync_check.configure(state="disabled") if self.combined is None: - self.combined = vcomb.default_combined() + if os.path.exists(vcomb.DEFAULT_PATH): + try: + self.combined = vcomb.load_file(vcomb.DEFAULT_PATH) + except (OSError, ValueError, KeyError): + 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") else: From 540ce5b1eb77230016ca35beca9450f95d6e5e79 Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:06:53 +0200 Subject: [PATCH 3/3] Fix crash when desktop config JSON is deleted: self-heal from board server.py and desktop_viewer.py read-only mode required the desktop config JSONs to exist and crashed/showed "Config-Datei fehlt" if one was deleted, even though the board already holds the config durably in NVM. Add versapad_combined.fetch_from_board()/load_or_fetch(): a missing combined JSON is now transparently rebuilt from the board via serial and cached back to disk, falling back to the legacy per-profile JSONs (or a clear error) only when the board is unreachable. --- AGENTS.md | 14 +++++++++++++ desktop_viewer.py | 30 +++++++++++++++++++++++---- server.py | 21 +++++++++++++------ versapad_combined.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fdfc96a..0604c07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,20 @@ 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". +- **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 + eine ungefangene `FileNotFoundError` bzw. "Config-Datei fehlt"-Anzeige, + obwohl das Board die Config laengst dauerhaft im NVM haelt. Fix: neue + `versapad_combined.fetch_from_board()`/`load_or_fetch()` -- fehlt die + Kombi-JSON, wird sie automatisch per Serial vom Board neu aufgebaut und + als Cache gespeichert (self-healing), nur bei unerreichbarem Board (Port + belegt/kein Board) bleibt der Fallback auf die alten Einzel-JSONs bzw. + eine Klartext-Fehlermeldung. In `desktop_viewer.py` nur versucht, wenn + Live-Sync aus ist (sonst haelt der Serial-Hintergrundthread den + COM-Port -- zwei gleichzeitige Zugriffe auf denselben `self.ser` waeren + eine Race Condition). Die Desktop-JSONs sind damit reiner Lesecache, kein + Pflegeaufwand mehr fuers Board-Backup. - **Bug behoben 2026-08-07:** `_on_toggle_editing()` initialisierte `self.combined` beim ersten Aktivieren des Programmiermodus mit `vcomb.default_combined()` — das seedet ALLE 3 Profile aus den alten diff --git a/desktop_viewer.py b/desktop_viewer.py index 008b2c7..926337b 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -526,8 +526,13 @@ 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. - Faellt zurueck auf die klassischen versapad_config{1,2,3}.json, wenn - es noch keine kombinierte Datei gibt.""" + 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): try: data = vcomb.load_file(vcomb.DEFAULT_PATH) @@ -538,8 +543,25 @@ class VersaPadViewer(tk.Tk): }) return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" except (KeyError, IndexError, ValueError): - pass # kaputte/unvollstaendige Datei -- auf Einzel-JSONs ausweichen - return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}" + 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: + hint = (" (Live-Sync ist an -- COM-Port belegt, zum automatischen " + "Neuladen vom Board erst ausschalten)") if self.live_sync.get() else "" + raise FileNotFoundError(f"{e}{hint}") from e def _render(self): for w in self.grid_frame.winfo_children(): diff --git a/server.py b/server.py index 1090911..d184a88 100644 --- a/server.py +++ b/server.py @@ -1,7 +1,10 @@ """ VersaPad Viewer -- Browser-Variante. -Liest bei jedem Request live die 3 Config-JSONs vom Desktop und rendert -die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. +Liest bei jedem Request die kombinierte Config-JSON vom Desktop und +rendert die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. Fehlt +die JSON (z.B. geloescht), wird sie automatisch per Serial vom Board neu +aufgebaut und als neuer Cache gespeichert -- das Board ist die eigentliche +Quelle der Wahrheit, siehe versapad_combined.load_or_fetch(). Kein Build-Schritt, kein externes Framework -- nur stdlib. Start: python server.py [--port 8765] @@ -12,6 +15,7 @@ import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs +import versapad_combined as vcomb import versapad_data as vp PAGE_CSS = """ @@ -87,7 +91,12 @@ def render_encoder(enc): def render_page(profile): - cfg = vp.load_profile(profile) + combined = vcomb.load_or_fetch() + raw = combined["profiles"][profile] + cfg = vp.annotate_profile({ + "buttons": [dict(b) for b in raw["buttons"]], + "encoders": [dict(e) for e in raw["encoders"]], + }) tabs = "".join( f'{html.escape(vp.PROFILE_NAMES[p])}' for p in sorted(vp.PROFILE_NAMES) @@ -107,7 +116,7 @@ def render_page(profile):
{cells}

Encoder

{encoders}
- + """ @@ -130,10 +139,10 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) - except FileNotFoundError as e: + except (FileNotFoundError, RuntimeError) as e: self.send_response(500) self.end_headers() - self.wfile.write(f"Config-Datei fehlt: {e}".encode("utf-8")) + self.wfile.write(f"Config nicht verfuegbar: {e}".encode("utf-8")) def main(): diff --git a/versapad_combined.py b/versapad_combined.py index 5423196..e6011ae 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -17,6 +17,7 @@ import os 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") @@ -92,3 +93,51 @@ def save_file(combined, path=DEFAULT_PATH): def load_file(path=DEFAULT_PATH): with open(path, "r", encoding="utf-8") as f: return json.load(f) + + +def fetch_from_board(link=None, profile_names=None): + """Liest Config+Makros direkt vom Board per Serial (~1-2s), das Board + ist die eigentliche Quelle der Wahrheit (write_to_board() speichert + dauerhaft im NVM -- die JSON-Dateien hier sind nur ein Lesecache). + link: bestehender VersaPadLink wiederverwenden (z.B. desktop_viewer's + self._link, damit nicht zwei Verbindungen um denselben COM-Port + konkurrieren) -- sonst wird eine eigene geoeffnet und wieder + geschlossen. Wirft RuntimeError mit Klartext-Ursache (last_error), + z.B. wenn der Port gerade von VersaGUI/einem anderen Viewer belegt ist.""" + owns_link = link is None + if owns_link: + link = vs.VersaPadLink() + try: + raw_cfg = link.read_full_config() + if raw_cfg is None: + raise RuntimeError(f"Config vom Board laden fehlgeschlagen: {link.last_error}") + raw_macros = link.read_macros() + if raw_macros is None: + raise RuntimeError(f"Makros vom Board laden fehlgeschlagen: {link.last_error}") + cfg_dict = proto.unpack_config(raw_cfg) + if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): + raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") + macro_slots = proto.unpack_macros(raw_macros) + return from_binary(cfg_dict, macro_slots, profile_names=profile_names) + finally: + if owns_link: + link.close() + + +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).""" + if os.path.exists(path): + return load_file(path) + combined = fetch_from_board(link=link, profile_names=profile_names) + try: + save_file(combined, path) + except OSError: + pass # Board-Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden + return combined