diff --git a/AGENTS.md b/AGENTS.md index b508aaf..525e540 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,26 @@ Nutzerorientierte Einführung: [`README.md`](README.md). Funktionen bleiben direkt aufrufbar (kein `.fn`-Unterschied wie bei älteren FastMCP-Versionen). Tool-Liste: siehe README oder `MCP_INFO_TEXT` in `desktop_viewer.py`. +- **Board-Serial-Tools schliessen den Link nach jedem Aufruf** (`get_board_status`, + `load_from_board`, `write_to_board` — `finally: _link.close()`). Grund: + `VersaPadLink` schliesst nie von selbst, ein einzelner Aufruf hätte sonst + den exklusiven COM-Port dauerhaft für den Rest des MCP-Serverprozesses + blockiert und Live-Sync/VersaGUI/den nächsten Aufruf mit "busy" ausgesperrt + (am 2026-08-14 live so aufgetreten, siehe unten). +- **Bug beobachtet 2026-08-14:** In diesem Agenten-Environment (Claude-Code- + VSCode-Extension) können mehrere unabhängige `versapad_mcp_server.py`- + Prozesse gleichzeitig laufen (bis zu 8 beobachtet, vermutlich durch + wiederholte Tool-Ladevorgänge/Reconnects innerhalb einer Session) — jeder + mit eigenem, nicht geteiltem In-Memory-State (`_state["combined"]`). + Konkret beobachtet: `set_button_*`/`set_macro` + `save_local()` liefen + korrekt auf einem Prozess, ein späterer `write_to_board()`-Aufruf landete + aber auf einem anderen (frischen, leeren) Prozess und schrieb versehentlich + eine leere Default-Config aufs Board, trotz `{"ok": true}`-Antwort. Fix: + vor `write_to_board()` immer erst `load_local()` (liest die Datei frisch + von der Platte, unabhängig davon welcher Prozess antwortet), und nach + jedem Schreibvorgang mit `load_from_board()` + `get_profile()` gegenlesen + statt dem ACK allein zu vertrauen — genau dieses Verify-Pattern hat den + Fehler hier live aufgedeckt. Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code nachschlagen — die Dateien sind klein genug, dass eine separate diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 3149cdb..52e644f 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -116,9 +116,15 @@ def get_macro(slot: int) -> dict: 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} + Tkinter-Viewer den COM-Port gerade halten. Schliesst die Verbindung + danach wieder (siehe write_to_board() fuer den Grund) -- der Port + ist exklusiv, ein einzelner Status-Check darf ihn nicht dauerhaft + fuer Live-Sync/VersaGUI blockieren.""" + 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) ─────────────────────────────────────── @@ -299,22 +305,26 @@ 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}") + von VersaGUI/dem Tkinter-Viewer gehalten wird. Schliesst die Verbindung + danach wieder (siehe write_to_board() fuer den Grund).""" + 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) + 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() + names = _cfg()["profile_names"] + _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) + return list_profiles() + finally: + _link.close() @mcp.tool() @@ -322,13 +332,21 @@ 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} + NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port. + Schliesst die Verbindung danach wieder -- VersaPadLink haelt den Port + sonst dauerhaft offen (kein automatisches Schliessen nach einem Befehl), + was Live-Sync/VersaGUI/den naechsten MCP-Aufruf sonst dauerhaft mit + "busy" blockieren wuerde, obwohl der eigentliche Vorgang laengst fertig + ist -- der Port ist exklusiv (siehe versapad_serial.py).""" + 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__":