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>
244 lines
8.7 KiB
Python
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)
|