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.
695 lines
30 KiB
Python
695 lines
30 KiB
Python
"""
|
||
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, 114
|
||
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)
|
||
set_button_note(profile, index, note) freie Notiz, was der Button tut
|
||
|
||
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
|
||
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
|
||
set_encoder_note(profile, index, field, note)
|
||
|
||
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("<Button-1>", lambda e: self._show_mcp_info())
|
||
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
|
||
info_btn.bind("<Leave>", 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 range(vp.NUM_PROFILES):
|
||
btn = tk.Label(self.tabs, text=f"Profil {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("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
|
||
btn.bind("<Double-Button-1>", 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", padx=(0, 20))
|
||
|
||
self.always_on_top = tk.BooleanVar(value=False)
|
||
self.topmost_check = tk.Checkbutton(
|
||
toggles_row, text="Immer im Vordergrund", variable=self.always_on_top,
|
||
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
|
||
self.topmost_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("<Unmap>", 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):
|
||
"""Profilnamen kommen immer aus der kombinierten JSON (nicht mehr
|
||
hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst
|
||
per schlankem Datei-Read (kein Board-Zugriff, siehe
|
||
versapad_combined.profile_names())."""
|
||
if self.editing.get() and self.combined:
|
||
names = self.combined["profile_names"]
|
||
else:
|
||
names = vcomb.read_profile_names()
|
||
for p, btn in self.tab_buttons.items():
|
||
btn.configure(text=names[p])
|
||
|
||
def _rename_tab(self, profile):
|
||
"""Profilname per Doppelklick auf den Tab umbenennen -- geht in
|
||
jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische
|
||
Domänenregeln). Im Programmiermodus wird der bereits geladene
|
||
In-Memory-State direkt bearbeitet + autosaved; sonst wird die
|
||
kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf
|
||
automatisch an, siehe versapad_combined.load_or_fetch())."""
|
||
if self.editing.get() and self.combined is not None:
|
||
combined = self.combined
|
||
else:
|
||
try:
|
||
combined = vcomb.load_or_fetch()
|
||
except (OSError, ValueError, KeyError) as e:
|
||
messagebox.showerror("Umbenennen fehlgeschlagen", str(e))
|
||
return
|
||
|
||
current = combined["profile_names"][profile]
|
||
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
|
||
initialvalue=current, parent=self)
|
||
if not name:
|
||
return
|
||
combined["profile_names"][profile] = name
|
||
|
||
if combined is self.combined:
|
||
self._autosave_combined()
|
||
else:
|
||
try:
|
||
vcomb.save_file(combined, vcomb.DEFAULT_PATH)
|
||
except OSError as e:
|
||
messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}")
|
||
return
|
||
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 os.path.exists(vcomb.DEFAULT_PATH):
|
||
mtimes["combined"] = os.path.getmtime(vcomb.DEFAULT_PATH)
|
||
if mtimes != self._mtimes:
|
||
self._mtimes = mtimes
|
||
if not self.editing.get():
|
||
self._render()
|
||
self.after(POLL_MS, self._poll)
|
||
|
||
def _on_toggle_topmost(self):
|
||
self.attributes("-topmost", self.always_on_top.get())
|
||
|
||
# ── 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:
|
||
if os.path.exists(vcomb.DEFAULT_PATH):
|
||
try:
|
||
self.combined = vcomb.load_file(vcomb.DEFAULT_PATH)
|
||
except (OSError, ValueError, KeyError):
|
||
self.combined = vcomb.default_combined()
|
||
else:
|
||
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
|
||
previous = self.combined
|
||
self.combined = vcomb.merge_notes(
|
||
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
|
||
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._autosave_combined()
|
||
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._autosave_combined()
|
||
self._render()
|
||
|
||
def _autosave_combined(self):
|
||
"""Speichert Programmiermodus-Aenderungen sofort in die kombinierte
|
||
Datei, damit der Nur-Lese-Modus (der dieselbe Datei bevorzugt liest,
|
||
siehe _current_profile_view) sie ohne Extra-Klick sieht. Schreibt
|
||
NICHT aufs Board -- dafuer weiterhin "Zum Board uebertragen"."""
|
||
try:
|
||
vcomb.save_file(self.combined, vcomb.DEFAULT_PATH)
|
||
except OSError as e:
|
||
self._status(f"Auto-Speichern fehlgeschlagen: {e}", False)
|
||
|
||
# ── Rendering ────────────────────────────────────────────────────
|
||
|
||
def _current_profile_view(self):
|
||
"""Liest das aktuell angezeigte Profil fuer den Nur-Lese-Modus.
|
||
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
|
||
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
|
||
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
|
||
Fehlt sie (z.B. versehentlich geloescht, oder frische Installation
|
||
ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird
|
||
sie automatisch angelegt -- per Serial vom Board, oder als leere
|
||
Default-Config, wenn auch kein Board erreichbar ist (siehe
|
||
versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz
|
||
ohne vorhandene Config sofort benutzbar, kein Datei-Handling von
|
||
Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den
|
||
COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die
|
||
klassischen versapad_config{1,2,3}.json (Export der offiziellen
|
||
VersaGUI) aus."""
|
||
if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get():
|
||
try:
|
||
data = vcomb.load_or_fetch(link=self._link)
|
||
raw = data["profiles"][self.profile]
|
||
cfg = vp.annotate_profile({
|
||
"buttons": [dict(b) for b in raw["buttons"]],
|
||
"encoders": [dict(e) for e in raw["encoders"]],
|
||
})
|
||
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
|
||
except (KeyError, IndexError, ValueError):
|
||
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen
|
||
try:
|
||
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
|
||
except FileNotFoundError as e:
|
||
hint = (" (Live-Sync ist an -- COM-Port belegt, zum automatischen "
|
||
"Neuladen vom Board erst ausschalten)") if self.live_sync.get() else ""
|
||
raise FileNotFoundError(f"{e}{hint}") from e
|
||
|
||
def _render(self):
|
||
self._update_tab_labels()
|
||
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, source_text = self._current_profile_view()
|
||
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
|
||
|
||
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=30)
|
||
|
||
tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
||
x=10, y=58, width=CARD_W - 20, height=34)
|
||
|
||
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 - 18, width=CARD_W - 20, height=14)
|
||
|
||
if editable:
|
||
card.configure(cursor="hand2")
|
||
handler = lambda e, idx=btn["index"]: self._edit_button(idx)
|
||
card.bind("<Button-1>", handler)
|
||
for child in card.winfo_children():
|
||
child.bind("<Button-1>", 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, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]),
|
||
("CW", "cw", enc["cw_label"], enc["cw_note"]),
|
||
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
|
||
row = tk.Frame(inner, bg=CARD_BG)
|
||
row.pack(fill="x", pady=(1, 4))
|
||
top = tk.Frame(row, bg=CARD_BG)
|
||
top.pack(fill="x")
|
||
tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
|
||
tk.Label(top, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
|
||
note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7),
|
||
wraplength=150, justify="left", anchor="w")
|
||
note_label.pack(fill="x")
|
||
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("<Button-1>", handler)
|
||
top.bind("<Button-1>", handler)
|
||
note_label.bind("<Button-1>", handler)
|
||
for child in top.winfo_children():
|
||
child.bind("<Button-1>", handler)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
VersaPadViewer().mainloop()
|