VersaGUI-py/versapad_mcp_server.py
cjjohn 4b5da69174 Add free-text notes per button/encoder action
Lets you record what a binding actually does (e.g. "Save in Fusion
360") alongside the auto-generated label ("Strg+S"). Notes live in a
new "note" field on every action dict, purely local like profile
names -- the firmware struct has no room for strings, and
pack_config/unpack_config already only touch type/data so the extra
key round-trips harmlessly.

Two things had to be handled carefully: changing a button's key/type
must not wipe its note (all set_button_*/set_encoder_* setters and the
edit dialog now carry the previous note forward), and re-reading from
the board must not erase notes either, since the firmware doesn't know
about them -- versapad_combined.merge_notes() restores them onto the
freshly-fetched state by button/encoder index.

Editable via the Programmiermodus dialog (new text field), visible on
both the desktop card (grown from 84 to 114px to fit it) and the
browser view. MCP server gets set_button_note()/set_encoder_note() so
notes can be set programmatically too.
2026-08-15 11:15:36 +02:00

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