Lets you record what a binding actually does (e.g. "Save in Fusion
360") alongside the auto-generated label ("Strg+S"). Notes live in a
new "note" field on every action dict, purely local like profile
names -- the firmware struct has no room for strings, and
pack_config/unpack_config already only touch type/data so the extra
key round-trips harmlessly.
Two things had to be handled carefully: changing a button's key/type
must not wipe its note (all set_button_*/set_encoder_* setters and the
edit dialog now carry the previous note forward), and re-reading from
the board must not erase notes either, since the firmware doesn't know
about them -- versapad_combined.merge_notes() restores them onto the
freshly-fetched state by button/encoder index.
Editable via the Programmiermodus dialog (new text field), visible on
both the desktop card (grown from 84 to 114px to fit it) and the
browser view. MCP server gets set_button_note()/set_encoder_note() so
notes can be set programmatically too.
239 lines
8.2 KiB
Python
239 lines
8.2 KiB
Python
"""
|
||
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
|
||
import sys
|
||
|
||
NUM_PROFILES = 3
|
||
|
||
|
||
def app_dir():
|
||
"""Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json,
|
||
siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir)
|
||
das Installationsverzeichnis neben der .exe, sonst der Ordner dieses
|
||
Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter
|
||
Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/
|
||
installieren, ohne Pfade von Hand anzupassen."""
|
||
if getattr(sys, "frozen", False):
|
||
return os.path.dirname(sys.executable)
|
||
return os.path.dirname(os.path.abspath(__file__))
|
||
|
||
|
||
# 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)}
|