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`.
This commit is contained in:
parent
50658107d5
commit
e456af7c19
3 changed files with 378 additions and 0 deletions
335
versapad_mcp_server.py
Normal file
335
versapad_mcp_server.py
Normal file
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue