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.
384 lines
16 KiB
Python
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")
|