VersaGUI-py/versapad_mcp_server.py
cjjohn e456af7c19 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`.
2026-08-05 08:16:22 +02:00

335 lines
14 KiB
Python

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