diff --git a/AGENTS.md b/AGENTS.md index 05fd16f..0604c07 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,32 @@ 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 + 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 +222,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..926337b 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: @@ -520,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) @@ -532,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 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."""