VersaGUI-py/desktop_viewer.py

448 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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()