Initial commit: VersaPad viewer + programming mode
This commit is contained in:
parent
ef0faec100
commit
6227903ebd
10 changed files with 1765 additions and 0 deletions
127
AGENTS.md
Normal file
127
AGENTS.md
Normal 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
329
action_dialog.py
Normal 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
448
desktop_viewer.py
Normal 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
3
run_browser.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
py server.py
|
||||
3
run_desktop.bat
Normal file
3
run_desktop.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
start "" pyw desktop_viewer.py
|
||||
157
server.py
Normal file
157
server.py
Normal 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
94
versapad_combined.py
Normal 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
189
versapad_data.py
Normal 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
196
versapad_protocol.py
Normal 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
219
versapad_serial.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue