""" 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), "note": action.get("note", "")} def _replace_action(old_action, new_type, new_data): """Baut eine neue Action mit neuem Typ/Daten, behaelt aber die Notiz vom vorherigen Stand bei (rein lokal, unabhaengig davon was die Aktion tut -- ein Tastenwechsel soll die Beschreibung 'was der Button macht' nicht loeschen). Zum Loeschen explizit set_button_note()/set_encoder_note() mit leerem String.""" return {"type": new_type, "data": new_data, "note": old_action.get("note", "")} # ── 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. Gibt den COM-Port danach sofort wieder frei (kein dauerhaft offen gehaltener Serial-Handle -- sonst blockiert dieser Prozess andere Tools/Viewer mit "busy", bis er beendet wird).""" try: profile = _link.read_active_profile() return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} finally: _link.close() # ── 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"] = _replace_action(btn["action"], "HidKey", (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"] = _replace_action(btn["action"], "HidConsumer", 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"] = _replace_action(btn["action"], "Macro", 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"] = _replace_action(btn["action"], "ProfileSwitch", _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). Notiz bleibt erhalten -- zum Loeschen set_button_note(profile, index, "").""" btn = _find(_profile(profile)["buttons"], index) btn["action"] = _replace_action(btn["action"], "None", 0) return _describe_action(btn["action"]) @mcp.tool() def set_button_note(profile: int, index: int, note: str) -> dict: """Setzt/aendert die freie Notiz eines MX-Buttons -- was der Button tut, unabhaengig von der technischen Aktion (z.B. 'Speichern in Fusion 360'). Rein lokal, landet nie aufs Board (wie Profilnamen). Leerer String loescht die Notiz.""" btn = _find(_profile(profile)["buttons"], index) btn["action"]["note"] = note 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] = _replace_action(enc[f], "HidKey", (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] = _replace_action(enc[f], "HidConsumer", 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] = _replace_action(enc[f], "Macro", 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] = _replace_action(enc[f], "ProfileSwitch", _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). Notiz bleibt erhalten -- zum Loeschen set_encoder_note(profile, index, field, "").""" enc, f = _encoder_field(profile, index, field) enc[f] = _replace_action(enc[f], "None", 0) return _describe_action(enc[f]) @mcp.tool() def set_encoder_note(profile: int, index: int, field: str, note: str) -> dict: """Setzt/aendert die freie Notiz einer Encoder-Aktion (sw/cw/ccw) -- was sie tut, unabhaengig von der technischen Aktion. Rein lokal, landet nie aufs Board. Leerer String loescht die Notiz.""" enc, f = _encoder_field(profile, index, field) enc[f]["note"] = note 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 UND Notizen bleiben erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den COM-Port danach sofort wieder frei (siehe get_board_status()).""" try: 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"] previous = _state["combined"] _state["combined"] = vcomb.merge_notes( vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous) return list_profiles() finally: _link.close() @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. Gibt den COM-Port danach sofort wieder frei (siehe get_board_status()).""" try: 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} finally: _link.close() if __name__ == "__main__": mcp.run(transport="stdio")