""" VersaPad Viewer -- Desktop-Fenster (Tkinter). Zeigt die Steuermatrix (4x5 Grid + 4 Encoder) eines Profils als natives Fenster, flaches Design (Label-Flaechen statt Canvas-Formen), pollt die 3 Config-JSONs auf Aenderung und baut das Grid dann neu auf. Optional: Live-Sync mit dem physischen Board -- fragt per Serial das aktuell aktive Profil ab (CONFIG_READ) und schaltet die Ansicht automatisch mit, wenn am Board der Profil-Encoder gedrueckt wird. Optional: Programmiermodus -- Zellen anklicken zum Bearbeiten (Action-Typ, HID-Taste, LED, Makro-Schritte), "Vom Board laden"/"Zum Board uebertragen" schreibt die komplette Config+Makros per Serial, kombiniertes Ein-Datei- Format (alle 3 Profile + lokale Profilnamen) fuer Import/Export. Beides braucht pyserial und einen freien COM-Port (nicht gleichzeitig mit VersaGUI moeglich -- Live-Sync wird beim Aktivieren des Programmiermodus automatisch ausgeschaltet, damit sich beide nicht um den Port streiten). Start: python desktop_viewer.py """ import os import queue import sys import threading import time import tkinter as tk from tkinter import filedialog, messagebox, simpledialog import pystray from PIL import Image import action_dialog import versapad_combined as vcomb import versapad_data as vp import versapad_protocol as vproto import versapad_serial as vs def resource_path(name): """Findet Assets (z.B. icon.png) sowohl im Quellordner als auch im PyInstaller-Bundle (sys._MEIPASS zur Laufzeit gesetzt).""" base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, name) BG = "#14161b" CARD_BG = "#1e2129" CARD_BORDER = "#2a2d37" TEXT = "#e8e8ec" TEXT_DIM = "#8a8d98" TEXT_EMPTY = "#4a4d58" ACCENT = "#3a6ff0" OK_GREEN = "#3ecf6e" WARN_RED = "#e0895a" POLL_MS = 1500 CARD_W, CARD_H = 150, 84 SERIAL_POLL_S = 1.5 SERIAL_IDLE_S = 3.0 SERIAL_STATUS_TEXT = { None: "verbunden", "no_pyserial": "pyserial fehlt (pip install pyserial)", "not_found": "Board nicht gefunden", "busy": "Port belegt (VersaGUI offen?)", "timeout": "keine Antwort vom Board", "nack": "Board hat abgelehnt (NACK)", "too_large": "Datenblock zu groß", } MCP_INFO_TEXT = """Dieses Programm bringt einen MCP-Server mit (versapad_mcp_server.py, im selben Ordner wie diese App). MCP (Model Context Protocol) ist ein offener Standard, über den eine KI-Anwendung Werkzeuge eines Programms direkt aufrufen kann -- egal ob Claude, ein anderer Assistent, oder eine eigene Automatisierung. Damit laesst sich die VersaPad-Belegung per KI-Anfrage aendern, ohne JSON von Hand zu schreiben oder in dieser GUI zu klicken. Einrichtung: in der MCP-Server-Liste der jeweiligen KI-Anwendung eintragen -- als Kommando python (bzw. python3/py) mit dem Pfad zu versapad_mcp_server.py als Argument. Wie genau das eingetragen wird, hängt von der verwendeten KI-Anwendung ab (eigene MCP-Server-Konfiguration, z.B. eine Einstellungsdatei oder ein Befehl in der jeweiligen App). Verfuegbare Werkzeuge: Lesen: list_profiles() Profile + lokale Namen get_profile(profile) alle 20 Buttons + 4 Encoder, lesbar beschriftet get_macro(slot) Tastenfolge eines Makro-Slots (0-31) get_board_status() ist das Board erreichbar, welches Profil aktiv Buttons setzen (profile 0-2, index 0-19): set_button_key(profile, index, key, modifiers) set_button_consumer(profile, index, consumer) set_button_macro(profile, index, slot) set_button_profile_switch(profile, index, target) set_button_none(profile, index) set_button_led(profile, index, r, g, b, anim, period_ms) Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'): set_encoder_key / _consumer / _macro / _profile_switch / _none(...) Makro: set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...] Profilname (nur lokal, nicht aufs Board): rename_profile(profile, name) Speichern/Laden: save_local() / load_local() <-> versapad_config_all.json load_from_board() / write_to_board() <-> echtes Board per Serial Wichtig: set_*-Aufrufe aendern nur den In-Memory-State. Erst save_local() oder write_to_board() macht die Aenderung dauerhaft. Board-Zugriff braucht den COM-Port exklusiv (nicht gleichzeitig mit VersaGUI oder Live-Sync/Programmiermodus hier in der GUI).""" class VersaPadViewer(tk.Tk): def __init__(self): super().__init__() self.title("VersaPad Steuermatrix") self.configure(bg=BG) self.geometry("760x760") self.profile = 0 self._mtimes = {} self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt self._link = vs.VersaPadLink() self._closing = False self.live_sync = tk.BooleanVar(value=False) self.editing = tk.BooleanVar(value=False) self._serial_results = queue.Queue() self._serial_thread = threading.Thread(target=self._serial_loop, daemon=True) try: self.iconbitmap(resource_path("icon.ico")) except tk.TclError: pass # z.B. kein .ico verfuegbar -- kein Beinbruch, nur Fenster-Icon fehlt dann self._tray_queue = queue.Queue() self._tray_icon = pystray.Icon( "versapad", Image.open(resource_path("icon.png")), "VersaPad Steuermatrix", menu=pystray.Menu( pystray.MenuItem("Öffnen", lambda: self._tray_queue.put("show"), default=True), pystray.MenuItem("Beenden", lambda: self._tray_queue.put("quit")), ), ) self._tray_icon.run_detached() header = tk.Frame(self, bg=BG) header.pack(fill="x", padx=20, pady=(18, 4)) tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT, font=("Segoe UI", 15, "bold")).pack(anchor="w") self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) self.header_sub.pack(anchor="w") info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14), cursor="hand2") info_btn.place(relx=1.0, x=0, y=-4, anchor="ne") info_btn.bind("", lambda e: self._show_mcp_info()) info_btn.bind("", lambda e: info_btn.configure(fg=ACCENT)) info_btn.bind("", lambda e: info_btn.configure(fg=TEXT_DIM)) self.tabs = tk.Frame(self, bg=BG) self.tabs.pack(fill="x", padx=20, pady=(12, 10)) self.tab_buttons = {} for p in sorted(vp.PROFILE_NAMES): btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT, font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2") btn.pack(side="left", padx=(0, 8)) btn.bind("", lambda e, prof=p: self.set_profile(prof, manual=True)) btn.bind("", lambda e, prof=p: self._rename_tab(prof)) self.tab_buttons[p] = btn toggles_row = tk.Frame(self, bg=BG) toggles_row.pack(fill="x", padx=20, pady=(0, 6)) self.sync_check = tk.Checkbutton( toggles_row, text="Live-Sync mit Board", variable=self.live_sync, command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG, activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) self.sync_check.pack(side="left") self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) self.sync_status.pack(side="left", padx=(8, 20)) self.edit_check = tk.Checkbutton( toggles_row, text="Programmiermodus", variable=self.editing, command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG, activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold")) self.edit_check.pack(side="left") self.prog_row = tk.Frame(self, bg=BG) for text, cmd in ( ("Vom Board laden", self._load_from_board), ("Zum Board übertragen", self._write_to_board), ("Datei laden...", self._load_file_dialog), ("Datei speichern...", self._save_file_dialog), ): tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT, activebackground=ACCENT, activeforeground="#fff", relief="flat", padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) self.prog_status.pack(side="left", padx=(8, 0)) # prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing) self.grid_frame = tk.Frame(self, bg=BG) self.grid_frame.pack(padx=20, pady=(8, 0)) tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8)) self.enc_frame = tk.Frame(self, bg=BG) self.enc_frame.pack(fill="x", padx=20) self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) self.footer.pack(anchor="w", padx=20, pady=(20, 10)) self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) self.bind("", self._on_unmap) self._serial_thread.start() self.set_profile(0) self.after(POLL_MS, self._poll) self.after(200, self._drain_serial_queue) self.after(200, self._drain_tray_queue) # ── Profil-Auswahl ───────────────────────────────────────────────────── def set_profile(self, profile, manual=False): self.profile = profile for p, btn in self.tab_buttons.items(): btn.configure(bg=ACCENT if p == profile else CARD_BG, fg="#ffffff" if p == profile else TEXT) self._render() def _update_tab_labels(self): for p, btn in self.tab_buttons.items(): if self.editing.get() and self.combined: btn.configure(text=self.combined["profile_names"][p]) else: btn.configure(text=vp.PROFILE_NAMES[p]) def _rename_tab(self, profile): if not self.editing.get() or self.combined is None: return current = self.combined["profile_names"][profile] name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):", initialvalue=current, parent=self) if name: self.combined["profile_names"][profile] = name self._update_tab_labels() def _show_mcp_info(self): win = tk.Toplevel(self) win.title("MCP-Server: versapad") win.configure(bg=BG) win.geometry("480x520") win.transient(self) tk.Label(win, text="MCP-Server \"versapad\"", bg=BG, fg=TEXT, font=("Segoe UI", 13, "bold")).pack(anchor="w", padx=16, pady=(16, 4)) tk.Label(win, text="Lässt eine KI (z.B. Claude) die VersaPad-Config direkt per\n" "Tool-Aufruf lesen/bearbeiten -- ohne JSON von Hand zu schreiben\n" "oder Klicks in dieser GUI.", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9), justify="left").pack(anchor="w", padx=16) text = tk.Text(win, bg=CARD_BG, fg=TEXT, font=("Consolas", 9), wrap="word", relief="flat", padx=12, pady=10, bd=0, highlightthickness=0) text.pack(fill="both", expand=True, padx=16, pady=12) text.insert("1.0", MCP_INFO_TEXT) text.configure(state="disabled") tk.Button(win, text="Schließen", command=win.destroy, bg=CARD_BG, fg=TEXT, activebackground=ACCENT, activeforeground="#fff", relief="flat", padx=14, pady=4).pack(pady=(0, 16)) # ── Config-Datei-Polling (nur im Nicht-Edit-Modus relevant) ──────────── def _poll(self): mtimes = vp.config_mtimes() if mtimes != self._mtimes: self._mtimes = mtimes if not self.editing.get(): self._render() self.after(POLL_MS, self._poll) # ── Live-Sync mit dem Board ──────────────────────────────────── def _on_toggle_sync(self): if not self.live_sync.get(): self.sync_status.configure(text="aus", fg=TEXT_DIM) self._link.close() def _serial_loop(self): """Laeuft dauerhaft im Hintergrund-Thread, pollt nur wenn live_sync an ist. Fasst NIE Tk-Widgets direkt an (nicht threadsicher) -- legt Ergebnisse nur in die Queue, das Tk-Mainloop-`after` liest sie im Haupt-Thread.""" while not self._closing: try: if not self.live_sync.get(): time.sleep(0.3) continue profile = self._link.read_active_profile() self._serial_results.put((profile, self._link.last_error)) except (RuntimeError, tk.TclError): return # Fenster wird gerade geschlossen, Tk-Interpreter nicht mehr gueltig except Exception as e: # Hintergrund-Thread darf nie sterben/den Prozess mitreissen self._serial_results.put((None, f"error:{e}")) profile = None time.sleep(SERIAL_POLL_S if profile is not None else SERIAL_IDLE_S) def _drain_serial_queue(self): try: while True: profile, error = self._serial_results.get_nowait() self._on_serial_result(profile, error) except queue.Empty: pass self.after(200, self._drain_serial_queue) def _on_serial_result(self, profile, error): if not self.live_sync.get(): return if profile is not None: self.sync_status.configure(text="verbunden", fg=OK_GREEN) if profile != self.profile: self.set_profile(profile, manual=False) else: text = SERIAL_STATUS_TEXT.get(error, error or "Fehler") self.sync_status.configure(text=text, fg=WARN_RED) # ── Tray-Icon (kein Taskleisten-Eintrag beim Minimieren, wie VersaGUI) ── def _on_unmap(self, event): """Minimieren faengt Windows normalerweise als Taskleisten-Icon ab -- wir wollen stattdessen: Fenster komplett weg, nur noch Tray-Icon.""" if event.widget is self and self.state() == "iconic": self._hide_to_tray() def _hide_to_tray(self): self.withdraw() def _show_from_tray(self): self.deiconify() self.state("normal") self.lift() self.focus_force() def _drain_tray_queue(self): try: while True: action = self._tray_queue.get_nowait() if action == "show": self._show_from_tray() elif action == "quit": self._quit() except queue.Empty: pass if not self._closing: self.after(200, self._drain_tray_queue) def _quit(self): self._closing = True self._link.close() try: self._tray_icon.stop() except Exception: pass self.destroy() # ── Programmiermodus ────────────────────────────────────────── def _on_toggle_editing(self): editing = self.editing.get() if editing: if self.live_sync.get(): self.live_sync.set(False) self._on_toggle_sync() self.sync_check.configure(state="disabled") if self.combined is None: self.combined = vcomb.default_combined() self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs) self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten") else: self.sync_check.configure(state="normal") self.prog_row.pack_forget() self.header_sub.configure(text="pollt Config-JSONs alle 1.5s") self._update_tab_labels() self._render() def _status(self, text, ok): self.prog_status.configure(text=text, fg=OK_GREEN if ok else WARN_RED) def _load_from_board(self): self._status("lade Config vom Board...", True) self.update_idletasks() raw_cfg = self._link.read_full_config() if raw_cfg is None: self._status(f"Config laden fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False) return self._status("lade Makros vom Board...", True) self.update_idletasks() raw_macros = self._link.read_macros() if raw_macros is None: self._status(f"Makros laden fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False) return cfg_dict = vproto.unpack_config(raw_cfg) if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): self._status("Board-Antwort ungültig (Magic/CRC) -- abgebrochen", False) return macro_slots = vproto.unpack_macros(raw_macros) names = self.combined["profile_names"] if self.combined else None self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) self.profile = self.combined["active_profile"] self._status("vom Board geladen", True) self._update_tab_labels() self._render() def _write_to_board(self): if self.combined is None: return if not messagebox.askyesno("Zum Board übertragen", "Config + Makros wirklich aufs Board schreiben?\n" "(überschreibt, was aktuell im Board-NVM steht)"): return cfg_bytes, macro_bytes = vcomb.to_binary(self.combined) self._status("schreibe Config...", True) self.update_idletasks() if not self._link.write_full_config(cfg_bytes): self._status(f"Config-Schreiben fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False) return self._status("schreibe Makros...", True) self.update_idletasks() if not self._link.write_macros(macro_bytes): self._status(f"Makros-Schreiben fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False) return self._status("erfolgreich aufs Board übertragen", True) def _save_file_dialog(self): if self.combined is None: return path = filedialog.asksaveasfilename( initialdir=os.path.dirname(vcomb.DEFAULT_PATH), initialfile=os.path.basename(vcomb.DEFAULT_PATH), defaultextension=".json", filetypes=[("JSON", "*.json")]) if not path: return vcomb.save_file(self.combined, path) self._status(f"gespeichert: {os.path.basename(path)}", True) def _load_file_dialog(self): path = filedialog.askopenfilename( initialdir=os.path.dirname(vcomb.DEFAULT_PATH), filetypes=[("JSON", "*.json")]) if not path: return try: self.combined = vcomb.load_file(path) except Exception as e: self._status(f"Laden fehlgeschlagen: {e}", False) return self.profile = self.combined.get("active_profile", 0) self._status(f"geladen: {os.path.basename(path)}", True) self._update_tab_labels() self._render() def _edit_button(self, index): buttons = self.combined["profiles"][self.profile]["buttons"] btn = next(b for b in buttons if b["index"] == index) dlg = action_dialog.ActionEditDialog(self, f"Button #{index}", btn["action"], btn["led"], self.combined["macros"]) if dlg.run(): btn["action"] = dlg.action if dlg.led is not None: btn["led"] = dlg.led self._render() def _edit_encoder_action(self, enc_index, field, field_label): encoders = self.combined["profiles"][self.profile]["encoders"] enc = next(e for e in encoders if e["index"] == enc_index) dlg = action_dialog.ActionEditDialog( self, f"Encoder {enc_index} – {field_label}", enc[field], None, self.combined["macros"]) if dlg.run(): enc[field] = dlg.action self._render() # ── Rendering ──────────────────────────────────────────────────── def _render(self): for w in self.grid_frame.winfo_children(): w.destroy() for w in self.enc_frame.winfo_children(): w.destroy() editing = self.editing.get() if editing: if self.combined is None: self.combined = vcomb.default_combined() raw = self.combined["profiles"][self.profile] cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], }) source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert" else: try: cfg = vp.load_profile(self.profile) except FileNotFoundError as e: tk.Label(self.grid_frame, text=f"Config-Datei fehlt: {e}", bg=BG, fg="#e05a5a", font=("Segoe UI", 10)).pack() return source_text = f"Quelle: {vp.CONFIG_PATHS[self.profile]}" for col in range(vp.GRID_COLS): self.grid_frame.grid_columnconfigure(col, minsize=CARD_W + 10) for row in range(vp.GRID_ROWS): self.grid_frame.grid_rowconfigure(row, minsize=CARD_H + 10) for btn in cfg["buttons"]: self._render_cell(btn, editable=editing) for enc in cfg["encoders"]: self._render_encoder(enc, editable=editing) self.footer.configure(text=source_text) def _render_cell(self, btn, editable=False): """Feste Pixel-Positionen (statt pack) -- garantiert identisches Layout fuer jede Zelle, egal ob leer oder mit Animation.""" empty = btn["action"]["type"] == "None" card = tk.Frame(self.grid_frame, bg=CARD_BG, highlightbackground=CARD_BORDER, highlightthickness=1, width=CARD_W, height=CARD_H) card.grid(row=btn["row"], column=btn["col"], padx=5, pady=5) card.grid_propagate(False) tk.Frame(card, bg=vp.led_css_hex(btn["led"]), height=5).place( x=0, y=0, relwidth=1.0) tk.Label(card, text=f"#{btn['index']}", bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).place(relx=1.0, x=-8, y=8, anchor="ne") label = btn["label"] or "—" tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT, font=("Segoe UI", 10, "normal" if empty else "bold"), wraplength=CARD_W - 20, justify="left", anchor="nw").place( x=10, y=26, width=CARD_W - 20, height=36) anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"]) tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7), anchor="sw").place( x=10, y=CARD_H - 20, width=CARD_W - 20, height=14) if editable: card.configure(cursor="hand2") handler = lambda e, idx=btn["index"]: self._edit_button(idx) card.bind("", handler) for child in card.winfo_children(): child.bind("", handler) def _render_encoder(self, enc, editable=False): card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER, highlightthickness=1) card.pack(side="left", expand=True, fill="both", padx=(0 if enc["index"] == 0 else 6, 0)) inner = tk.Frame(card, bg=CARD_BG) inner.pack(fill="both", expand=True, padx=10, pady=8) tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4)) for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]), ("CCW", "ccw", enc["ccw_label"])): row = tk.Frame(inner, bg=CARD_BG) row.pack(fill="x", pady=1) tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left") tk.Label(row, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right") if editable: row.configure(cursor="hand2") handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl) row.bind("", handler) for child in row.winfo_children(): child.bind("", handler) if __name__ == "__main__": VersaPadViewer().mainloop()