Initial commit: VersaPad viewer + programming mode

This commit is contained in:
cjjohn 2026-08-04 20:03:49 +02:00
parent ef0faec100
commit 6227903ebd
10 changed files with 1765 additions and 0 deletions

127
AGENTS.md Normal file
View file

@ -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.

329
action_dialog.py Normal file
View file

@ -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)

448
desktop_viewer.py Normal file
View file

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

3
run_browser.bat Normal file
View file

@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
py server.py

3
run_desktop.bat Normal file
View file

@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
start "" pyw desktop_viewer.py

157
server.py Normal file
View file

@ -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"""<div class="{css_class}" style="{style}">
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
<div class="idx">#{btn['index']}</div>
<div class="label">{label}</div>
<div class="anim">{"" if empty else anim}</div>
</div>"""
def render_encoder(enc):
return f"""<div class="enc">
<div class="idx">Encoder {enc['index']}</div>
<div class="row"><span class="k">Druck</span><span class="v">{html.escape(enc['sw_label']) or ''}</span></div>
<div class="row"><span class="k">CW</span><span class="v">{html.escape(enc['cw_label']) or ''}</span></div>
<div class="row"><span class="k">CCW</span><span class="v">{html.escape(enc['ccw_label']) or ''}</span></div>
</div>"""
def render_page(profile):
cfg = vp.load_profile(profile)
tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(vp.PROFILE_NAMES[p])}</a>'
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"""<!doctype html>
<html><head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="4">
<title>VersaPad Steuermatrix</title>
<style>{PAGE_CSS}</style>
</head><body>
<h1>VersaPad Steuermatrix</h1>
<div class="sub">Live-Ansicht der Config-JSONs · aktualisiert alle 4s</div>
<div class="tabs">{tabs}</div>
<div class="grid">{cells}</div>
<h2>Encoder</h2>
<div class="encoders">{encoders}</div>
<footer>Quelle: {html.escape(vp.CONFIG_PATHS[profile])}</footer>
</body></html>"""
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()

94
versapad_combined.py Normal file
View file

@ -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)

189
versapad_data.py Normal file
View file

@ -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)}

196
versapad_protocol.py Normal file
View file

@ -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 = "<BH" # type(1B) + data(2B, little-endian) = 3B
def _pack_action(action):
return struct.pack(_ACTION_FMT, ACTION_TYPE_TO_INT[action["type"]], action["data"] & 0xFFFF)
def _unpack_action(buf, offset):
t, data = struct.unpack_from(_ACTION_FMT, buf, offset)
return {"type": ACTION_TYPES[t] if t < len(ACTION_TYPES) else f"Unknown{t}", "data": data}, offset + 3
def crc16(buf):
"""CRC16-CCITT (Poly 0x1021, Init 0xFFFF, MSB-first, kein XOR-Out) --
exakt nvm_config_crc() aus nvm_config.cpp."""
crc = 0xFFFF
for byte in buf:
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) if (crc & 0x8000) else (crc << 1)
crc &= 0xFFFF
return crc
# ── Profil (236B) ────────────────────────────────────────────────────────────────
def pack_profile(profile):
"""profile: {"buttons": [20x {index,action,led}], "encoders": [4x {index,sw,cw,ccw}]}"""
buttons_by_index = {b["index"]: b for b in profile["buttons"]}
encoders_by_index = {e["index"]: e for e in profile["encoders"]}
mx_actions = b"".join(_pack_action(buttons_by_index[i]["action"]) for i in range(20))
enc_actions = b""
for i in range(4):
enc = encoders_by_index[i]
enc_actions += _pack_action(enc["sw"]) + _pack_action(enc["cw"]) + _pack_action(enc["ccw"])
leds = [buttons_by_index[i]["led"] for i in range(20)]
led_r = bytes(led["r"] for led in leds)
led_g = bytes(led["g"] for led in leds)
led_b = bytes(led["b"] for led in leds)
led_bri = bytes(led["brightness"] for led in leds)
led_anim = bytes(ANIM_TYPE_TO_INT[led["anim"]] for led in leds)
led_period = b"".join(struct.pack("<H", led["period_ms"]) for led in leds)
blob = mx_actions + enc_actions + led_r + led_g + led_b + led_bri + led_anim + led_period
assert len(blob) == 236, f"Profil-Blob {len(blob)}B != 236B"
return blob
def unpack_profile(buf, offset):
start = offset
buttons = []
for i in range(20):
action, offset = _unpack_action(buf, offset)
buttons.append({"index": i, "action": action})
encoders = []
for i in range(4):
sw, offset = _unpack_action(buf, offset)
cw, offset = _unpack_action(buf, offset)
ccw, offset = _unpack_action(buf, offset)
encoders.append({"index": i, "sw": sw, "cw": cw, "ccw": ccw})
led_r = buf[offset:offset + 20]; offset += 20
led_g = buf[offset:offset + 20]; offset += 20
led_b = buf[offset:offset + 20]; offset += 20
led_bri = buf[offset:offset + 20]; offset += 20
led_anim = buf[offset:offset + 20]; offset += 20
led_period = struct.unpack_from("<20H", buf, offset); offset += 40
for i in range(20):
buttons[i]["led"] = {
"r": led_r[i], "g": led_g[i], "b": led_b[i], "brightness": led_bri[i],
"anim": ANIM_TYPES[led_anim[i]] if led_anim[i] < len(ANIM_TYPES) else "Static",
"period_ms": led_period[i],
}
assert offset - start == 236, f"Profil-Unpack {offset - start}B != 236B"
return {"buttons": buttons, "encoders": encoders}, offset
# ── Gesamt-Config (740B) ───────────────────────────────────────────────
def pack_config(cfg):
"""cfg: {"active_profile", "global_brightness", "enc_sensitivity"[4], "profiles"[3]}
Berechnet die CRC selbst -- ignoriert einen evtl. vorhandenen cfg["crc"]."""
header_tail = struct.pack(
"<BB4B19x",
cfg["active_profile"] & 0xFF,
cfg["global_brightness"] & 0xFF,
*[s & 0xFF for s in cfg["enc_sensitivity"]],
)
profiles_blob = b"".join(pack_profile(p) for p in cfg["profiles"])
tail = header_tail + profiles_blob
assert len(tail) == CONFIG_SIZE - 7, f"Config-Tail {len(tail)}B != {CONFIG_SIZE - 7}B"
crc = crc16(tail)
head = struct.pack("<IBH", NVM_CONFIG_MAGIC, NVM_CONFIG_VERSION, crc)
blob = head + tail
assert len(blob) == CONFIG_SIZE, f"Config-Blob {len(blob)}B != {CONFIG_SIZE}B"
return blob
def unpack_config(buf):
if len(buf) != CONFIG_SIZE:
raise ValueError(f"Config-Blob hat {len(buf)}B, erwartet {CONFIG_SIZE}B")
magic, version, crc, active_profile, global_brightness = struct.unpack_from("<IBHBB", buf, 0)
enc_sensitivity = list(struct.unpack_from("<4B", buf, 9))
offset = 32
profiles = []
for _ in range(3):
profile, offset = unpack_profile(buf, offset)
profiles.append(profile)
computed_crc = crc16(buf[7:])
return {
"magic": magic,
"magic_ok": magic == NVM_CONFIG_MAGIC,
"version": version,
"crc": crc,
"crc_ok": crc == computed_crc,
"active_profile": active_profile,
"global_brightness": global_brightness,
"enc_sensitivity": enc_sensitivity,
"profiles": profiles,
}
# ── Makro-Tabelle (512B) ─────────────────────────────────────────────────────
def pack_macros(slots):
"""slots: Liste von 32 Listen mit bis zu 8 {"keycode","modifier"}-Dicts
(fehlende/kuerzere werden mit keycode=0/modifier=0 aufgefuellt)."""
assert len(slots) == MACRO_SLOTS, f"{len(slots)} Makro-Slots != {MACRO_SLOTS}"
out = bytearray(MACRO_SIZE)
for slot_idx, steps in enumerate(slots):
for step_idx in range(MACRO_MAX_STEPS):
keycode, modifier = 0, 0
if step_idx < len(steps) and steps[step_idx]:
keycode = steps[step_idx].get("keycode", 0) & 0xFF
modifier = steps[step_idx].get("modifier", 0) & 0xFF
offset = (slot_idx * MACRO_MAX_STEPS + step_idx) * 2
out[offset] = keycode
out[offset + 1] = modifier
return bytes(out)
def unpack_macros(buf):
if len(buf) != MACRO_SIZE:
raise ValueError(f"Makro-Blob hat {len(buf)}B, erwartet {MACRO_SIZE}B")
slots = []
for slot_idx in range(MACRO_SLOTS):
steps = []
for step_idx in range(MACRO_MAX_STEPS):
offset = (slot_idx * MACRO_MAX_STEPS + step_idx) * 2
keycode, modifier = buf[offset], buf[offset + 1]
if keycode == 0:
break # keycode=0 beendet die Sequenz (Firmware-Konvention)
steps.append({"keycode": keycode, "modifier": modifier})
slots.append(steps)
return slots

219
versapad_serial.py Normal file
View file

@ -0,0 +1,219 @@
"""
Serial-Client fuer das VersaPad-Board -- liest/schreibt die komplette
Config (740B, alle 3 Profile) und die Makro-Tabelle (512B, 32 Slots) per
CDC-USB-Protokoll.
Protokoll 1:1 aus VersaGUI/src/Protocol.cs und VersaMCU/doc/07_serial_protocol.md
uebernommen, nicht neu geraten:
- 8-Byte-Pakete: [0]=Cmd/Evt, [1]=key_id/chunk-index/-count, [2..7]=Payload
- Config: CmdConfigRead(0x13) -> 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)