VersaGUI-py/versapad_combined.py
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

143 lines
5.9 KiB
Python

"""
Kombiniertes Ein-Datei-Format fuer den Programmiermodus: alle 3 Profile +
Makro-Tabelle + (nur lokal gespeicherte) Profilnamen in einer JSON-Datei,
plus Konvertierung zu/von den Binaerblobs aus versapad_protocol.py.
Passt besser zum echten Geraeteprotokoll als die 3 Einzeldateien von
versapad_data.py: CONFIG_BEGIN/DATA/COMMIT ueberträgt ohnehin immer den
kompletten 740B-Block (alle 3 Profile auf einmal), nie nur ein Profil.
Profilnamen: die Firmware-Structs (SDeviceConfig/SDeviceProfile) haben
keinerlei Platz fuer einen String -- Header exakt 32B, jedes Profil exakt
236B, alles verplant (siehe nvm_config.h). Namen bleiben deshalb rein
lokal in dieser Datei, landen nie auf dem Board.
"""
import json
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")
DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
def _empty_profile():
buttons = [{"index": i, "action": {"type": "None", "data": 0},
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
"anim": "Static", "period_ms": 4000}} for i in range(20)]
none = {"type": "None", "data": 0}
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
return {"buttons": buttons, "encoders": encoders}
def default_combined():
"""Seed aus den 3 bestehenden Einzel-JSONs (versapad_data.CONFIG_PATHS),
fehlende Dateien werden als leeres Profil aufgefuellt. Makros leer,
da die Einzel-JSONs keine Makro-Schritte enthalten (kein Exportformat
dafuer) -- fuer echte Makro-Inhalte "Vom Board laden" benutzen."""
profiles = []
for p in range(3):
try:
cfg = vp.load_profile(p)
profiles.append({
"buttons": [{"index": b["index"], "action": b["action"], "led": b["led"]} for b in cfg["buttons"]],
"encoders": [{"index": e["index"], "sw": e["sw"], "cw": e["cw"], "ccw": e["ccw"]} for e in cfg["encoders"]],
})
except FileNotFoundError:
profiles.append(_empty_profile())
return {
"active_profile": 0,
"global_brightness": 255,
"enc_sensitivity": [1, 1, 1, 1],
"profile_names": list(DEFAULT_NAMES),
"profiles": profiles,
"macros": [[] for _ in range(proto.MACRO_SLOTS)],
}
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()."""
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"],
"macros": macro_slots,
}
def to_binary(combined):
"""-> (config_bytes[740], macro_bytes[512])"""
config_bytes = proto.pack_config({
"active_profile": combined["active_profile"],
"global_brightness": combined["global_brightness"],
"enc_sensitivity": combined["enc_sensitivity"],
"profiles": combined["profiles"],
})
macro_bytes = proto.pack_macros(combined["macros"])
return config_bytes, macro_bytes
def save_file(combined, path=DEFAULT_PATH):
with open(path, "w", encoding="utf-8") as f:
json.dump(combined, f, indent=2, ensure_ascii=False)
return 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