448 lines
19 KiB
Python
448 lines
19 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 threading
|
||
import time
|
||
import tkinter as tk
|
||
from tkinter import filedialog, messagebox, simpledialog
|
||
|
||
import action_dialog
|
||
import versapad_combined as vcomb
|
||
import versapad_data as vp
|
||
import versapad_protocol as vproto
|
||
import versapad_serial as vs
|
||
|
||
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ß",
|
||
}
|
||
|
||
|
||
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)
|
||
|
||
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")
|
||
|
||
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("<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")
|
||
|
||
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._on_close)
|
||
self._serial_thread.start()
|
||
|
||
self.set_profile(0)
|
||
self.after(POLL_MS, self._poll)
|
||
self.after(200, self._drain_serial_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()
|
||
|
||
# ── 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=f"verbunden · Board zeigt {vp.PROFILE_NAMES[profile]}", 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)
|
||
|
||
def _on_close(self):
|
||
self._closing = True
|
||
self._link.close()
|
||
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("<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 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("<Button-1>", handler)
|
||
for child in row.winfo_children():
|
||
child.bind("<Button-1>", handler)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
VersaPadViewer().mainloop()
|