VersaGUI-py/versapad_mcp_server.py
Julian Appel aba81463e5 Show macro key sequences in the browser view and MCP replies
Beide Ansichten reichen jetzt die Makrotabelle an action_label() durch, so
dass eine Makro-Belegung ihre echte Tastenfolge zeigt statt nur der
Slot-Nummer -- dieselbe Darstellung wie im Desktop-Fenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:33:15 +02:00

388 lines
16 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):
"""label zeigt bei Makros die echte Tastenfolge statt nur der
Slot-Nummer (gleiche Darstellung wie im Hauptfenster) -- die Makrotabelle
liegt im selben State, also kein Grund, hier weniger zu verraten."""
return {"type": action["type"], "data": action["data"],
"label": vp.action_label(action, _cfg().get("macros")),
"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")