From e712d68505521816c5fe5aa9bb90063e7e339afa Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Sat, 11 Apr 2026 01:14:37 -0400 Subject: [PATCH] feat: add erase-all protocol and browse helpers to protocol/client layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protocol.py: - SUB_ERASE_ALL_BEGIN = 0xA3, SUB_ERASE_ALL_CONFIRM = 0xA2 (confirmed 4-11-26 MITM) - SUB_CHANNEL_CONFIG (0x06) data length = 0x24 (36 bytes) in DATA_LENGTHS - begin_erase_all() — single frame, token=0xFE, response 0x5C - confirm_erase_all() — single frame, token=0xFE, response 0x5D - read_event_storage_range() — two-step read (probe+data), token=0xFE Response last 8 bytes = first/last stored event key; both 0x01110000 after erase client.py: - list_event_keys() — browse-mode 1E→0A→1F walk, no waveform download; returns list of hex key strings; used as fast pre-check before get_events() - get_events(skip_waveform_for_keys=set()) — for already-seen keys: only 0A+1F(browse), skips 1E-arm/0C/POLL×3/5A entirely - delete_all_events() — orchestrates the confirmed erase sequence: 0xA3 → 0x1C → 0x06 → 0xA2; logs first/last key from storage range response Co-Authored-By: Claude Sonnet 4.6 --- minimateplus/client.py | 121 ++++++++++++++++++++++++++++++++++++++- minimateplus/protocol.py | 81 +++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 2 deletions(-) diff --git a/minimateplus/client.py b/minimateplus/client.py index 47d4a20..022b0cc 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -252,7 +252,98 @@ class MiniMateClient: log.info("count_events: %d event(s) found via 1E/1F chain", count) return count - def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None) -> list[Event]: + def list_event_keys(self) -> list[str]: + """ + Return the hex key strings for all stored events without downloading + any waveform data. Uses the same browse-mode 1E -> 0A -> 1F walk as + count_events() but collects the key at each step. + + Returns: + List of 8-char lowercase hex strings, e.g. ["01110000", "0111245a"]. + Empty list if device has no stored events or 1E fails. + """ + proto = self._require_proto() + try: + key4, data8 = proto.read_event_first() + except ProtocolError as exc: + log.warning("list_event_keys: 1E failed: %s -- returning []", exc) + return [] + + if data8[4:8] == b"\x00\x00\x00\x00": + log.info("list_event_keys: device is empty") + return [] + + keys: list[str] = [] + while data8[4:8] != b"\x00\x00\x00\x00": + keys.append(key4.hex()) + try: + proto.read_waveform_header(key4) + except ProtocolError as exc: + log.warning( + "list_event_keys: 0A failed for key=%s: %s -- stopping", + key4.hex(), exc, + ) + break + try: + key4, data8 = proto.advance_event(browse=True) + log.debug( + "list_event_keys: 1F -> key=%s trailing=%s", + key4.hex(), data8[4:8].hex(), + ) + except ProtocolError as exc: + log.warning( + "list_event_keys: 1F failed after %d event(s): %s -- stopping", + len(keys), exc, + ) + break + + log.info("list_event_keys: %d key(s): %s", len(keys), keys) + return keys + + def delete_all_events(self) -> None: + """ + Erase all stored events from the device memory. + + This performs the complete erase sequence confirmed from the 4-11-26 + MITM capture of a Blastware ACH session: + + 1. SUB 0xA3 (begin_erase_all) — initiate erase, token=0xFE + 2. SUB 0x1C (read_monitor_status) — status read between erase commands + 3. SUB 0x06 (read_event_storage_range) — verify storage state, token=0xFE + 4. SUB 0xA2 (confirm_erase_all) — commit erase, token=0xFE + + After this call the device's event memory is empty. The unit returns to + its normal operating state automatically (no restart-monitoring call needed). + + Raises: + ProtocolError: on timeout or unexpected device response. + """ + proto = self._require_proto() + + log.info("delete_all_events: step 1/4 — begin erase (SUB 0xA3)") + proto.begin_erase_all() + log.debug("delete_all_events: 0xA3 ack received") + + log.info("delete_all_events: step 2/4 — monitor status read (SUB 0x1C)") + proto.read_monitor_status() + log.debug("delete_all_events: 0x1C read complete") + + log.info("delete_all_events: step 3/4 — event storage range read (SUB 0x06)") + rng = proto.read_event_storage_range() + if len(rng.data) >= 8: + first_key = rng.data[-8:-4].hex() + last_key = rng.data[-4:].hex() + log.info( + "delete_all_events: storage range — first=%s last=%s", + first_key, last_key, + ) + log.debug("delete_all_events: 0x06 read complete") + + log.info("delete_all_events: step 4/4 — confirm erase (SUB 0xA2)") + proto.confirm_erase_all() + log.info("delete_all_events: erase confirmed — device memory cleared") + + def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None, skip_waveform_for_keys: Optional[set] = None) -> list[Event]: """ Download all stored events from the device using the confirmed 1E → 0A → 0C → 5A → 1F event-iterator protocol. @@ -303,6 +394,34 @@ class MiniMateClient: while data8[4:8] != b"\x00\x00\x00\x00": cur_key = key4 # key for this event's 0A/1E-arm/0C/5A calls log.info("get_events: record %d key=%s", idx, cur_key.hex()) + + # Fast-advance path: if this key is already downloaded, skip + # 1E-arm/0C/POLL/5A entirely. Only 0A + 1F(browse) are needed + # to advance the device's internal pointer to the next event. + # This is identical to the browse-mode walk in count_events(). + if skip_waveform_for_keys and cur_key.hex() in skip_waveform_for_keys: + log.debug("get_events: key=%s already seen -- fast-advance only", cur_key.hex()) + try: + proto.read_waveform_header(cur_key) + except ProtocolError as exc: + log.warning( + "get_events: 0A failed for key=%s (skip path): %s -- stopping", + cur_key.hex(), exc, + ) + break + try: + key4, data8 = proto.advance_event(browse=True) + except ProtocolError as exc: + log.warning( + "get_events: 1F failed for key=%s (skip path): %s -- stopping", + cur_key.hex(), exc, + ) + break + idx += 1 + if stop_after_index is not None and idx > stop_after_index: + break + continue + ev = Event(index=idx) ev._waveform_key = cur_key diff --git a/minimateplus/protocol.py b/minimateplus/protocol.py index 04b77d8..83afa6e 100644 --- a/minimateplus/protocol.py +++ b/minimateplus/protocol.py @@ -57,7 +57,7 @@ SUB_POLL = 0x5B SUB_SERIAL_NUMBER = 0x15 SUB_FULL_CONFIG = 0x01 SUB_EVENT_INDEX = 0x08 -SUB_CHANNEL_CONFIG = 0x06 +SUB_CHANNEL_CONFIG = 0x06 # Event storage range read (first/last key) ✅ SUB_MONITOR_STATUS = 0x1C # Monitoring status read (battery, memory, mode) ✅ SUB_EVENT_HEADER = 0x1E SUB_EVENT_ADVANCE = 0x1F @@ -82,6 +82,12 @@ SUB_TRIGGER_CONFIRM = 0x83 # Confirm trigger write ✅ SUB_START_MONITORING = 0x96 # Start monitoring → response 0x69 ✅ SUB_STOP_MONITORING = 0x97 # Stop monitoring → response 0x68 ✅ +# Erase-all SUBs (confirmed from 4-11-26 MITM capture) +# Both use token=0xFE at params[7] and return minimal 11-byte acks. +# Standard response formula applies: 0xFF - SUB. +SUB_ERASE_ALL_BEGIN = 0xA3 # Begin erase all events → response 0x5C ✅ +SUB_ERASE_ALL_CONFIRM = 0xA2 # Confirm erase all events → response 0x5D ✅ + # Hardcoded data lengths for the two-step read protocol. # # The S3 probe response page_key is always 0x0000 — it does NOT carry the @@ -96,6 +102,7 @@ DATA_LENGTHS: dict[int, int] = { SUB_SERIAL_NUMBER: 0x0A, # 10-byte serial number block ✅ SUB_FULL_CONFIG: 0x98, # 152-byte full config block ✅ SUB_EVENT_INDEX: 0x58, # 88-byte event index ✅ + SUB_CHANNEL_CONFIG: 0x24, # 36-byte event storage range (first/last key) ✅ SUB_MONITOR_STATUS: 0x2C, # 44-byte monitor status block (idle) ✅ SUB_EVENT_HEADER: 0x08, # 8-byte event header (waveform key + event data) ✅ SUB_EVENT_ADVANCE: 0x08, # 8-byte next-key response ✅ @@ -1137,6 +1144,78 @@ class MiniMateProtocol: self._send(frame) return self.recv_write_ack(expected_sub=rsp_sub) + def read_event_storage_range(self) -> S3Frame: + """ + Read event storage range (SUB 0x06 → response 0xF9). + + Two-step read: probe (offset=0x00) then data (offset=0x24 = 36 bytes). + Uses token=0xFE at params[7] — same as the erase sequence. + + The 36-byte response ends with two 4-byte event keys (first and last + stored event key). After a successful erase, both keys are 0x01110000 + (device-empty sentinel). Confirmed from 4-11-26 MITM capture. + + Returns: + S3Frame with 36 bytes of storage range data. + + Raises: + ProtocolError: on timeout or wrong response SUB. + """ + rsp_sub = _expected_rsp_sub(SUB_CHANNEL_CONFIG) # 0xFF - 0x06 = 0xF9 + params = token_params(0xFE) + log.debug("read_event_storage_range: probe step rsp_sub=0x%02X", rsp_sub) + self._send(build_bw_frame(SUB_CHANNEL_CONFIG, offset=0x00, params=params)) + self._recv_one(expected_sub=rsp_sub) + + log.debug( + "read_event_storage_range: data step offset=0x%02X", + DATA_LENGTHS[SUB_CHANNEL_CONFIG], + ) + self._send(build_bw_frame(SUB_CHANNEL_CONFIG, + offset=DATA_LENGTHS[SUB_CHANNEL_CONFIG], + params=params)) + return self._recv_one(expected_sub=rsp_sub) + + def begin_erase_all(self) -> S3Frame: + """ + Send Begin-Erase-All command (SUB 0xA3 → response 0x5C). + + Single frame with token=0xFE at params[7]. The device acknowledges with + a minimal ack and begins the erase process. Follow up with + read_monitor_status() + read_event_storage_range() + confirm_erase_all() + to complete the sequence. Confirmed from 4-11-26 MITM capture. + + Returns: + S3Frame ack from device (SUB 0x5C). + + Raises: + ProtocolError: on timeout or wrong response SUB. + """ + rsp_sub = _expected_rsp_sub(SUB_ERASE_ALL_BEGIN) # 0xFF - 0xA3 = 0x5C + log.debug("begin_erase_all: rsp_sub=0x%02X", rsp_sub) + self._send(build_bw_frame(SUB_ERASE_ALL_BEGIN, params=token_params(0xFE))) + return self._recv_one(expected_sub=rsp_sub) + + def confirm_erase_all(self) -> S3Frame: + """ + Send Confirm-Erase-All command (SUB 0xA2 → response 0x5D). + + Single frame with token=0xFE at params[7]. Must be preceded by + begin_erase_all() + read_monitor_status() + read_event_storage_range(). + After this call the device memory is cleared. Confirmed from 4-11-26 + MITM capture. + + Returns: + S3Frame ack from device (SUB 0x5D). + + Raises: + ProtocolError: on timeout or wrong response SUB. + """ + rsp_sub = _expected_rsp_sub(SUB_ERASE_ALL_CONFIRM) # 0xFF - 0xA2 = 0x5D + log.debug("confirm_erase_all: rsp_sub=0x%02X", rsp_sub) + self._send(build_bw_frame(SUB_ERASE_ALL_CONFIRM, params=token_params(0xFE))) + return self._recv_one(expected_sub=rsp_sub) + # ── Internal helpers ────────────────────────────────────────────────────── def _send(self, frame: bytes) -> None: