From e456af7c19edbbf46cac1e8222abdaef3a2119fc Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:16:22 +0200 Subject: [PATCH] Add MCP server so Claude (or any MCP client) can reprogram VersaPad directly 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`. --- AGENTS.md | 14 ++ versapad_data.py | 29 ++++ versapad_mcp_server.py | 335 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 versapad_mcp_server.py diff --git a/AGENTS.md b/AGENTS.md index fed699d..2faadb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,20 @@ faktisch ein schlankes Python-Pendant zu VersaGUI. Der Browser-Server Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend, kein Programmiermodus (bewusst einfach gehalten). +**MCP-Server (Config per KI-Tool-Aufruf statt Hand-JSON):** +- `versapad_mcp_server.py` — registriert als User-Scope-MCP-Server "versapad" + (`claude mcp add -s user versapad -- versapad_mcp_server.py`, + landet in `~/.claude.json`). Tools: `list_profiles`/`get_profile`/ + `get_macro`/`get_board_status` (lesen), `set_button_key`/`_consumer`/ + `_macro`/`_profile_switch`/`_none`/`_led`, dieselben `set_encoder_*`, + `set_macro`, `rename_profile` (mutieren nur In-Memory-State), `save_local`/ + `load_local` (↔ `versapad_config_all.json`), `load_from_board`/ + `write_to_board` (↔ echtes Board per Serial). Nutzt das MCP-SDK v2 (Paket + `mcp`, Klasse `mcp.server.mcpserver.MCPServer` — **nicht** `FastMCP` aus + `mcp.server.fastmcp`, das gibt's in dieser SDK-Version nicht mehr, wurde + umbenannt). `@mcp.tool()`-dekorierte Funktionen bleiben direkt aufrufbar + (kein `.fn`-Unterschied wie bei alten FastMCP-Versionen). + **Binaer-/Serial-Schicht (fuer den Programmiermodus):** - `versapad_protocol.py` — pack/unpack fuer `SDeviceConfig`(740B, alle 3 Profile) und `SMacroTable`(512B, 32 Slots) + CRC16 (Poly 0x1021, Init diff --git a/versapad_data.py b/versapad_data.py index c010ac3..f4abcc0 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -99,6 +99,35 @@ def consumer_choices(): 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).""" diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py new file mode 100644 index 0000000..3149cdb --- /dev/null +++ b/versapad_mcp_server.py @@ -0,0 +1,335 @@ +""" +MCP-Server fuer VersaPad -- macht die Config direkt per Tool-Aufruf +programmierbar (fuer Claude oder jede andere MCP-faehige KI), ohne JSON +von Hand zu schreiben oder die GUI zu bedienen. + +Arbeitet auf einem In-Memory-State (kombinierte Config, alle 3 Profile + +Makros + lokale Profilnamen -- siehe versapad_combined.py), der explizit +lokal gespeichert/geladen oder mit dem Board synchronisiert wird: + + load_local() / save_local() <-> ~/OneDrive/Desktop/versapad_config_all.json + load_from_board() / write_to_board() <-> echtes Board per Serial + +Bewusst kein Auto-Write bei jedem set_*-Aufruf: mehrere Aenderungen sollen +sich zu einem Transfer buendeln lassen, und ein Board ist nicht immer +angeschlossen. Der COM-Port ist exklusiv -- write_to_board()/load_from_board() +schlagen fehl (mit klarer Fehlermeldung), wenn VersaGUI oder der +Tkinter-Viewer (Live-Sync/Programmiermodus) den Port gerade haelt. + +Start (stdio-Transport, fuer .mcp.json): + py versapad_mcp_server.py +""" +from mcp.server.mcpserver import MCPServer + +import versapad_combined as vcomb +import versapad_data as vp +import versapad_protocol as vproto +import versapad_serial as vs + +mcp = MCPServer("versapad") + +_state = {"combined": None} +_link = vs.VersaPadLink() + + +def _cfg(): + if _state["combined"] is None: + _state["combined"] = vcomb.default_combined() + return _state["combined"] + + +def _profile(index: int): + cfg = _cfg() + if not (0 <= index <= 2): + raise ValueError("profile muss 0, 1 oder 2 sein") + return cfg["profiles"][index] + + +def _find(items, index): + for item in items: + if item["index"] == index: + return item + raise ValueError(f"Index {index} nicht gefunden (gueltig: 0-{len(items) - 1})") + + +def _profile_switch_data(target): + if target == "next": + return 0xFFFF + if target in (0, 1, 2): + return target + raise ValueError("target muss 'next' oder 0/1/2 sein") + + +def _describe_action(action): + return {"type": action["type"], "data": action["data"], "label": vp.action_label(action)} + + +# ── Lesen ──────────────────────────────────────────────────────────────────── + +@mcp.tool() +def list_profiles() -> dict: + """Listet alle 3 Profile mit lokalem Namen und welches als aktiv markiert ist + (im aktuellen In-Memory-State -- ruf load_from_board() vorher auf fuer den + echten Board-Stand).""" + cfg = _cfg() + return { + "active_profile": cfg["active_profile"], + "profiles": [{"index": i, "name": cfg["profile_names"][i]} for i in range(3)], + } + + +@mcp.tool() +def get_profile(profile: int) -> dict: + """Liefert alle 20 Button- und 4 Encoder-Belegungen eines Profils, lesbar + beschriftet (z.B. 'Strg+S'). profile: 0, 1 oder 2.""" + p = _profile(profile) + return { + "buttons": [ + {"index": b["index"], "action": _describe_action(b["action"]), "led": b["led"]} + for b in p["buttons"] + ], + "encoders": [ + { + "index": e["index"], + "sw": _describe_action(e["sw"]), + "cw": _describe_action(e["cw"]), + "ccw": _describe_action(e["ccw"]), + } + for e in p["encoders"] + ], + } + + +@mcp.tool() +def get_macro(slot: int) -> dict: + """Liest die Tastenfolge eines Makro-Slots (0-31). Slot-Konvention: + MX-Buttons = eigener Button-Index (0-19), Encoder = 20 + enc*3 + act_idx + (act_idx 0=SW/1=CW/2=CCW).""" + macros = _cfg()["macros"] + if not (0 <= slot < len(macros)): + raise ValueError(f"slot muss 0-{len(macros) - 1} sein") + steps = macros[slot] + return {"slot": slot, "steps": steps, "label": vp.macro_slot_label(steps)} + + +@mcp.tool() +def get_board_status() -> dict: + """Prueft per Serial, ob das Board erreichbar ist und welches Profil dort + gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der + Tkinter-Viewer den COM-Port gerade halten.""" + profile = _link.read_active_profile() + return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} + + +# ── Buttons (20 pro Profil, MX-Matrix) ─────────────────────────────────────── + +@mcp.tool() +def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = []) -> dict: + """Belegt einen MX-Button (index 0-19) mit einer HID-Taste + optionalen + Modifiern (Strg/Shift/Alt/Win). key z.B. 'S', 'F5', 'Enter', 'Pfeil-Namen + siehe get_profile-Ausgabe fuer Beispiele. Aendert nur den In-Memory-State, + kein automatisches Schreiben aufs Board -- danach write_to_board() rufen.""" + btn = _find(_profile(profile)["buttons"], index) + keycode = vp.hid_key_code_for_name(key) + mod_bits = vp.modifier_bits_for_names(modifiers) + btn["action"] = {"type": "HidKey", "data": (mod_bits << 8) | keycode} + return _describe_action(btn["action"]) + + +@mcp.tool() +def set_button_consumer(profile: int, index: int, consumer: str) -> dict: + """Belegt einen MX-Button mit einer Medientaste, z.B. 'Play / Pause', + 'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'.""" + btn = _find(_profile(profile)["buttons"], index) + cid = vp.consumer_id_for_name(consumer) + btn["action"] = {"type": "HidConsumer", "data": cid} + return _describe_action(btn["action"]) + + +@mcp.tool() +def set_button_macro(profile: int, index: int, slot: int) -> dict: + """Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst + mit set_macro() befuellen.""" + btn = _find(_profile(profile)["buttons"], index) + btn["action"] = {"type": "Macro", "data": slot} + return _describe_action(btn["action"]) + + +@mcp.tool() +def set_button_profile_switch(profile: int, index: int, target) -> dict: + """Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2.""" + btn = _find(_profile(profile)["buttons"], index) + btn["action"] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)} + return _describe_action(btn["action"]) + + +@mcp.tool() +def set_button_none(profile: int, index: int) -> dict: + """Entfernt die Belegung eines MX-Buttons (Action = None).""" + btn = _find(_profile(profile)["buttons"], index) + btn["action"] = {"type": "None", "data": 0} + return _describe_action(btn["action"]) + + +@mcp.tool() +def set_button_led(profile: int, index: int, r: int, g: int, b: int, + anim: str = "Static", period_ms: int = 4000) -> dict: + """Setzt Farbe/Animation eines MX-Button-LEDs. anim: Static/Blink/Pulse/ + FadeIn/FadeOut/ColorCycle/ColorFade (Pulse braucht period_ms >= 2).""" + if anim not in vproto.ANIM_TYPES: + raise ValueError(f"anim muss einer von {vproto.ANIM_TYPES} sein") + btn = _find(_profile(profile)["buttons"], index) + btn["led"] = {"r": r, "g": g, "b": b, "brightness": btn["led"].get("brightness", 255), + "anim": anim, "period_ms": period_ms} + return btn["led"] + + +# ── Encoder (4 pro Profil, je sw/cw/ccw) ───────────────────────────────────── + +def _encoder_field(profile: int, index: int, field: str): + if field not in ("sw", "cw", "ccw"): + raise ValueError("field muss 'sw', 'cw' oder 'ccw' sein") + return _find(_profile(profile)["encoders"], index), field + + +@mcp.tool() +def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: list[str] = []) -> dict: + """Belegt eine Encoder-Aktion (index 0-3, field 'sw'/'cw'/'ccw') mit einer + HID-Taste + optionalen Modifiern.""" + enc, f = _encoder_field(profile, index, field) + keycode = vp.hid_key_code_for_name(key) + mod_bits = vp.modifier_bits_for_names(modifiers) + enc[f] = {"type": "HidKey", "data": (mod_bits << 8) | keycode} + return _describe_action(enc[f]) + + +@mcp.tool() +def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict: + """Belegt eine Encoder-Aktion mit einer Medientaste.""" + enc, f = _encoder_field(profile, index, field) + enc[f] = {"type": "HidConsumer", "data": vp.consumer_id_for_name(consumer)} + return _describe_action(enc[f]) + + +@mcp.tool() +def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict: + """Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31).""" + enc, f = _encoder_field(profile, index, field) + enc[f] = {"type": "Macro", "data": slot} + return _describe_action(enc[f]) + + +@mcp.tool() +def set_encoder_profile_switch(profile: int, index: int, field: str, target) -> dict: + """Belegt eine Encoder-Aktion mit Profilwechsel. target: 'next' oder 0/1/2. + Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der + Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern.""" + enc, f = _encoder_field(profile, index, field) + enc[f] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)} + return _describe_action(enc[f]) + + +@mcp.tool() +def set_encoder_none(profile: int, index: int, field: str) -> dict: + """Entfernt eine Encoder-Belegung (Action = None).""" + enc, f = _encoder_field(profile, index, field) + enc[f] = {"type": "None", "data": 0} + return _describe_action(enc[f]) + + +# ── Makros ─────────────────────────────────────────────────────────────────── + +@mcp.tool() +def set_macro(slot: int, steps: list[dict]) -> dict: + """Setzt die Tastenfolge eines Makro-Slots (0-31, max. 8 Schritte). + steps: [{"key": "Z", "modifiers": ["Strg"]}, ...] -- Ausfuehrung stoppt + beim ersten leeren/fehlenden Schritt, also keine Luecken lassen. Nur + Strg/Shift/Alt als Modifier (kein Win, passend zur Firmware-Konvention + fuer Makro-Steps).""" + macros = _cfg()["macros"] + if not (0 <= slot < len(macros)): + raise ValueError(f"slot muss 0-{len(macros) - 1} sein") + if len(steps) > 8: + raise ValueError("maximal 8 Schritte pro Makro") + packed = [] + for step in steps: + keycode = vp.hid_key_code_for_name(step["key"]) + mods = [m for m in step.get("modifiers", []) if m != "Win"] + if "Win" in step.get("modifiers", []): + raise ValueError("Win ist bei Makro-Schritten nicht erlaubt (nur Strg/Shift/Alt)") + mod_bits = vp.modifier_bits_for_names(mods) + packed.append({"keycode": keycode, "modifier": mod_bits}) + macros[slot] = packed + return {"slot": slot, "steps": packed, "label": vp.macro_slot_label(packed)} + + +# ── Profilnamen (nur lokal, siehe versapad_combined.py) ────────────────────── + +@mcp.tool() +def rename_profile(profile: int, name: str) -> dict: + """Benennt ein Profil lokal um (0, 1 oder 2). Landet NICHT auf dem Board -- + die Firmware hat dafuer keinen Speicherplatz, rein kosmetisch fuer uns.""" + if not (0 <= profile <= 2): + raise ValueError("profile muss 0, 1 oder 2 sein") + _cfg()["profile_names"][profile] = name + return {"profile": profile, "name": name} + + +# ── Persistenz: lokale Datei ↔ Board ───────────────────────────────────────── + +@mcp.tool() +def save_local(path: str = None) -> dict: + """Speichert den aktuellen In-Memory-State als kombinierte JSON (Default: + ~/OneDrive/Desktop/versapad_config_all.json).""" + saved = vcomb.save_file(_cfg(), path or vcomb.DEFAULT_PATH) + return {"path": saved} + + +@mcp.tool() +def load_local(path: str = None) -> dict: + """Laedt den In-Memory-State aus der kombinierten JSON (Default: + ~/OneDrive/Desktop/versapad_config_all.json). Ueberschreibt unsichere + Aenderungen seit dem letzten save_local()/load_from_board().""" + _state["combined"] = vcomb.load_file(path or vcomb.DEFAULT_PATH) + return list_profiles() + + +@mcp.tool() +def load_from_board() -> dict: + """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und + ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die + kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade + von VersaGUI/dem Tkinter-Viewer gehalten wird.""" + raw_cfg = _link.read_full_config() + if raw_cfg is None: + raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") + raw_macros = _link.read_macros() + if raw_macros is None: + raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}") + + cfg_dict = vproto.unpack_config(raw_cfg) + if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): + raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") + macro_slots = vproto.unpack_macros(raw_macros) + + names = _cfg()["profile_names"] + _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) + return list_profiles() + + +@mcp.tool() +def write_to_board() -> dict: + """Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs + Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft + Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit + NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.""" + cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) + if not _link.write_full_config(cfg_bytes): + raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") + if not _link.write_macros(macro_bytes): + raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") + return {"ok": True} + + +if __name__ == "__main__": + mcp.run(transport="stdio")