VersaGUI-py/versapad_data.py
Julian Appel 189ec01e68 Merge branch 'main' into dev/jappel
Reconciles two independent lines of work: main cherry-picked and then
extended dev/jappel's build-script/config-portability/rename-tab/
COM-port fixes, additionally fixing a config-loss bug (rebuild wiped
the config when it lived in the install dir -- moved to roaming
%APPDATA% instead) and adding free-text notes per action plus a
frameless resizable window. dev/jappel keeps its .mcp.json registration
and docs/ reference tree, which main deliberately left out.

Conflict resolutions favored main's versions where the two sides solved
the same problem (config location, build deploy target, COM-port
release, tab rename) since main's fixes were validated against a real
rebuild-wipes-config incident. docs/architecture.md and
docs/data-model.md updated to describe the resulting %APPDATA% config
path and the note field.
2026-08-15 18:16:00 +02:00

253 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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)}