Wraps the existing protocol/serial/combined-config layers as MCP tools (set_button_key, set_encoder_consumer, set_macro, load_from_board, write_to_board, ...) instead of requiring hand-written JSON + manual GUI import. Registered at user scope via `claude mcp add`.
218 lines
7 KiB
Python
218 lines
7 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
|
||
|
||
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"),
|
||
}
|
||
|
||
PROFILE_NAMES = {
|
||
0: "Profil 0 – Windows",
|
||
1: "Profil 1 – Fusion 360",
|
||
2: "Profil 2 – BricsCAD",
|
||
}
|
||
|
||
# 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/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus
|
||
einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt."""
|
||
for b in cfg["buttons"]:
|
||
b["label"] = action_label(b["action"])
|
||
b["col"], b["row"] = button_grid_position(b["index"])
|
||
for e in cfg["encoders"]:
|
||
e["sw_label"] = action_label(e["sw"])
|
||
e["cw_label"] = action_label(e["cw"])
|
||
e["ccw_label"] = action_label(e["ccw"])
|
||
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)}
|