""" 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.join(vp.app_dir(), "versapad_config_all.json") DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] def _empty_profile(): buttons = [{"index": i, "action": {"type": "None", "data": 0, "note": ""}, "led": {"r": 80, "g": 40, "b": 0, "brightness": 255, "anim": "Static", "period_ms": 4000}} for i in range(20)] none = {"type": "None", "data": 0, "note": ""} 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(). Actions kommen frisch vom Board ohne "note" (die Firmware kennt keine Notizen, siehe merge_notes()) -- hier nur mit leerem Default versehen, damit das Feld ueberall verlaesslich existiert.""" profiles = config_dict["profiles"] for profile in profiles: for b in profile["buttons"]: b["action"].setdefault("note", "") for e in profile["encoders"]: for field in ("sw", "cw", "ccw"): e[field].setdefault("note", "") 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": profiles, "macros": macro_slots, } def merge_notes(combined, previous): """Kopiert Notizen (Button/Encoder-Aktion) aus einem vorherigen State in einen frisch vom Board gelesenen State -- wie profile_names sind Notizen rein lokal und wuerden bei jedem load_from_board()/fetch_from_board() sonst verloren gehen, weil die Firmware sie nicht kennt. previous=None (z.B. allererstes Laden) -> nichts zu tun, combined unveraendert zurueckgegeben. Aendert combined in-place und gibt es zurueck.""" if not previous: return combined for p_idx, profile in enumerate(combined["profiles"]): if p_idx >= len(previous["profiles"]): continue prev_profile = previous["profiles"][p_idx] prev_buttons = {b["index"]: b for b in prev_profile.get("buttons", [])} for b in profile["buttons"]: prev = prev_buttons.get(b["index"]) if prev: b["action"]["note"] = prev["action"].get("note", "") prev_encoders = {e["index"]: e for e in prev_profile.get("encoders", [])} for e in profile["encoders"]: prev = prev_encoders.get(e["index"]) if prev: for field in ("sw", "cw", "ccw"): e[field]["note"] = prev[field].get("note", "") return combined 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): os.makedirs(os.path.dirname(path), exist_ok=True) 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, oder frische Installation ohne jede Config), wird sie automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache dafuer), und falls auch das Board nicht erreichbar ist (nicht verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite) als leere Default-Config (vgl. default_combined()) -- damit ist das Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit einem Fehler zu blockieren.""" if os.path.exists(path): return load_file(path) try: combined = fetch_from_board(link=link, profile_names=profile_names) except RuntimeError: combined = default_combined() if profile_names: combined["profile_names"] = profile_names try: save_file(combined, path) except OSError: pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden return combined def read_profile_names(path=DEFAULT_PATH): """Nur die (lokalen) Profilnamen lesen, ohne die volle Config zu brauchen -- fuer Tab-Beschriftungen im Nur-Lese-Modus. Greift bewusst nicht aufs Board zu (kein COM-Port-Konflikt mit Live-Sync), faellt bei fehlender/kaputter Datei auf DEFAULT_NAMES zurueck.""" if os.path.exists(path): try: return load_file(path).get("profile_names", list(DEFAULT_NAMES)) except (OSError, ValueError): pass return list(DEFAULT_NAMES)