Compare commits

..

3 commits

Author SHA1 Message Date
cjjohn
540ce5b1eb 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.
2026-08-08 17:06:53 +02:00
cjjohn
8a2a73c68d 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.
2026-08-07 20:50:52 +02:00
cjjohn
4215323f3f 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 <noreply@anthropic.com>
2026-08-07 15:37:40 +02:00
5 changed files with 173 additions and 19 deletions

View file

@ -29,7 +29,14 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
Makros per 8-Byte-Paket-Protokoll, Board-Identifikation per VID/PID Makros per 8-Byte-Paket-Protokoll, Board-Identifikation per VID/PID
`239A:0042`. Schreiben ist sicher im Sinne von "kann NVM nicht zerlegen" `239A:0042`. Schreiben ist sicher im Sinne von "kann NVM nicht zerlegen"
— Firmware prüft Magic/CRC/Keycode-Bereich vor jedem Save, antwortet — 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 + - `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros +
**nur lokal gespeicherte** Profilnamen), Default-Pfad **nur lokal gespeicherte** Profilnamen), Default-Pfad
`~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll: `~\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` vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json`
zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine
Bearbeitung "verschwunden". Bearbeitung "verschwunden".
- **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 - HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein
Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm
wie bei den Fensterverstecktricks in anderen Projekten zu riskieren). wie bei den Fensterverstecktricks in anderen Projekten zu riskieren).
@ -189,3 +222,13 @@ Committen: prägnante Commit-Message je abgeschlossenem, verifiziertem
Arbeitspaket. Nach jedem Push: alle bekannten Remotes prüfen (`origin` auf Arbeitspaket. Nach jedem Push: alle bekannten Remotes prüfen (`origin` auf
GitHub, `jappel` auf git.jappel.io) — beide müssen synchron bleiben, siehe GitHub, `jappel` auf git.jappel.io) — beide müssen synchron bleiben, siehe
Speicher-Notiz "Multi-Remote-Repos synchron halten". 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).

View file

@ -391,6 +391,12 @@ class VersaPadViewer(tk.Tk):
self._on_toggle_sync() self._on_toggle_sync()
self.sync_check.configure(state="disabled") self.sync_check.configure(state="disabled")
if self.combined is None: if self.combined is None:
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.combined = vcomb.default_combined()
self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs) self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs)
self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten") self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten")
@ -520,8 +526,13 @@ class VersaPadViewer(tk.Tk):
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen. sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
Faellt zurueck auf die klassischen versapad_config{1,2,3}.json, wenn Fehlt sie (z.B. versehentlich geloescht) und ist Live-Sync gerade aus
es noch keine kombinierte Datei gibt.""" (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): if os.path.exists(vcomb.DEFAULT_PATH):
try: try:
data = vcomb.load_file(vcomb.DEFAULT_PATH) data = vcomb.load_file(vcomb.DEFAULT_PATH)
@ -532,8 +543,25 @@ class VersaPadViewer(tk.Tk):
}) })
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
except (KeyError, IndexError, ValueError): except (KeyError, IndexError, ValueError):
pass # kaputte/unvollstaendige Datei -- auf Einzel-JSONs ausweichen 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]}" 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): def _render(self):
for w in self.grid_frame.winfo_children(): for w in self.grid_frame.winfo_children():

View file

@ -1,7 +1,10 @@
""" """
VersaPad Viewer -- Browser-Variante. VersaPad Viewer -- Browser-Variante.
Liest bei jedem Request live die 3 Config-JSONs vom Desktop und rendert Liest bei jedem Request die kombinierte Config-JSON vom Desktop und
die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. 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. Kein Build-Schritt, kein externes Framework -- nur stdlib.
Start: python server.py [--port 8765] Start: python server.py [--port 8765]
@ -12,6 +15,7 @@ import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import versapad_combined as vcomb
import versapad_data as vp import versapad_data as vp
PAGE_CSS = """ PAGE_CSS = """
@ -87,7 +91,12 @@ def render_encoder(enc):
def render_page(profile): 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( tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>' f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>'
for p in sorted(vp.PROFILE_NAMES) for p in sorted(vp.PROFILE_NAMES)
@ -107,7 +116,7 @@ def render_page(profile):
<div class="grid">{cells}</div> <div class="grid">{cells}</div>
<h2>Encoder</h2> <h2>Encoder</h2>
<div class="encoders">{encoders}</div> <div class="encoders">{encoders}</div>
<footer>Quelle: {html.escape(vp.CONFIG_PATHS[profile])}</footer> <footer>Quelle: {html.escape(vcomb.DEFAULT_PATH)}</footer>
</body></html>""" </body></html>"""
@ -130,10 +139,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
except FileNotFoundError as e: except (FileNotFoundError, RuntimeError) as e:
self.send_response(500) self.send_response(500)
self.end_headers() 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(): def main():

View file

@ -17,6 +17,7 @@ import os
import versapad_data as vp import versapad_data as vp
import versapad_protocol as proto 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.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): def load_file(path=DEFAULT_PATH):
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
return json.load(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

View file

@ -37,6 +37,7 @@ CONFIG_SIZE = 740
MACRO_SIZE = 512 MACRO_SIZE = 512
ACTIVE_PROFILE_OFFSET = 7 ACTIVE_PROFILE_OFFSET = 7
CMD_READ_STATUS = 0x06
CMD_CONFIG_BEGIN = 0x10 CMD_CONFIG_BEGIN = 0x10
CMD_CONFIG_DATA = 0x11 CMD_CONFIG_DATA = 0x11
CMD_CONFIG_COMMIT = 0x12 CMD_CONFIG_COMMIT = 0x12
@ -46,6 +47,7 @@ CMD_MACRO_DATA = 0x21
CMD_MACRO_COMMIT = 0x22 CMD_MACRO_COMMIT = 0x22
CMD_MACRO_READ = 0x23 CMD_MACRO_READ = 0x23
EVT_STATUS = 0x86
EVT_CONFIG_ACK = 0x90 EVT_CONFIG_ACK = 0x90
EVT_CONFIG_NACK = 0x91 EVT_CONFIG_NACK = 0x91
EVT_CONFIG_BEGIN = 0x92 EVT_CONFIG_BEGIN = 0x92
@ -187,16 +189,39 @@ class VersaPadLink:
# ── Oeffentliche API ──────────────────────────────────────── # ── Oeffentliche API ────────────────────────────────────────
def read_active_profile(self): def read_active_profile(self, deadline_s=1.0):
"""0-2 bei Erfolg, None bei Timeout/Fehler/kein Board.""" """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: 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 return None
profile = buf[ACTIVE_PROFILE_OFFSET] 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): if profile not in (0, 1, 2):
self.last_error = "timeout" self.last_error = "timeout"
return None return None
self.last_error = None
return profile return profile
self.last_error = "timeout"
return None
except serial.SerialException:
self.close()
self.last_error = "busy"
return None
def read_full_config(self): def read_full_config(self):
"""740B Rohdaten (alle 3 Profile) oder None.""" """740B Rohdaten (alle 3 Profile) oder None."""