feat: updates to 0.8.0 - initial write functions

This commit is contained in:
2026-04-07 02:09:29 -04:00
parent c2ab94f20c
commit bcc044655a
6 changed files with 1083 additions and 21 deletions
+72
View File
@@ -45,6 +45,10 @@ from .protocol import MiniMateProtocol, ProtocolError
from .protocol import (
SUB_SERIAL_NUMBER,
SUB_FULL_CONFIG,
SUB_WRITE_CONFIRM_A,
SUB_WRITE_CONFIRM_B,
SUB_WRITE_CONFIRM_C,
SUB_TRIGGER_CONFIRM,
)
from .transport import SerialTransport, BaseTransport
@@ -527,6 +531,74 @@ class MiniMateClient:
else:
log.warning("download_waveform: waveform decode produced no samples")
# ── Write commands ────────────────────────────────────────────────────────
def push_config_raw(
self,
event_index_data: bytes,
compliance_data: bytes,
trigger_data: bytes,
waveform_data: bytes,
) -> None:
"""
Push a complete config update to the device using the confirmed write
sequence from the 3-11-26 BW TX capture.
This is the raw-bytes interface — callers supply pre-encoded payloads for
each write block. A higher-level method that encodes from ComplianceConfig
and re-reads the current payloads first can be built on top of this.
Full write sequence (confirmed from 3-11-26 BW TX capture frames 102112):
SUB 68 → event index write → ack SUB 0x97
SUB 73 → confirm B → ack SUB 0x8C
SUB 71 (×3 chunks) → compliance write → each ack SUB 0x8E
SUB 72 → confirm A → ack SUB 0x8D
SUB 82 → trigger config write → ack SUB 0x7D
SUB 83 → trigger confirm → ack SUB 0x7C
SUB 69 → waveform data write → ack SUB 0x96
SUB 74 → confirm C → ack SUB 0x8B
SUB 72 → confirm A → ack SUB 0x8D
Args:
event_index_data: Raw bytes for SUB 68 write (88-byte event index).
compliance_data: Raw bytes for SUB 71 write (≥2082 bytes, 3 chunks).
trigger_data: Raw bytes for SUB 82 write (44-byte trigger config).
waveform_data: Raw bytes for SUB 69 write.
Raises:
RuntimeError: if the client is not connected.
ProtocolError: if any write step fails (timeout, bad ack SUB).
ValueError: if compliance_data is too short for the 3-chunk split.
"""
proto = self._require_proto()
# 68 → 73
log.info("push_config_raw: write event index (SUB 68)")
proto.write_event_index(event_index_data)
log.info("push_config_raw: confirm B (SUB 73)")
proto.write_confirm(SUB_WRITE_CONFIRM_B)
# 71×3 → 72 (handled internally by write_compliance_config_raw)
log.info("push_config_raw: write compliance config (SUB 71 ×3 + confirm 72)")
proto.write_compliance_config_raw(compliance_data)
# 82 → 83
log.info("push_config_raw: write trigger config (SUB 82)")
proto.write_trigger_config(trigger_data)
log.info("push_config_raw: trigger confirm (SUB 83)")
proto.write_confirm(SUB_TRIGGER_CONFIRM)
# 69 → 74 → 72
log.info("push_config_raw: write waveform data (SUB 69)")
proto.write_waveform_data(waveform_data)
log.info("push_config_raw: confirm C (SUB 74)")
proto.write_confirm(SUB_WRITE_CONFIRM_C)
log.info("push_config_raw: confirm A (SUB 72)")
proto.write_confirm(SUB_WRITE_CONFIRM_A)
log.info("push_config_raw: complete")
# ── Internal helpers ──────────────────────────────────────────────────────
def _require_proto(self) -> MiniMateProtocol:
+103
View File
@@ -194,6 +194,109 @@ def build_bw_frame(sub: int, offset: int = 0, params: bytes = bytes(10)) -> byte
return wire
def build_bw_write_frame(
sub: int,
data: bytes,
*,
offset: int = 0,
params: bytes = bytes(10),
) -> bytes:
"""
Build a BW→S3 write-command frame.
Write frames extend the standard 16-byte read header with a variable-length
data payload. They use a different checksum formula from read frames.
**CRITICAL: Write frames use minimal DLE stuffing.**
Unlike read frames (build_bw_frame), write frames do NOT apply full DLE
stuffing to the payload. Only the BW_CMD byte (0x10) at position [0] is
doubled to 0x10 0x10 on the wire. All other bytes — flags, sub, offset,
params, data, and checksum — are written RAW with no stuffing, even if they
contain 0x10 bytes (e.g. offset_hi=0x10 for compliance chunks, or 0x10
bytes in the write data payload).
Confirmed from 3-11-26 BW TX capture (frames 102112): all 11 write frames
match the rule "double BW_CMD only; everything else raw." ✅ 2026-04-07.
Wire layout:
[41] ACK
[02] STX
[10 10] BW_CMD doubled (the ONLY DLE stuffing applied)
[00] flags
[sub] write command byte (0x680x83)
[00] always zero
[hi][lo] offset as uint16 BE (raw; NOT stuffed even if hi=0x10)
[params] 10 bytes (raw)
[data] variable-length write payload (raw; NOT stuffed)
[chk] checksum byte (raw; NOT stuffed even if 0x10)
[03] ETX
De-stuffed payload (for checksum computation):
[0] BW_CMD 0x10
[1] flags 0x00
[2] SUB write command byte
[3] 0x00 always zero
[4] offset_hi
[5] offset_lo
[6:16] params 10 bytes
[16:] data write payload
[-1] chk
**Checksum formula (confirmed 2026-03-12 from 3-11-26 BW TX capture):**
chk = (sum(b for b in payload[2:] if b != 0x10) + 0x10) % 256
where payload = destuffed content BEFORE appending chk.
This skips all 0x10 bytes in payload[2:] (sub onwards), including any
0x10 bytes in the offset, params, data, and the checksum byte itself.
The offset field [4:6] meaning per write SUB:
- SUBs 68, 69, 82 (single-chunk writes): offset = data[1] + 2, where
data[1] is an embedded length field in the write payload.
Confirmed from capture: 68→0x5A (data[1]=0x58+2), 82→0x1C
(data[1]=0x1A+2), 69→0xCA (data[1]=0xC8+2).
- SUB 71 (multi-chunk compliance): 0x1004 for full chunks, 0x002C
for the final partial chunk.
- Confirm frames (72, 73, 74, 83): offset=0, no data.
Args:
sub: Write command SUB byte.
data: Write payload (variable length; empty for confirm frames).
offset: 16-bit value placed at [4:6]. See per-SUB notes above.
params: 10 bytes placed at [6:16]. All-zero for most writes; compliance
chunk writes use chunk-specific values.
Returns:
Complete frame bytes ready to write to the transport.
"""
if len(params) != 10:
raise ValueError(f"params must be exactly 10 bytes, got {len(params)}")
if offset > 0xFFFF:
raise ValueError(f"offset must fit in uint16, got {offset:#06x}")
offset_hi = (offset >> 8) & 0xFF
offset_lo = offset & 0xFF
# Destuffed payload (used only for checksum; not sent directly)
payload_no_chk = bytes([BW_CMD, 0x00, sub, 0x00, offset_hi, offset_lo]) + params + data
# Large-frame checksum: sum payload[2:] skipping all 0x10 bytes, add 0x10.
# Applied to the destuffed representation — confirms correctly against
# all 11 write frames in the 3-11-26/170151 BW TX capture. ✅
chk = (sum(b for b in payload_no_chk[2:] if b != 0x10) + 0x10) & 0xFF
# Wire construction: only BW_CMD is doubled; everything else is raw.
# Do NOT use dle_stuff() here — that would incorrectly double 0x10 bytes
# in the offset, params, and data sections.
wire = (
bytes([ACK, STX]) # Frame prefix (not part of payload)
+ bytes([BW_CMD, BW_CMD]) # BW_CMD doubled (only DLE stuffing applied)
+ payload_no_chk[1:] # flags, sub, offset, params, data — RAW
+ bytes([chk]) # checksum — RAW
+ bytes([ETX]) # Frame terminator
)
return wire
def waveform_key_params(key4: bytes) -> bytes:
"""
Build the 10-byte params block that carries a 4-byte waveform key.
+277 -4
View File
@@ -30,6 +30,7 @@ from .framing import (
S3FrameParser,
build_bw_frame,
build_5a_frame,
build_bw_write_frame,
waveform_key_params,
token_params,
bulk_waveform_params,
@@ -65,6 +66,17 @@ SUB_BULK_WAVEFORM = 0x5A
SUB_COMPLIANCE = 0x1A
SUB_UNKNOWN_2E = 0x2E
# Write command SUBs (= Read SUB + 0x60, confirmed from BW captures 3-11-26)
# Response SUB follows the standard 0xFF - Request SUB rule.
SUB_EVENT_INDEX_WRITE = 0x68 # Write event index (0x08 + 0x60) ✅
SUB_WAVEFORM_DATA_WRITE = 0x69 # Write waveform data (0x09 + 0x60) ✅
SUB_COMPLIANCE_WRITE = 0x71 # Write compliance cfg (0x11 + 0x60) ✅
SUB_WRITE_CONFIRM_A = 0x72 # Confirm A — sent after 71×3 and other writes ✅
SUB_WRITE_CONFIRM_B = 0x73 # Confirm B — sent after 68 ✅
SUB_WRITE_CONFIRM_C = 0x74 # Confirm C — sent after 69 ✅
SUB_TRIGGER_CONFIG_WRITE = 0x82 # Write trigger config (0x22 + 0x60) ✅
SUB_TRIGGER_CONFIRM = 0x83 # Confirm trigger write ✅
# Hardcoded data lengths for the two-step read protocol.
#
# The S3 probe response page_key is always 0x0000 — it does NOT carry the
@@ -95,10 +107,11 @@ DATA_LENGTHS: dict[int, int] = {
# Confirmed from 1-2-26 BW TX capture analysis (2026-04-02).
_BULK_CHUNK_OFFSET = 0x1004 # offset field for probe + all regular chunk requests ✅
_BULK_TERM_OFFSET = 0x005A # offset field for termination request ✅
_BULK_COUNTER_STEP = 0x0400 # chunk counter increment for chunks 2+
# Chunk 1 counter is 0x1004 (NOT 1 * 0x0400 = 0x0400). Confirmed from 4-2-26 BW TX
# capture. Chunks 2+ use n * 0x0400 (0x0800, 0x0C00, …). Device silently ignores
# frames with wrong counter — this was the root cause of the full-waveform timeout.
_BULK_COUNTER_STEP = 0x0400 # chunk counter increment per chunk ✅
# Chunk counter formula: chunk_num * 0x0400 for ALL chunks including chunk 1.
# Earlier captures showed 0x1004 for chunk 1 — that was a Blastware artifact, not a
# protocol requirement. Confirmed 2026-04-06: 0x0400 for chunk 1 works; 0x1004
# causes a 120-second device timeout. Formula n * 0x0400 is used for all chunks.
# Default timeout values (seconds).
# MiniMate Plus is a slow device — keep these generous.
@@ -749,6 +762,266 @@ class MiniMateProtocol:
return bytes(config)
# ── Write commands (SUBs 6883) ───────────────────────────────────────────
def recv_write_ack(
self,
expected_sub: int,
timeout: Optional[float] = None,
) -> S3Frame:
"""
Wait for a write-ack S3 frame.
All write ack responses are 17-byte frames (11-byte header + no data +
1 checksum byte) with SUB = 0xFF - request_SUB. The page_key and data
section carry zeros. Confirmed from 3-11-26 BW capture.
Args:
expected_sub: Expected response SUB byte (0xFF - write_request_SUB).
timeout: Seconds to wait; defaults to self._recv_timeout.
Returns:
The ack S3Frame.
Raises:
TimeoutError: if no frame arrives in time.
UnexpectedResponse: if the response SUB doesn't match.
"""
log.debug("recv_write_ack: waiting for SUB=0x%02X", expected_sub)
ack = self._recv_one(expected_sub=expected_sub, timeout=timeout)
log.debug(
"recv_write_ack: received SUB=0x%02X page=0x%04X data=%d bytes",
ack.sub, ack.page_key, len(ack.data),
)
return ack
def write_confirm(self, sub: int) -> S3Frame:
"""
Send a zero-data confirm frame and wait for the ack.
Confirm frames (SUBs 72, 73, 74, 83) carry no write data — they are
16-byte header-only frames (offset=0, params=zeros, data=b"") with the
DLE-aware large-frame checksum. The device acks with the complementary
RSP_SUB.
Args:
sub: Confirm SUB byte (SUB_WRITE_CONFIRM_A/B/C or SUB_TRIGGER_CONFIRM).
Returns:
The ack S3Frame.
Raises:
ProtocolError: on timeout or wrong response SUB.
"""
rsp_sub = _expected_rsp_sub(sub)
frame = build_bw_write_frame(sub, b"")
log.debug("write_confirm: SUB=0x%02X frame=%s", sub, frame.hex())
self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub)
def write_event_index(self, data: bytes) -> S3Frame:
"""
Send a SUB 68 (EVENT_INDEX_WRITE) frame and await the confirm ack (SUB 97).
Offset formula: data[1] + 2 — confirmed from 3-11-26 BW TX capture frame 102.
The write payload has a 2-byte header [0x00][length] where data[1] encodes
the length of the meaningful payload; offset = data[1] + 2.
Example from capture:
data[0:4] = 00 58 09 00 (data[1]=0x58=88 → offset=0x5A=90)
data length = 91, offset = 90
Write sequence fragment:
68 (data) → device acks with SUB 0x97
73 (confirm) → device acks with SUB 0x8C
Callers should call write_confirm(SUB_WRITE_CONFIRM_B) after this.
Args:
data: Raw event-index payload bytes to write to the device.
Must be at least 2 bytes. data[1] must contain the length field.
Returns:
The SUB 0x97 ack frame.
Raises:
ProtocolError: on timeout or wrong response SUB.
ValueError: if data is shorter than 2 bytes.
"""
if len(data) < 2:
raise ValueError(f"event index write data must be at least 2 bytes, got {len(data)}")
rsp_sub = _expected_rsp_sub(SUB_EVENT_INDEX_WRITE) # 0xFF - 0x68 = 0x97
offset = data[1] + 2
frame = build_bw_write_frame(SUB_EVENT_INDEX_WRITE, data, offset=offset)
log.debug(
"write_event_index: %d bytes data[1]=0x%02X offset=0x%04X rsp_sub=0x%02X",
len(data), data[1], offset, rsp_sub,
)
self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub)
def write_waveform_data(self, data: bytes) -> S3Frame:
"""
Send a SUB 69 (WAVEFORM_DATA_WRITE) frame and await the confirm ack (SUB 96).
Offset formula: data[1] + 2 — same pattern as write_event_index().
Confirmed from 3-11-26 BW TX capture frame 110:
data[0:4] = 00 c8 08 00 (data[1]=0xC8=200 → offset=0xCA=202)
data length = 204, offset = 202
Write sequence fragment:
69 (data) → device acks with SUB 0x96
74 (confirm) → device acks with SUB 0x8B
72 (confirm) → device acks with SUB 0x8D
Callers should call write_confirm(SUB_WRITE_CONFIRM_C) then
write_confirm(SUB_WRITE_CONFIRM_A) after this.
Args:
data: Raw waveform-data payload bytes to write.
Must be at least 2 bytes. data[1] must contain the length field.
Returns:
The SUB 0x96 ack frame.
Raises:
ProtocolError: on timeout or wrong response SUB.
ValueError: if data is shorter than 2 bytes.
"""
if len(data) < 2:
raise ValueError(f"waveform data write payload must be at least 2 bytes, got {len(data)}")
rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_DATA_WRITE) # 0xFF - 0x69 = 0x96
offset = data[1] + 2
frame = build_bw_write_frame(SUB_WAVEFORM_DATA_WRITE, data, offset=offset)
log.debug(
"write_waveform_data: %d bytes data[1]=0x%02X offset=0x%04X rsp_sub=0x%02X",
len(data), data[1], offset, rsp_sub,
)
self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub)
def write_compliance_config_raw(self, data: bytes) -> None:
"""
Send the SUB 71 (COMPLIANCE_WRITE) 3-chunk sequence and final confirm.
The full compliance config payload (~2128 bytes) is split into exactly 3
chunks with hardcoded boundaries and params confirmed from the 3-11-26 BW
TX capture (frames 104108):
Chunk 1 — first 1027 bytes:
offset=0x1004 params=bytes(10)
device acks SUB 0x8E
Chunk 2 — next 1055 bytes:
offset=0x1004 params=b'\\x00\\x00\\x00\\x00\\x10\\x04' + b'\\x00'*4
device acks SUB 0x8E
Chunk 3 — remaining bytes:
offset=0x002C params=b'\\x00\\x00\\x08' + b'\\x00'*7
device acks SUB 0x8E
Confirm — SUB 72 (zero data):
device acks SUB 0x8D
The total write payload should be at least 1027+1055=2082 bytes; chunk 3
carries everything after offset 2082 (typically ~46 bytes for a 2128-byte
config).
Args:
data: Raw compliance config bytes to write. Must be at least 2082 bytes.
Raises:
ValueError: if data is too short to fill chunks 1 and 2.
ProtocolError: on timeout or wrong response SUB from any chunk.
"""
_CHUNK1_SIZE = 1027
_CHUNK2_SIZE = 1055
_CHUNK1_OFFSET = 0x1004
_CHUNK2_OFFSET = 0x1004
_CHUNK3_OFFSET = 0x002C
_CHUNK1_PARAMS = bytes(10)
_CHUNK2_PARAMS = bytes([0x00, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])
_CHUNK3_PARAMS = bytes([0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
min_size = _CHUNK1_SIZE + _CHUNK2_SIZE
if len(data) < min_size:
raise ValueError(
f"Compliance write data too short: {len(data)} bytes, "
f"need at least {min_size} (chunk1={_CHUNK1_SIZE} + chunk2={_CHUNK2_SIZE})"
)
rsp_sub = _expected_rsp_sub(SUB_COMPLIANCE_WRITE) # 0xFF - 0x71 = 0x8E
chunk1 = data[:_CHUNK1_SIZE]
chunk2 = data[_CHUNK1_SIZE : _CHUNK1_SIZE + _CHUNK2_SIZE]
chunk3 = data[_CHUNK1_SIZE + _CHUNK2_SIZE :]
chunks = [
(1, chunk1, _CHUNK1_OFFSET, _CHUNK1_PARAMS),
(2, chunk2, _CHUNK2_OFFSET, _CHUNK2_PARAMS),
(3, chunk3, _CHUNK3_OFFSET, _CHUNK3_PARAMS),
]
for chunk_num, chunk_data, chunk_offset, chunk_params in chunks:
frame = build_bw_write_frame(
SUB_COMPLIANCE_WRITE,
chunk_data,
offset=chunk_offset,
params=chunk_params,
)
log.debug(
"write_compliance_config_raw: chunk %d %d bytes "
"offset=0x%04X params=%s",
chunk_num, len(chunk_data), chunk_offset, chunk_params.hex(),
)
self._send(frame)
self.recv_write_ack(expected_sub=rsp_sub)
log.debug("write_compliance_config_raw: chunk %d acked", chunk_num)
# Final confirm (SUB 72)
log.debug("write_compliance_config_raw: sending confirm (SUB 0x72)")
self.write_confirm(SUB_WRITE_CONFIRM_A)
log.debug("write_compliance_config_raw: done")
def write_trigger_config(self, data: bytes) -> S3Frame:
"""
Send a SUB 82 (TRIGGER_CONFIG_WRITE) frame and await the confirm ack (SUB 7D).
Offset formula: data[1] + 2 — same pattern as write_event_index().
Confirmed from 3-11-26 BW TX capture frame 108:
data[0:4] = 00 1a d5 00 (data[1]=0x1A=26 → offset=0x1C=28)
data length = 29, offset = 28
Write sequence fragment:
82 (data) → device acks with SUB 0x7D
83 (confirm) → device acks with SUB 0x7C
Callers should call write_confirm(SUB_TRIGGER_CONFIRM) after this.
Args:
data: Raw trigger-config payload bytes to write.
Must be at least 2 bytes. data[1] must contain the length field.
Returns:
The SUB 0x7D ack frame.
Raises:
ProtocolError: on timeout or wrong response SUB.
ValueError: if data is shorter than 2 bytes.
"""
if len(data) < 2:
raise ValueError(f"trigger config write payload must be at least 2 bytes, got {len(data)}")
rsp_sub = _expected_rsp_sub(SUB_TRIGGER_CONFIG_WRITE) # 0xFF - 0x82 = 0x7D
offset = data[1] + 2
frame = build_bw_write_frame(SUB_TRIGGER_CONFIG_WRITE, data, offset=offset)
log.debug(
"write_trigger_config: %d bytes data[1]=0x%02X offset=0x%04X rsp_sub=0x%02X",
len(data), data[1], offset, rsp_sub,
)
self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub)
# ── Internal helpers ──────────────────────────────────────────────────────
def _send(self, frame: bytes) -> None: