From 6227903ebd21b737ec8ceaf8c4be59ac8546e74b Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:03:49 +0200 Subject: [PATCH] Initial commit: VersaPad viewer + programming mode --- AGENTS.md | 127 ++++++++++++ action_dialog.py | 329 +++++++++++++++++++++++++++++++ desktop_viewer.py | 448 +++++++++++++++++++++++++++++++++++++++++++ run_browser.bat | 3 + run_desktop.bat | 3 + server.py | 157 +++++++++++++++ versapad_combined.py | 94 +++++++++ versapad_data.py | 189 ++++++++++++++++++ versapad_protocol.py | 196 +++++++++++++++++++ versapad_serial.py | 219 +++++++++++++++++++++ 10 files changed, 1765 insertions(+) create mode 100644 AGENTS.md create mode 100644 action_dialog.py create mode 100644 desktop_viewer.py create mode 100644 run_browser.bat create mode 100644 run_desktop.bat create mode 100644 server.py create mode 100644 versapad_combined.py create mode 100644 versapad_data.py create mode 100644 versapad_protocol.py create mode 100644 versapad_serial.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..860210c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,127 @@ +# versapad-viewer + +Eigenes kleines Tool (Grovy311-Kontext, nicht Teil der fremden jappel-Repos +VersaGUI/VersaMCU). Zeigt die Steuermatrix (4x5 Button-Grid + 4 Encoder) als +Browser-Live-Seite oder natives Tkinter-Fenster. Inzwischen mehr als ein +reiner Viewer: der Tkinter-Desktop-Viewer hat einen vollen Programmiermodus +(Action-Editor, Serial-Schreibzugriff aufs Board, Makro-Editor) bekommen — +faktisch ein schlankes Python-Pendant zu VersaGUI. Der Browser-Server +(`server.py`) blieb bewusst read-only/einfach. + +## Architektur + +**Read-only-Schicht (JSON, beide Frontends):** +- `versapad_data.py` — Decoding fuer Anzeige: JSON laden, HID-Keycode/ + Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie (index = + spalte*5+reihe), `hid_key_choices()`/`consumer_choices()` fuer Dropdowns. +- `server.py` — stdlib `http.server`, generiert HTML bei jedem Request neu, + Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend, + kein Programmiermodus (bewusst einfach gehalten). + +**Binaer-/Serial-Schicht (fuer den Programmiermodus):** +- `versapad_protocol.py` — pack/unpack fuer `SDeviceConfig`(740B, alle 3 + Profile) und `SMacroTable`(512B, 32 Slots) + CRC16 (Poly 0x1021, Init + 0xFFFF), 1:1 aus den Firmware-Structs (`nvm_config.h`, `action.h`, + `macro_config.h`, `CButton.h`-LEDAnim-Enum, `nvm_config.cpp`-CRC). + **Gegen echtes Board validiert:** read → unpack → pack → byte-identisch + zum Original, inkl. CRC — siehe Git-Historie dieser Datei / Sessionlog. +- `versapad_serial.py` — `VersaPadLink`: liest/schreibt komplette Config + + Makros per 8-Byte-Paket-Protokoll (`CmdConfigRead/Begin/Data/Commit`, + `CmdMacroRead/Begin/Data/Commit`), Board-Identifikation per VID/PID + `239A:0042`. Schreiben ist sicher im Sinne von "kann NVM nicht zerlegen" + — Firmware prueft Magic/CRC/Keycode-Bereich vor jedem Save und antwortet + sonst nur mit NACK (kein Datenmuell moeglich). +- `versapad_combined.py` — kombiniertes Ein-Datei-Format (alle 3 Profile + + Makros + **nur lokal gespeicherte** Profilnamen) statt der 3 Einzel-JSONs. + Passt besser zum Wire-Protokoll: `CONFIG_BEGIN/COMMIT` ueberträgt ohnehin + immer den kompletten 740B-Block auf einmal, nie nur ein Profil. + Default-Pfad: `~\OneDrive\Desktop\versapad_config_all.json`. + +**UI:** +- `desktop_viewer.py` — Tkinter, flaches Design (Label-Flächen, keine + Canvas-Formen/Anti-Aliasing). Zellen nutzen `.place()` mit festen + Pixel-Positionen statt `.pack()` (mit pack sah das Grid je nach leerer/ + belegter Zelle ungleichmäßig aus). Drei unabhängige Modi: + - **Nur lesen** (Default): pollt die 3 JSONs alle 1.5s. + - **Live-Sync**: pollt per Serial das aktive Profil vom Board, schaltet + Tabs automatisch mit. Laeuft in einem Hintergrundthread, der NUR ueber + eine `queue.Queue` mit dem Main-Thread kommuniziert (`self.after()` + direkt aus einem Fremdthread aufrufen ist in Tkinter nicht threadsicher + und hat den Prozess in einer frueheren Version lautlos abstuerzen + lassen — `pyw` hat kein Konsolenfenster, der Crash war unsichtbar). + - **Programmiermodus**: Zellen anklicken → `action_dialog.py` + (`ActionEditDialog`/`MacroStepsDialog`) bearbeitet Action-Typ, HID-Taste + (Dropdown statt Tastendruck-Capture — kein WinAPI-Hook, um nicht wieder + einen AV-Fehlalarm wie bei den Fensterverstecktricks zu riskieren), + Consumer, Makro-Slot+Schritte, ProfileSwitch, LED-Farbe/Animation. + "Vom Board laden"/"Zum Board übertragen" nutzen die Binaer-Schicht. + Schaltet Live-Sync automatisch aus (ein `VersaPadLink` kann nicht von + zwei Konsumenten gleichzeitig genutzt werden). +- `action_dialog.py` — die beiden Bearbeiten-Dialoge, importiert nur + `versapad_data` (fuer Dropdown-Inhalte). + +## Quelle der Wahrheit — nicht raten + +Bei Aenderungen am Schema/Decoding/Binaerformat immer gegen die Firmware- +und GUI-Quellen abgleichen, nie aus dem Gedaechtnis rekonstruieren: +- `VersaGUI/src/ActionDialog.cs` (HidKeyName, s_consumer, Modifier-Bits) +- `VersaGUI/src/Protocol.cs` (Paket-IDs, Chunk-Groessen) +- `VersaMCU/src/config/nvm_config.h` + `.cpp` (SDeviceConfig-Layout, CRC) +- `VersaMCU/src/config/action.h` (SAction, ActionType-Enum-Werte) +- `VersaMCU/src/config/macro_config.h` + `.cpp` (SMacroTable-Layout) +- `VersaMCU/src/CButton.h` (LEDAnim-Enum-Werte) +- `VersaMCU/src/CMainController.cpp` (welche Commands die Firmware + tatsaechlich behandelt — Protocol.cs definiert mehr Konstanten als die + Firmware zwingend implementiert, immer hier gegenchecken) + +Zeichentasten (A-Z, Ziffern, Satzzeichen) zeigen US-Layout-Näherung, da die +echte GUI das über `GetKeyNameText()` layoutabhängig auflöst — das bilden +wir ohne WinAPI-Call nicht nach. + +**Korrektur einer frueheren Annahme:** Anders als zuerst notiert, kann die +Firmware Makros sehr wohl per normaler USB-Verbindung schreiben +(`USB_CMD_MACRO_BEGIN/DATA/COMMIT` in `CMainController.cpp` sind +implementiert) — kein SWD/JTAG noetig. Der VersaPad-Skill +(`~/.claude/skills/versapad/SKILL.md`) behauptet noch das Gegenteil und +sollte bei Gelegenheit korrigiert werden. + +## Start + +``` +py server.py # Browser, http://127.0.0.1:8765 +pyw desktop_viewer.py # natives Fenster, kein Konsolenfenster +``` +Oder `run_browser.bat` / `run_desktop.bat` per Doppelklick, oder die +Desktop-Verknuepfungen "VersaPad Viewer (Browser)" / "VersaPad Viewer +(Fenster)". + +**Wichtig:** `pyw`/`py` benutzen, nicht bare `pythonw`/`python` — auf hal9001 +liegt in PATH zuerst eine Hermes-Agent-venv (`...\hermes\hermes-agent\venv\ +Scripts\pythonw.exe`) ohne pyserial. `pyw`/`py` (Python Launcher) lösen +zuverlässig zur echten Python313-Installation auf. + +## Dateien/Pfade (hardcoded, hal9001-spezifisch) + +- Einzel-JSONs (Read-only-Modus): `~\OneDrive\Desktop\versapad_config{1,2,3}.json` + — Profil 0 Windows / 1 Fusion 360 / 2 BricsCAD, siehe `versapad_data.CONFIG_PATHS`. +- Kombinierte Datei (Programmiermodus-Export): `~\OneDrive\Desktop\ +versapad_config_all.json` (Default, siehe `versapad_combined.DEFAULT_PATH`). + +## Offene Punkte + +- Kein automatischer Windows-Start/Tray-Icon — bewusst einfach gehalten, + bei Bedarf nachrüstbar. +- Live-Sync und VersaGUI (bzw. Programmiermodus) können den COM-Port nicht + gleichzeitig halten (exklusiver Zugriff) — zeigt dann "Port belegt", kein + Crash. VersaGUI läuft als Tray-App dauerhaft im Hintergrund weiter, auch + wenn nur das Konfigurationsfenster geschlossen wird — für Programmiermodus/ + Live-Sync muss es über das Tray-Icon → "Beenden" wirklich beendet werden. + Live-Sync wird beim Aktivieren des Programmiermodus automatisch ausgeschaltet. + Innerhalb des Programmiermodus selbst laufen "Vom Board laden"/"Zum Board + übertragen" synchron im UI-Thread (kurzzeitiges Einfrieren möglich, v.a. + bei Timeout) — bislang nicht als Hintergrund-Thread ausgelagert. + Der Browser-Server (`server.py`) hat keinen Programmiermodus. +- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein + Tastendruck-Capture (bewusst, um WinAPI-Hooks/AV-Fehlalarme zu vermeiden). +- Macro-Editor erlaubt nur Strg/Shift/Alt pro Schritt (kein Win), passend + zum Original-`ActionDialog.cs`-Verhalten fuer Makro-Steps. diff --git a/action_dialog.py b/action_dialog.py new file mode 100644 index 0000000..09a9f0d --- /dev/null +++ b/action_dialog.py @@ -0,0 +1,329 @@ +""" +Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu +VersaGUI/src/ActionDialog.cs, aber in Tkinter und ohne Tastendruck-Capture +(kein WinAPI-Hook -- stattdessen Tasten-Auswahl per Dropdown, siehe +versapad_data.hid_key_choices()). + +ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons). +MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur + echten GUI ("8 Step-Buttons + je Strg/Shift/Alt-Checkboxen", kein Win). +""" +import tkinter as tk +from tkinter import colorchooser, ttk + +import versapad_data as vp + +BG = "#1e2129" +BG2 = "#14161b" +TEXT = "#e8e8ec" +TEXT_DIM = "#8a8d98" +ACCENT = "#3a6ff0" + +TYPE_CHOICES = [ + ("None", "Keine"), + ("HidKey", "Taste (HID)"), + ("HidConsumer", "Medientaste"), + ("Macro", "Makro"), + ("ProfileSwitch", "Profilwechsel"), +] + +PROFILE_SWITCH_CHOICES = [ + ("Nächstes Profil (Zyklus)", 0xFFFF), + ("Profil 1", 0), + ("Profil 2", 1), + ("Profil 3", 2), +] + + +class _ModalDialog(tk.Toplevel): + def __init__(self, parent, title): + super().__init__(parent) + self.title(title) + self.configure(bg=BG2) + self.transient(parent) + self.resizable(False, False) + self.cancelled = True + + def _finish(self, cancelled): + self.cancelled = cancelled + self.grab_release() + self.destroy() + + def run(self): + self.update_idletasks() + self.grab_set() + self.wait_window(self) + return not self.cancelled + + +class MacroStepsDialog(_ModalDialog): + """Ergebnis in self.steps nach run()==True.""" + + def __init__(self, parent, steps): + super().__init__(parent, "Makro-Schritte") + self.steps = [] + + key_choices = vp.hid_key_choices() + self._name_by_code = {code: name for code, name in key_choices} + self._code_by_name = {name: code for code, name in key_choices} + names = ["(leer)"] + [name for _, name in key_choices] + + tk.Label(self, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).grid( + row=0, column=0, columnspan=5, sticky="w", padx=12, pady=(12, 6)) + + self._key_vars = [] + self._mod_vars = [] + for i in range(8): + r = i + 1 + tk.Label(self, text=f"{i + 1}.", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e") + + key_var = tk.StringVar(value="(leer)") + combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly") + combo.grid(row=r, column=1, padx=4, pady=2) + self._key_vars.append(key_var) + + mods = {} + for j, label in enumerate(("Strg", "Shift", "Alt")): + v = tk.BooleanVar(value=False) + tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT, + selectcolor=BG, activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, sticky="w") + mods[label] = v + self._mod_vars.append(mods) + + for i, step in enumerate(steps[:8]): + self._key_vars[i].set(self._name_by_code.get(step["keycode"], "(leer)")) + self._mod_vars[i]["Strg"].set(bool(step["modifier"] & 0x01)) + self._mod_vars[i]["Shift"].set(bool(step["modifier"] & 0x02)) + self._mod_vars[i]["Alt"].set(bool(step["modifier"] & 0x04)) + + btns = tk.Frame(self, bg=BG2) + btns.grid(row=9, column=0, columnspan=5, pady=12) + tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff", + activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) + tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG, + fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + + def _on_ok(self): + steps = [] + for i in range(8): + name = self._key_vars[i].get() + if name == "(leer)": + break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren + modifier = ( + (0x01 if self._mod_vars[i]["Strg"].get() else 0) | + (0x02 if self._mod_vars[i]["Shift"].get() else 0) | + (0x04 if self._mod_vars[i]["Alt"].get() else 0) + ) + steps.append({"keycode": self._code_by_name[name], "modifier": modifier}) + self.steps = steps + self._finish(False) + + +class ActionEditDialog(_ModalDialog): + """Ergebnis in self.action / self.led nach run()==True. led bleibt None + wenn led_in None war (Encoder -- keine eigene Farbe).""" + + def __init__(self, parent, title, action, led, macros): + super().__init__(parent, title) + self._macros = macros + self._led = dict(led) if led is not None else None + self.action = None + self.led = None + + row = 0 + tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid( + row=row, column=0, sticky="w", padx=12, pady=(12, 4)); row += 1 + + self._type_var = tk.StringVar(value=action["type"]) + type_frame = tk.Frame(self, bg=BG2) + type_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12); row += 1 + for value, label in TYPE_CHOICES: + tk.Radiobutton(type_frame, text=label, variable=self._type_var, value=value, + command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG, + activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) + + self._panel_row = row + self._panel = tk.Frame(self, bg=BG2) + self._panel.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=8) + row += 1 + + # HidKey-Panel + key_choices = vp.hid_key_choices() + self._key_name_by_code = {code: name for code, name in key_choices} + self._key_code_by_name = {name: code for code, name in key_choices} + self._hidkey_mods = {} + self._hidkey_key_var = tk.StringVar() + + # HidConsumer-Panel + cons_choices = vp.consumer_choices() + self._cons_name_by_id = {cid: name for cid, name in cons_choices} + self._cons_id_by_name = {name: cid for cid, name in cons_choices} + self._consumer_var = tk.StringVar() + + # Macro-Panel + self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0) + self._macro_preview_var = tk.StringVar() + + # ProfileSwitch-Panel + self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0]) + + if action["type"] == "HidKey": + keycode = action["data"] & 0xFF + modifier = (action["data"] >> 8) & 0xFF + self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)")) + self._hidkey_mods_init = modifier + else: + self._hidkey_key_var.set("(leer)") + self._hidkey_mods_init = 0 + + if action["type"] == "HidConsumer": + self._consumer_var.set(self._cons_name_by_id.get(action["data"], cons_choices[0][1])) + else: + self._consumer_var.set(cons_choices[0][1]) + + if action["type"] == "ProfileSwitch": + for name, val in PROFILE_SWITCH_CHOICES: + if val == action["data"] or (val == 0xFFFF and action["data"] in (0xFFFF, 0x00FF)): + self._profile_switch_var.set(name) + break + + # LED-Panel (nur wenn showColor) + self._anim_var = tk.StringVar(value=self._led["anim"] if self._led else "Static") + self._period_var = tk.IntVar(value=self._led["period_ms"] if self._led else 4000) + self._led_color = (self._led["r"], self._led["g"], self._led["b"]) if self._led else (80, 40, 0) + + if self._led is not None: + led_frame = tk.Frame(self, bg=BG2) + led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)); row += 1 + tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).pack(anchor="w") + + color_row = tk.Frame(led_frame, bg=BG2) + color_row.pack(anchor="w", pady=4) + self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4) + self._swatch.pack(side="left") + tk.Button(color_row, text="Farbe wählen...", command=self._pick_color, bg=BG, + fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) + + anim_row = tk.Frame(led_frame, bg=BG2) + anim_row.pack(anchor="w", pady=4) + tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()), + width=12, state="readonly").pack(side="left", padx=6) + tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left", padx=(12, 0)) + tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var, + width=7).pack(side="left", padx=6) + + btns = tk.Frame(self, bg=BG2) + btns.grid(row=row, column=0, columnspan=3, pady=14) + tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff", + activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) + tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG, + fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + + self._on_type_change() + + def _hex(self): + r, g, b = self._led_color + return f"#{r:02x}{g:02x}{b:02x}" + + def _pick_color(self): + result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe") + if result and result[0]: + r, g, b = (int(c) for c in result[0]) + self._led_color = (r, g, b) + self._swatch.configure(bg=self._hex()) + + def _on_type_change(self): + for w in self._panel.winfo_children(): + w.destroy() + t = self._type_var.get() + + if t == "HidKey": + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + self._hidkey_mods = {} + for label, bit in (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08)): + v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit)) + tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG, + activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 6)) + self._hidkey_mods[bit] = v + row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4) + tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + names = ["(leer)"] + [n for _, n in vp.hid_key_choices()] + ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names, + width=18, state="readonly").pack(side="left", padx=6) + + elif t == "HidConsumer": + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + names = [n for _, n in vp.consumer_choices()] + ttk.Combobox(row1, textvariable=self._consumer_var, values=names, + width=20, state="readonly").pack(side="left", padx=6) + + elif t == "Macro": + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + tk.Label(row1, text="Slot (0-31):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + tk.Spinbox(row1, from_=0, to=31, textvariable=self._macro_slot_var, width=5, + command=self._refresh_macro_preview).pack(side="left", padx=6) + tk.Button(row1, text="Schritte bearbeiten...", command=self._edit_macro_steps, + bg=BG, fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) + row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=(4, 0)) + tk.Label(row2, textvariable=self._macro_preview_var, bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 8), wraplength=340, justify="left").pack(anchor="w") + self._refresh_macro_preview() + + elif t == "ProfileSwitch": + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + tk.Label(row1, text="Ziel:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + ttk.Combobox(row1, textvariable=self._profile_switch_var, + values=[n for n, _ in PROFILE_SWITCH_CHOICES], + width=22, state="readonly").pack(side="left", padx=6) + + self.update_idletasks() + + def _refresh_macro_preview(self): + slot = self._macro_slot_var.get() + steps = self._macros[slot] if 0 <= slot < len(self._macros) else [] + self._macro_preview_var.set(f"Slot {slot}: {vp.macro_slot_label(steps)}") + + def _edit_macro_steps(self): + slot = self._macro_slot_var.get() + current = self._macros[slot] if 0 <= slot < len(self._macros) else [] + dlg = MacroStepsDialog(self, current) + if dlg.run(): + while len(self._macros) <= slot: + self._macros.append([]) + self._macros[slot] = dlg.steps + self._refresh_macro_preview() + + def _on_ok(self): + t = self._type_var.get() + if t == "None": + action = {"type": "None", "data": 0} + elif t == "HidKey": + name = self._hidkey_key_var.get() + keycode = self._key_code_by_name.get(name, 0) + modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get()) + action = {"type": "HidKey", "data": (modifier << 8) | keycode} + elif t == "HidConsumer": + action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]} + elif t == "Macro": + action = {"type": "Macro", "data": self._macro_slot_var.get()} + elif t == "ProfileSwitch": + name = self._profile_switch_var.get() + data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name) + action = {"type": "ProfileSwitch", "data": data} + else: + action = {"type": "None", "data": 0} + + self.action = action + if self._led is not None: + r, g, b = self._led_color + period = max(2, self._period_var.get()) if self._anim_var.get() == "Pulse" else self._period_var.get() + self.led = {"r": r, "g": g, "b": b, "brightness": self._led.get("brightness", 255), + "anim": self._anim_var.get(), "period_ms": period} + self._finish(False) diff --git a/desktop_viewer.py b/desktop_viewer.py new file mode 100644 index 0000000..02e6fd5 --- /dev/null +++ b/desktop_viewer.py @@ -0,0 +1,448 @@ +""" +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("", 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._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("", 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() diff --git a/run_browser.bat b/run_browser.bat new file mode 100644 index 0000000..a4965fc --- /dev/null +++ b/run_browser.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +py server.py diff --git a/run_desktop.bat b/run_desktop.bat new file mode 100644 index 0000000..5bdf3f0 --- /dev/null +++ b/run_desktop.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +start "" pyw desktop_viewer.py diff --git a/server.py b/server.py new file mode 100644 index 0000000..1090911 --- /dev/null +++ b/server.py @@ -0,0 +1,157 @@ +""" +VersaPad Viewer -- Browser-Variante. +Liest bei jedem Request live die 3 Config-JSONs vom Desktop und rendert +die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. +Kein Build-Schritt, kein externes Framework -- nur stdlib. + +Start: python server.py [--port 8765] +""" +import argparse +import html +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +import versapad_data as vp + +PAGE_CSS = """ +:root { color-scheme: dark; } +* { box-sizing: border-box; } +body { + margin: 0; padding: 32px 24px 64px; + background: #14161b; color: #e8e8ec; + font-family: -apple-system, "Segoe UI", Roboto, sans-serif; +} +h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } +.sub { color: #8a8d98; font-size: 13px; margin-bottom: 24px; } +.tabs { display: flex; gap: 8px; margin-bottom: 28px; } +.tab { + padding: 8px 16px; border-radius: 8px; text-decoration: none; + color: #c4c6cf; background: #1e2129; font-size: 14px; font-weight: 500; + border: 1px solid #2a2d37; +} +.tab.active { background: #3a6ff0; color: #fff; border-color: #3a6ff0; } +.grid { + display: grid; + grid-template-columns: repeat(4, 140px); + grid-template-rows: repeat(5, 76px); + grid-auto-flow: column; + gap: 10px; + margin-bottom: 36px; +} +.cell { + position: relative; border-radius: 10px; padding: 8px 10px; + display: flex; flex-direction: column; justify-content: flex-end; + background: #1e2129; border: 1px solid #2a2d37; overflow: hidden; +} +.cell .swatch { + position: absolute; top: 0; left: 0; right: 0; height: 6px; +} +.cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; } +.cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; } +.cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; } +.cell.empty .label { color: #4a4d58; font-weight: 400; } +h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; } +.encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; } +.enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; } +.enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; } +.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; } +.enc .row .k { color: #8a8d98; } +.enc .row .v { font-weight: 500; text-align: right; } +footer { margin-top: 40px; color: #6a6d78; font-size: 12px; } +""" + + +def render_cell(btn): + empty = btn["action"]["type"] == "None" + anim = vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"]) + css_class = "cell empty" if empty else "cell" + grid_col, grid_row = btn["col"] + 1, btn["row"] + 1 + style = f"grid-column:{grid_col}; grid-row:{grid_row};" + label = html.escape(btn["label"]) if btn["label"] else "—" + return f"""
+
+
#{btn['index']}
+
{label}
+
{"" if empty else anim}
+
""" + + +def render_encoder(enc): + return f"""
+
Encoder {enc['index']}
+
Druck{html.escape(enc['sw_label']) or '—'}
+
CW{html.escape(enc['cw_label']) or '—'}
+
CCW{html.escape(enc['ccw_label']) or '—'}
+
""" + + +def render_page(profile): + cfg = vp.load_profile(profile) + tabs = "".join( + f'{html.escape(vp.PROFILE_NAMES[p])}' + for p in sorted(vp.PROFILE_NAMES) + ) + cells = "".join(render_cell(b) for b in cfg["buttons"]) + encoders = "".join(render_encoder(e) for e in cfg["encoders"]) + return f""" + + + +VersaPad Steuermatrix + + +

VersaPad Steuermatrix

+
Live-Ansicht der Config-JSONs · aktualisiert alle 4s
+
{tabs}
+
{cells}
+

Encoder

+
{encoders}
+
Quelle: {html.escape(vp.CONFIG_PATHS[profile])}
+""" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def do_GET(self): + query = parse_qs(urlparse(self.path).query) + try: + profile = int(query.get("profile", ["0"])[0]) + except ValueError: + profile = 0 + if profile not in vp.PROFILE_NAMES: + profile = 0 + try: + body = render_page(profile).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except FileNotFoundError as e: + self.send_response(500) + self.end_headers() + self.wfile.write(f"Config-Datei fehlt: {e}".encode("utf-8")) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--no-browser", action="store_true") + args = parser.parse_args() + + server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + url = f"http://127.0.0.1:{args.port}/" + print(f"VersaPad Viewer läuft auf {url} (Strg+C zum Beenden)") + if not args.no_browser: + webbrowser.open(url) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/versapad_combined.py b/versapad_combined.py new file mode 100644 index 0000000..5423196 --- /dev/null +++ b/versapad_combined.py @@ -0,0 +1,94 @@ +""" +Kombiniertes Ein-Datei-Format fuer den Programmiermodus: alle 3 Profile + +Makro-Tabelle + (nur lokal gespeicherte) Profilnamen in einer JSON-Datei, +plus Konvertierung zu/von den Binaerblobs aus versapad_protocol.py. + +Passt besser zum echten Geraeteprotokoll als die 3 Einzeldateien von +versapad_data.py: CONFIG_BEGIN/DATA/COMMIT ueberträgt ohnehin immer den +kompletten 740B-Block (alle 3 Profile auf einmal), nie nur ein Profil. + +Profilnamen: die Firmware-Structs (SDeviceConfig/SDeviceProfile) haben +keinerlei Platz fuer einen String -- Header exakt 32B, jedes Profil exakt +236B, alles verplant (siehe nvm_config.h). Namen bleiben deshalb rein +lokal in dieser Datei, landen nie auf dem Board. +""" +import json +import os + +import versapad_data as vp +import versapad_protocol as proto + +DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json") + +DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] + + +def _empty_profile(): + buttons = [{"index": i, "action": {"type": "None", "data": 0}, + "led": {"r": 80, "g": 40, "b": 0, "brightness": 255, + "anim": "Static", "period_ms": 4000}} for i in range(20)] + none = {"type": "None", "data": 0} + encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)] + return {"buttons": buttons, "encoders": encoders} + + +def default_combined(): + """Seed aus den 3 bestehenden Einzel-JSONs (versapad_data.CONFIG_PATHS), + fehlende Dateien werden als leeres Profil aufgefuellt. Makros leer, + da die Einzel-JSONs keine Makro-Schritte enthalten (kein Exportformat + dafuer) -- fuer echte Makro-Inhalte "Vom Board laden" benutzen.""" + profiles = [] + for p in range(3): + try: + cfg = vp.load_profile(p) + profiles.append({ + "buttons": [{"index": b["index"], "action": b["action"], "led": b["led"]} for b in cfg["buttons"]], + "encoders": [{"index": e["index"], "sw": e["sw"], "cw": e["cw"], "ccw": e["ccw"]} for e in cfg["encoders"]], + }) + except FileNotFoundError: + profiles.append(_empty_profile()) + + return { + "active_profile": 0, + "global_brightness": 255, + "enc_sensitivity": [1, 1, 1, 1], + "profile_names": list(DEFAULT_NAMES), + "profiles": profiles, + "macros": [[] for _ in range(proto.MACRO_SLOTS)], + } + + +def from_binary(config_dict, macro_slots, profile_names=None): + """config_dict: Ergebnis von versapad_protocol.unpack_config(). + macro_slots: Ergebnis von versapad_protocol.unpack_macros().""" + return { + "active_profile": config_dict["active_profile"], + "global_brightness": config_dict["global_brightness"], + "enc_sensitivity": config_dict["enc_sensitivity"], + "profile_names": profile_names or list(DEFAULT_NAMES), + "profiles": config_dict["profiles"], + "macros": macro_slots, + } + + +def to_binary(combined): + """-> (config_bytes[740], macro_bytes[512])""" + config_bytes = proto.pack_config({ + "active_profile": combined["active_profile"], + "global_brightness": combined["global_brightness"], + "enc_sensitivity": combined["enc_sensitivity"], + "profiles": combined["profiles"], + }) + macro_bytes = proto.pack_macros(combined["macros"]) + return config_bytes, macro_bytes + + +def save_file(combined, path=DEFAULT_PATH): + with open(path, "w", encoding="utf-8") as f: + json.dump(combined, f, indent=2, ensure_ascii=False) + return path + + +def load_file(path=DEFAULT_PATH): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) diff --git a/versapad_data.py b/versapad_data.py new file mode 100644 index 0000000..c010ac3 --- /dev/null +++ b/versapad_data.py @@ -0,0 +1,189 @@ +""" +Liest die 3 VersaPad-Config-JSONs (Desktop) und decodiert sie zu +lesbaren Strukturen. Referenz: VersaGUI/src/ActionDialog.cs, ConfigJson.cs +(s_consumer-Tabelle, HidKeyName()-Sonderzeichen, Modifier-Bits). + +Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. +""" +import json +import os + +CONFIG_PATHS = { + 0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"), + 1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"), + 2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"), +} + +PROFILE_NAMES = { + 0: "Profil 0 – Windows", + 1: "Profil 1 – Fusion 360", + 2: "Profil 2 – BricsCAD", +} + +# index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge) +GRID_COLS = 4 +GRID_ROWS = 5 + +MODIFIER_BITS = [ + (0x01, "Strg"), + (0x02, "Shift"), + (0x04, "Alt"), + (0x08, "Win"), +] + +# Verbatim aus ActionDialog.cs HidKeyName() -- Sondertasten ohne Scan-Code-Eintrag +_SPECIAL_KEYS = { + 0x28: "Enter", 0x29: "Escape", 0x2A: "Backspace", 0x2B: "Tab", + 0x2C: "Leer", 0x39: "Caps", 0x46: "Druck", 0x47: "Rollen", + 0x48: "Pause", 0x49: "Einfg", 0x4A: "Pos1", 0x4B: "Bild↑", + 0x4C: "Entf", 0x4D: "Ende", 0x4E: "Bild↓", + 0x4F: "→", 0x50: "←", 0x51: "↓", 0x52: "↑", + 0x53: "NumLock", 0x54: "Num/", 0x55: "Num*", 0x56: "Num-", + 0x57: "Num+", 0x58: "NumEnter", + 0x59: "Num1", 0x5A: "Num2", 0x5B: "Num3", 0x5C: "Num4", + 0x5D: "Num5", 0x5E: "Num6", 0x5F: "Num7", 0x60: "Num8", + 0x61: "Num9", 0x62: "Num0", 0x63: "Num.", + 0x3A: "F1", 0x3B: "F2", 0x3C: "F3", 0x3D: "F4", 0x3E: "F5", + 0x3F: "F6", 0x40: "F7", 0x41: "F8", 0x42: "F9", 0x43: "F10", + 0x44: "F11", 0x45: "F12", +} + +# HID Usage Page 0x07 (Keyboard), Zeichentasten -- US-Layout-Beschriftung als +# lesbare Näherung (die reale GUI zeigt das aktive Windows-Layout via +# GetKeyNameText, das können wir hier ohne WinAPI-Call nicht nachbilden). +for _i in range(26): + _SPECIAL_KEYS[0x04 + _i] = chr(ord("A") + _i) +for _i in range(9): + _SPECIAL_KEYS[0x1E + _i] = str(_i + 1) +_SPECIAL_KEYS[0x27] = "0" +_SPECIAL_KEYS.update({ + 0x2D: "-", 0x2E: "=", 0x2F: "[", 0x30: "]", 0x31: "\\", + 0x32: "#", 0x33: ";", 0x34: "'", 0x35: "`", 0x36: ",", + 0x37: ".", 0x38: "/", 0x64: "\\ (Non-US)", 0x65: "Menu", +}) + +# Verbatim aus ActionDialog.cs s_consumer -- HID Consumer Usage IDs (Usage Page 0x0C) +_CONSUMER_NAMES = { + 0x00CD: "Play / Pause", + 0x00B5: "Nächster Titel", + 0x00B6: "Vorheriger Titel", + 0x00B7: "Stop", + 0x00E9: "Lauter", + 0x00EA: "Leiser", + 0x00E2: "Stummschalten", + 0x0192: "Taschenrechner", + 0x0223: "Browser – Startseite", + 0x0224: "Browser – Zurück", + 0x0225: "Browser – Vor", + 0x00B0: "Aufnahme", +} + +ANIM_LABELS = { + "Static": "● statisch", + "Blink": "◎ blinkend", + "Pulse": "≈ pulsierend", + "FadeIn": "↗ fade-in", + "FadeOut": "↘ fade-out", + "ColorCycle": "⟳ regenbogen", + "ColorFade": "⟳ farbwechsel", +} + + +def hid_key_choices(): + """Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren.""" + return sorted(_SPECIAL_KEYS.items()) + + +def consumer_choices(): + """Sortierte [(usage_id, name), ...] fuer Dropdown-Auswahl beim Editieren.""" + return sorted(_CONSUMER_NAMES.items()) + + +def macro_step_label(step): + """step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante + von hid_key_label() (dort steckt beides in einem 16-Bit data-Feld, hier getrennt).""" + mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit] + key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}") + return "+".join(mods + [key]) if mods else key + + +def macro_slot_label(steps): + if not steps: + return "(leer)" + return " → ".join(macro_step_label(s) for s in steps) + + +def hid_key_label(data): + """data = keycode | (modifier << 8) -> z.B. 'Strg+S'""" + keycode = data & 0xFF + modifier = (data >> 8) & 0xFF + mods = [name for bit, name in MODIFIER_BITS if modifier & bit] + key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}") + return "+".join(mods + [key]) if mods else key + + +def consumer_label(data): + return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}") + + +def action_label(action): + """Menschenlesbarer Text für eine DeviceAction {type, data}.""" + t = action.get("type") + d = action.get("data", 0) + if t == "None": + return "" + if t == "HidKey": + return hid_key_label(d) + if t == "HidConsumer": + return consumer_label(d) + if t == "Macro": + return f"Makro (Slot {d})" + if t == "ProfileSwitch": + if d in (0xFFFF, 0x00FF): + return "Profilwechsel → nächstes" + return f"Profilwechsel → Profil {d}" + return f"{t} ({d})" + + +def led_css(led): + return f"rgb({led['r']},{led['g']},{led['b']})" + + +def led_css_hex(led): + """Tk-Farben brauchen #RRGGBB statt rgb(...).""" + return f"#{led['r']:02x}{led['g']:02x}{led['b']:02x}" + + +def button_grid_position(index): + """index -> (col, row), Reihe 0 = oben""" + col, row = divmod(index, GRID_ROWS) + return col, row + + +def annotate_profile(cfg): + """Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus + einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt.""" + for b in cfg["buttons"]: + b["label"] = action_label(b["action"]) + b["col"], b["row"] = button_grid_position(b["index"]) + for e in cfg["encoders"]: + e["sw_label"] = action_label(e["sw"]) + e["cw_label"] = action_label(e["cw"]) + e["ccw_label"] = action_label(e["ccw"]) + return cfg + + +def load_profile(profile): + path = CONFIG_PATHS[profile] + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + return annotate_profile(cfg) + + +def load_all_profiles(): + return {p: load_profile(p) for p in CONFIG_PATHS} + + +def config_mtimes(): + """Für Auto-Reload: liefert {profile: mtime} der 3 JSON-Dateien.""" + return {p: os.path.getmtime(path) for p, path in CONFIG_PATHS.items() if os.path.exists(path)} diff --git a/versapad_protocol.py b/versapad_protocol.py new file mode 100644 index 0000000..ef1bbdc --- /dev/null +++ b/versapad_protocol.py @@ -0,0 +1,196 @@ +""" +Binaeres NVM-Layout des VersaPad-Boards -- pack/unpack fuer SDeviceConfig +(740B) und SMacroTable (512B), plus die CRC16, die die Firmware fuer +CONFIG_COMMIT/-READ prueft. + +1:1 aus den Firmware-Quellen uebernommen, nichts geraten: + - VersaMCU/src/config/nvm_config.h (SDeviceConfig/SDeviceProfile-Layout) + - VersaMCU/src/config/action.h (SAction, ActionType-Enum-Werte) + - VersaMCU/src/config/macro_config.h (SMacroTable/SMacroStep-Layout) + - VersaMCU/src/CButton.h (LEDAnim-Enum-Werte) + - VersaMCU/src/config/nvm_config.cpp (nvm_config_crc(), Validierung) + +Wer hier etwas aendert: IMMER gegen diese Dateien abgleichen, nicht raten -- +ein falsches Byte-Layout schreibt (nach CRC-Fehlschlag zum Glueck nur NACK, +kein Datenmuell aufs Board, siehe nvm_config_validate()). +""" +import struct + +NVM_CONFIG_MAGIC = 0x56503203 +NVM_CONFIG_VERSION = 3 +CONFIG_SIZE = 740 +MACRO_SIZE = 512 +MACRO_SLOTS = 32 +MACRO_MAX_STEPS = 8 + +ACTION_TYPES = ["None", "HidKey", "HidConsumer", "HostCommand", "Macro", "ProfileSwitch"] +ACTION_TYPE_TO_INT = {name: i for i, name in enumerate(ACTION_TYPES)} + +ANIM_TYPES = ["Static", "Blink", "Pulse", "FadeIn", "FadeOut", "ColorCycle", "ColorFade"] +ANIM_TYPE_TO_INT = {name: i for i, name in enumerate(ANIM_TYPES)} + +_ACTION_FMT = " EvtConfigBegin/Data/End (0x92/93/94) + CmdConfigBegin/Data/Commit(0x10/11/12) -> EvtConfigAck/Nack (0x90/91) + - Makros: CmdMacroRead(0x23) -> EvtMacroBegin/Data/End (0x96/97/98) + CmdMacroBegin/Data/Commit(0x20/21/22) -> EvtMacroAck/Nack (0x95/99) + +Board-Identifikation per VID/PID (VID_239A&PID_0042, wie in SerialManager.cs). + +Wichtig: Der COM-Port ist exklusiv -- wenn VersaGUI/Tray gerade verbunden +ist, schlaegt das Oeffnen hier fehl (kein Konflikt, einfach kein Erfolg). + +Schreiben ist sicher im Sinne von "kann das Board nicht zerlegen": die +Firmware prueft Magic/Version/CRC (Config) bzw. Keycode-Bereich (Makros) +VOR jedem NVM-Write und antwortet sonst nur mit NACK (siehe +CMainController.cpp, USB_CMD_CONFIG_COMMIT / USB_CMD_MACRO_COMMIT). +""" +import time + +try: + import serial + import serial.tools.list_ports + SERIAL_AVAILABLE = True +except ImportError: + SERIAL_AVAILABLE = False + +BAUD = 115200 +PACKET_SIZE = 8 +PAYLOAD_SIZE = 6 +CONFIG_SIZE = 740 +MACRO_SIZE = 512 +ACTIVE_PROFILE_OFFSET = 7 + +CMD_CONFIG_BEGIN = 0x10 +CMD_CONFIG_DATA = 0x11 +CMD_CONFIG_COMMIT = 0x12 +CMD_CONFIG_READ = 0x13 +CMD_MACRO_BEGIN = 0x20 +CMD_MACRO_DATA = 0x21 +CMD_MACRO_COMMIT = 0x22 +CMD_MACRO_READ = 0x23 + +EVT_CONFIG_ACK = 0x90 +EVT_CONFIG_NACK = 0x91 +EVT_CONFIG_BEGIN = 0x92 +EVT_CONFIG_DATA = 0x93 +EVT_CONFIG_END = 0x94 +EVT_MACRO_ACK = 0x95 +EVT_MACRO_BEGIN = 0x96 +EVT_MACRO_DATA = 0x97 +EVT_MACRO_END = 0x98 +EVT_MACRO_NACK = 0x99 + +VID_PID_TOKEN = "239A:0042" + + +def find_port(): + if not SERIAL_AVAILABLE: + return None + for p in serial.tools.list_ports.comports(): + if p.hwid and VID_PID_TOKEN in p.hwid.upper(): + return p.device + return None + + +class VersaPadLink: + """Haelt optional eine offene Verbindung; verbindet bei Bedarf neu.""" + + def __init__(self): + self.ser = None + self.last_error = None # None | "no_pyserial" | "not_found" | "busy" | "timeout" | "nack" + + @property + def connected(self): + return self.ser is not None and self.ser.is_open + + def _ensure_open(self): + if self.connected: + return True + if not SERIAL_AVAILABLE: + self.last_error = "no_pyserial" + return False + port = find_port() + if not port: + self.last_error = "not_found" + return False + try: + self.ser = serial.Serial(port, BAUD, timeout=0.5) + self.ser.dtr = True + time.sleep(0.2) + self.ser.reset_input_buffer() + self.last_error = None + return True + except serial.SerialException: + self.ser = None + self.last_error = "busy" + return False + + def close(self): + if self.ser is not None: + try: + self.ser.close() + except Exception: + pass + self.ser = None + + # ── Generischer Dump-Empfang (CONFIG_READ / MACRO_READ) ───────────── + + def _read_dump(self, request_cmd, begin_evt, data_evt, end_evt, size, deadline_s=3.0): + if not self._ensure_open(): + return None + try: + self.ser.reset_input_buffer() + self.ser.write(bytes([request_cmd, 0, 0, 0, 0, 0, 0, 0])) + + buf = bytearray(size) + began = False + deadline = time.time() + deadline_s + while time.time() < deadline: + raw = self.ser.read(PACKET_SIZE) + if len(raw) < PACKET_SIZE: + continue + evt, idx = raw[0], raw[1] + if evt == begin_evt: + began = True + elif evt == data_evt: + offset = idx * PAYLOAD_SIZE + if 0 <= offset < size: + n = min(PAYLOAD_SIZE, size - offset) + buf[offset:offset + n] = raw[2:2 + n] + elif evt == end_evt: + if not began: + self.last_error = "timeout" + return None + self.last_error = None + return bytes(buf) + self.last_error = "timeout" + return None + except serial.SerialException: + self.close() + self.last_error = "busy" + return None + + # ── Generisches Schreiben (CONFIG_BEGIN/DATA/COMMIT / MACRO_*) ──────── + + def _write_blob(self, begin_cmd, data_cmd, commit_cmd, ack_evt, nack_evt, blob, deadline_s=5.0): + if not self._ensure_open(): + return False + chunks = -(-len(blob) // PAYLOAD_SIZE) + if chunks > 255: + self.last_error = "too_large" + return False + try: + self.ser.reset_input_buffer() + self.ser.write(bytes([begin_cmd, chunks, 0, 0, 0, 0, 0, 0])) + for i in range(chunks): + offset = i * PAYLOAD_SIZE + chunk = blob[offset:offset + PAYLOAD_SIZE] + chunk = chunk + bytes(PAYLOAD_SIZE - len(chunk)) + self.ser.write(bytes([data_cmd, i]) + chunk) + self.ser.write(bytes([commit_cmd, 0, 0, 0, 0, 0, 0, 0])) + + deadline = time.time() + deadline_s + while time.time() < deadline: + raw = self.ser.read(PACKET_SIZE) + if len(raw) < PACKET_SIZE: + continue + evt = raw[0] + if evt == ack_evt: + self.last_error = None + return True + if evt == nack_evt: + self.last_error = "nack" + return False + self.last_error = "timeout" + return False + except serial.SerialException: + self.close() + self.last_error = "busy" + return False + + # ── Oeffentliche API ──────────────────────────────────────── + + def read_active_profile(self): + """0-2 bei Erfolg, None bei Timeout/Fehler/kein Board.""" + buf = self._read_dump(CMD_CONFIG_READ, EVT_CONFIG_BEGIN, EVT_CONFIG_DATA, EVT_CONFIG_END, CONFIG_SIZE) + if buf is None: + return None + profile = buf[ACTIVE_PROFILE_OFFSET] + if profile not in (0, 1, 2): + self.last_error = "timeout" + return None + return profile + + def read_full_config(self): + """740B Rohdaten (alle 3 Profile) oder None.""" + return self._read_dump(CMD_CONFIG_READ, EVT_CONFIG_BEGIN, EVT_CONFIG_DATA, EVT_CONFIG_END, CONFIG_SIZE) + + def write_full_config(self, blob): + """blob: 740B (siehe versapad_protocol.pack_config). True = Board hat ACK geschickt.""" + assert len(blob) == CONFIG_SIZE + return self._write_blob(CMD_CONFIG_BEGIN, CMD_CONFIG_DATA, CMD_CONFIG_COMMIT, + EVT_CONFIG_ACK, EVT_CONFIG_NACK, blob) + + def read_macros(self): + """512B Rohdaten (32 Slots) oder None.""" + return self._read_dump(CMD_MACRO_READ, EVT_MACRO_BEGIN, EVT_MACRO_DATA, EVT_MACRO_END, MACRO_SIZE) + + def write_macros(self, blob): + """blob: 512B (siehe versapad_protocol.pack_macros). True = Board hat ACK geschickt.""" + assert len(blob) == MACRO_SIZE + return self._write_blob(CMD_MACRO_BEGIN, CMD_MACRO_DATA, CMD_MACRO_COMMIT, + EVT_MACRO_ACK, EVT_MACRO_NACK, blob)