""" Liest die 3 VersaPad-Config-JSONs (Desktop) und decodiert sie zu lesbaren Strukturen. Referenz: VersaGUI/src/ActionDialog.cs, ConfigJson.cs (s_consumer-Tabelle, HidKeyName()-Sonderzeichen, Modifier-Bits). Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. """ import json import os NUM_PROFILES = 3 APP_NAME = "VersaPadViewer" def app_dir(): """Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json, siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und unter jedem Benutzer. Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen dieselbe Datei sehen, sonst laufen zwei Configs auseinander und Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist am 2026-08-15 passiert -- .exe zeigte ein anderes Profil 0 als der MCP-Server). Bewusst auch NICHT das Installationsverzeichnis (%LOCALAPPDATA%\\ VersaPadViewer, wo die .exe liegt): `build_and_deploy.ps1` raeumt das Zielverzeichnis vor jedem Deploy komplett ab (`Remove-Item -Recurse`) -- laege die Config dort, wuerde JEDER Rebuild die Nutzerdaten mitloeschen (am 2026-08-15 genau so passiert, alle Notizen weg). Programm- und Datenverzeichnis bleiben deshalb getrennt: Roaming-AppData fuer die Config.""" base = (os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") or os.path.join(os.path.expanduser("~"), ".local", "share")) return os.path.join(base, APP_NAME) # CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind # keine von diesem Tool geschriebenen Dateien, sondern ein Export der # offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese- # Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche, # von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH # (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop). CONFIG_PATHS = { 0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"), 1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"), 2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"), } # index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge) GRID_COLS = 4 GRID_ROWS = 5 MODIFIER_BITS = [ (0x01, "Strg"), (0x02, "Shift"), (0x04, "Alt"), (0x08, "Win"), ] # Verbatim aus ActionDialog.cs HidKeyName() -- Sondertasten ohne Scan-Code-Eintrag _SPECIAL_KEYS = { 0x28: "Enter", 0x29: "Escape", 0x2A: "Backspace", 0x2B: "Tab", 0x2C: "Leer", 0x39: "Caps", 0x46: "Druck", 0x47: "Rollen", 0x48: "Pause", 0x49: "Einfg", 0x4A: "Pos1", 0x4B: "Bild↑", 0x4C: "Entf", 0x4D: "Ende", 0x4E: "Bild↓", 0x4F: "→", 0x50: "←", 0x51: "↓", 0x52: "↑", 0x53: "NumLock", 0x54: "Num/", 0x55: "Num*", 0x56: "Num-", 0x57: "Num+", 0x58: "NumEnter", 0x59: "Num1", 0x5A: "Num2", 0x5B: "Num3", 0x5C: "Num4", 0x5D: "Num5", 0x5E: "Num6", 0x5F: "Num7", 0x60: "Num8", 0x61: "Num9", 0x62: "Num0", 0x63: "Num.", 0x3A: "F1", 0x3B: "F2", 0x3C: "F3", 0x3D: "F4", 0x3E: "F5", 0x3F: "F6", 0x40: "F7", 0x41: "F8", 0x42: "F9", 0x43: "F10", 0x44: "F11", 0x45: "F12", } # HID Usage Page 0x07 (Keyboard), Zeichentasten -- US-Layout-Beschriftung als # lesbare Näherung (die reale GUI zeigt das aktive Windows-Layout via # GetKeyNameText, das können wir hier ohne WinAPI-Call nicht nachbilden). for _i in range(26): _SPECIAL_KEYS[0x04 + _i] = chr(ord("A") + _i) for _i in range(9): _SPECIAL_KEYS[0x1E + _i] = str(_i + 1) _SPECIAL_KEYS[0x27] = "0" _SPECIAL_KEYS.update({ 0x2D: "-", 0x2E: "=", 0x2F: "[", 0x30: "]", 0x31: "\\", 0x32: "#", 0x33: ";", 0x34: "'", 0x35: "`", 0x36: ",", 0x37: ".", 0x38: "/", 0x64: "\\ (Non-US)", 0x65: "Menu", }) # Verbatim aus ActionDialog.cs s_consumer -- HID Consumer Usage IDs (Usage Page 0x0C) _CONSUMER_NAMES = { 0x00CD: "Play / Pause", 0x00B5: "Nächster Titel", 0x00B6: "Vorheriger Titel", 0x00B7: "Stop", 0x00E9: "Lauter", 0x00EA: "Leiser", 0x00E2: "Stummschalten", 0x0192: "Taschenrechner", 0x0223: "Browser – Startseite", 0x0224: "Browser – Zurück", 0x0225: "Browser – Vor", 0x00B0: "Aufnahme", } ANIM_LABELS = { "Static": "● statisch", "Blink": "◎ blinkend", "Pulse": "≈ pulsierend", "FadeIn": "↗ fade-in", "FadeOut": "↘ fade-out", "ColorCycle": "⟳ regenbogen", "ColorFade": "⟳ farbwechsel", } def hid_key_choices(): """Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren.""" return sorted(_SPECIAL_KEYS.items()) def consumer_choices(): """Sortierte [(usage_id, name), ...] fuer Dropdown-Auswahl beim Editieren.""" return sorted(_CONSUMER_NAMES.items()) _KEY_CODE_BY_NAME = {name: code for code, name in _SPECIAL_KEYS.items()} _CONSUMER_ID_BY_NAME = {name: cid for cid, name in _CONSUMER_NAMES.items()} def hid_key_code_for_name(name): """z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen.""" if name not in _KEY_CODE_BY_NAME: raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(_KEY_CODE_BY_NAME)}") return _KEY_CODE_BY_NAME[name] def consumer_id_for_name(name): """z.B. 'Play / Pause' -> 0xCD. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen.""" if name not in _CONSUMER_ID_BY_NAME: raise ValueError(f"Unbekannte Medienaktion {name!r}. Gueltige Namen: {sorted(_CONSUMER_ID_BY_NAME)}") return _CONSUMER_ID_BY_NAME[name] def modifier_bits_for_names(names): """['Strg','Shift'] -> 0x03. Wirft ValueError bei unbekanntem Namen.""" valid = {name: bit for bit, name in MODIFIER_BITS} bits = 0 for name in names: if name not in valid: raise ValueError(f"Unbekannter Modifier {name!r}. Gueltig: {sorted(valid)}") bits |= valid[name] return bits def macro_step_label(step): """step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante von hid_key_label() (dort steckt beides in einem 16-Bit data-Feld, hier getrennt).""" mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit] key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}") return "+".join(mods + [key]) if mods else key def macro_slot_label(steps): if not steps: return "(leer)" return " → ".join(macro_step_label(s) for s in steps) def hid_key_label(data): """data = keycode | (modifier << 8) -> z.B. 'Strg+S'""" keycode = data & 0xFF modifier = (data >> 8) & 0xFF mods = [name for bit, name in MODIFIER_BITS if modifier & bit] key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}") return "+".join(mods + [key]) if mods else key def consumer_label(data): return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}") def action_label(action): """Menschenlesbarer Text für eine DeviceAction {type, data}.""" t = action.get("type") d = action.get("data", 0) if t == "None": return "" if t == "HidKey": return hid_key_label(d) if t == "HidConsumer": return consumer_label(d) if t == "Macro": return f"Makro (Slot {d})" if t == "ProfileSwitch": if d in (0xFFFF, 0x00FF): return "Profilwechsel → nächstes" return f"Profilwechsel → Profil {d}" return f"{t} ({d})" def led_css(led): return f"rgb({led['r']},{led['g']},{led['b']})" def led_css_hex(led): """Tk-Farben brauchen #RRGGBB statt rgb(...).""" return f"#{led['r']:02x}{led['g']:02x}{led['b']:02x}" def button_grid_position(index): """index -> (col, row), Reihe 0 = oben""" col, row = divmod(index, GRID_ROWS) return col, row def annotate_profile(cfg): """Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck) oder aus dem kombinierten Programmiermodus-State kommt.""" for b in cfg["buttons"]: b["label"] = action_label(b["action"]) b["note"] = b["action"].get("note", "") b["col"], b["row"] = button_grid_position(b["index"]) for e in cfg["encoders"]: e["sw_label"] = action_label(e["sw"]) e["sw_note"] = e["sw"].get("note", "") e["cw_label"] = action_label(e["cw"]) e["cw_note"] = e["cw"].get("note", "") e["ccw_label"] = action_label(e["ccw"]) e["ccw_note"] = e["ccw"].get("note", "") return cfg def load_profile(profile): path = CONFIG_PATHS[profile] with open(path, "r", encoding="utf-8") as f: cfg = json.load(f) return annotate_profile(cfg) def load_all_profiles(): return {p: load_profile(p) for p in CONFIG_PATHS} def config_mtimes(): """Für Auto-Reload: liefert {profile: mtime} der 3 JSON-Dateien.""" return {p: os.path.getmtime(path) for p, path in CONFIG_PATHS.items() if os.path.exists(path)}