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.
This commit is contained in:
parent
8a2a73c68d
commit
540ce5b1eb
4 changed files with 104 additions and 10 deletions
14
AGENTS.md
14
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`
|
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
|
- **Bug behoben 2026-08-07:** `_on_toggle_editing()` initialisierte
|
||||||
`self.combined` beim ersten Aktivieren des Programmiermodus mit
|
`self.combined` beim ersten Aktivieren des Programmiermodus mit
|
||||||
`vcomb.default_combined()` — das seedet ALLE 3 Profile aus den alten
|
`vcomb.default_combined()` — das seedet ALLE 3 Profile aus den alten
|
||||||
|
|
|
||||||
|
|
@ -526,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)
|
||||||
|
|
@ -538,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
|
||||||
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
|
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):
|
def _render(self):
|
||||||
for w in self.grid_frame.winfo_children():
|
for w in self.grid_frame.winfo_children():
|
||||||
|
|
|
||||||
21
server.py
21
server.py
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue