VersaGUI-py/versapad_serial.py
cjjohn 4215323f3f Use lightweight READ_STATUS instead of full CONFIG_READ for profile polling
Live-Sync polls read_active_profile() every 1.5s, but it was requesting
a full 740-byte config dump (~124 chunk packets) just to read one
byte out of it. The firmware handles CONFIG_READ synchronously and
blocking, which delayed its LED animation update enough to make
Pulse/Blink visibly stutter on every poll cycle -- see
VersaMCU's doc/07_serial_protocol.md ("READ_STATUS vs. CONFIG_READ
fuer Polling") for the root-cause writeup on the firmware side.

read_active_profile() now sends VersaMCU's new CMD_READ_STATUS (0x06)
and reads back a single EVT_STATUS (0x86) packet instead of driving
the chunked dump protocol. Requires the corresponding firmware update
(VersaMCU commit "Add lightweight READ_STATUS command..."); older
firmware without it just times out gracefully (last_error stays
"timeout", no crash).

desktop_viewer.py's SERIAL_POLL_S/SERIAL_IDLE_S already sit back at
their original 1.5s/3.0s (temporarily raised to 8s as a stopgap before
the firmware fix landed) since the expensive dump is gone now.

Verified end-to-end against a freshly flashed board: correct profile
returned, no more visible LED stutter with Live-Sync on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 15:37:40 +02:00

244 lines
8.7 KiB
Python

"""
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_READ_STATUS = 0x06
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_STATUS = 0x86
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, deadline_s=1.0):
"""0-2 bei Erfolg, None bei Timeout/Fehler/kein Board.
Nutzt CMD_READ_STATUS (1 Antwortpaket) statt eines vollen CONFIG_READ-
Dumps (124 Pakete) -- der volle Dump blockiert die Firmware lange genug,
dass laufende LED-Pulse-Animationen beim Live-Sync-Polling sichtbar
stottern (siehe VersaMCU doc/07_serial_protocol.md, "READ_STATUS vs.
CONFIG_READ fuer Polling"). Für ältere Firmware ohne CMD_READ_STATUS
faellt das Board auf keine Antwort zurueck -> Timeout, kein Absturz."""
if not self._ensure_open():
return None
try:
self.ser.reset_input_buffer()
self.ser.write(bytes([CMD_READ_STATUS, 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
if raw[0] == EVT_STATUS:
profile = raw[1]
if profile not in (0, 1, 2):
self.last_error = "timeout"
return None
self.last_error = None
return profile
self.last_error = "timeout"
return None
except serial.SerialException:
self.close()
self.last_error = "busy"
return None
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)