11 Commits

Author SHA1 Message Date
claude b9a8e50b3c docs: update protocol reference with v0.9.0 erase-all protocol
Changelog section:
- 5 new entries (2026-04-11): erase-all confirmation, SUB 0x06 purpose
  resolved, §7.11 added, §14.6 ACH session lifecycle marked IMPLEMENTED

§5.1 Request Commands:
- SUB 0x06 description updated: "EVENT STORAGE RANGE READ" (not "CHANNEL
  CONFIG READ"), token=0xFE, last 8 bytes = first/last stored event keys
- SUB 0xA3 added: ERASE ALL BEGIN — standard build_bw_frame, token=0xFE, ack 0x5C
- SUB 0xA2 added: ERASE ALL CONFIRM — standard build_bw_frame, token=0xFE, ack 0x5D

§5.2 Response SUBs:
- 0x06→0xF9 marked CONFIRMED 2026-04-11
- 0xA3→0x5C and 0xA2→0x5D added with CONFIRMED status

§7.11 (new section): Erase-All Protocol
- Full wire sequence (6 request/response pairs)
- SUB 0x06 storage range payload layout (36 bytes, last 8 = first/last key)
- Post-erase key counter reset: device restarts from 0x01110000
- Implementation notes pointing to client.py and ach_server.py

§14.6 ACH Session Lifecycle:
- Removed "Future" label — fully implemented in bridges/ach_server.py
- Added step 6 (optional erase), step 8 (DCD/DTR auto-resume)
- Documents ach_server.py flags and ach_state.json schema
- Notes RV55 DCD/DTR issue as known open problem

Open Questions table:
- SUB 0x06 purpose RESOLVED
- Erase-all sequence RESOLVED
- ACH server RESOLVED
- Sensor Check byte: still open, added as formal question
- RV55 DCD/DTR: added as new open question

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 01:20:43 -04:00
claude 77d9c17680 docs: update CHANGELOG and CLAUDE.md for v0.9.0
CHANGELOG.md:
- New v0.9.0 section covering erase-all protocol, browse helpers,
  delete_all_events(), ach_mitm.py, and ACH server overhaul
- Back-filled v0.8.0 section (write pipeline, monitoring, ACH server)
  that was missing from the previous release notes

CLAUDE.md:
- Bump version to v0.9.0
- Add erase-all protocol section with full wire sequence, SUB 0x06
  storage range response layout, and post-erase key counter reset notes
- Document ACH server state format (ach_state.json v0.9.0 schema with
  downloaded_keys + max_downloaded_key)
- Add RV55 DCD/DTR issue to What's next

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 01:15:11 -04:00
claude 8a1bd34551 feat: add ach_mitm.py — transparent TCP MITM proxy for ACH session capture
Listens for inbound unit connections, connects upstream to a real Blastware
ACH server, and forwards bytes bidirectionally while saving both directions to
raw_bw_<ts>.bin and raw_s3_<ts>.bin in the existing capture format.

Used to capture the 4-11-26 Blastware ACH session that confirmed the erase-all
protocol (SUBs 0xA3/0x1C/0x06/0xA2) and the event deletion wire sequence.

Usage:
  python bridges/ach_mitm.py --bw-host 127.0.0.1 --bw-port 9999 --listen-port 9998
  Point the unit's call-home destination at this machine:9998.
  Point this proxy's --bw-host/port at the upstream Blastware ACH server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 01:15:02 -04:00
claude 09788b931a feat: overhaul ACH server with key-based state, erase support, and reset detection
State format (ach_state.json):
- Replace event_count with downloaded_keys (set of hex strings) + max_downloaded_key
- Key-based tracking correctly handles delete-then-re-record: after device erase the
  count drops to 0, but new events have new (or recycled) keys

Browse pre-check:
- list_event_keys() walk before get_events() to bail early when nothing is new
- get_events() called with skip_waveform_for_keys= for already-seen keys, so repeat
  call-homes only download waveforms for genuinely new events

--clear-after-download flag:
- After saving new events, calls client.delete_all_events() (0xA3→0x1C→0x06→0xA2)
- On success: resets downloaded_keys=[] and max_downloaded_key="00000000" so the
  next session starts fresh (device counter resets to 0x01110000 after erase)

Post-erase key-reuse detection:
- Device counter resets to 0x01110000 after any erase; new events reuse old keys
- If max(device_keys) < max_downloaded_key, the device was wiped externally
  (Blastware, manual) — seen_keys is discarded and all device keys treated as new

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 01:14:50 -04:00
claude e712d68505 feat: add erase-all protocol and browse helpers to protocol/client layer
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 <noreply@anthropic.com>
2026-04-11 01:14:37 -04:00
claude 8f5da918b5 fix: correct Event and PeakValues field names in ach_server serialization
Event model uses peak_values (not peaks) and project_info (not direct fields).
PeakValues fields are tran/vert/long/micl/peak_vector_sum (not transverse etc).
ProjectInfo fields accessed via ev.project_info.project etc.

Also fix ev.timestamp serialization: use str() instead of .isoformat() since
Timestamp is a custom dataclass, not datetime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 02:09:57 -04:00
claude a03c77af09 fix: remove non-existent DeviceInfo fields from ach_server log and dict
calibration_date, aux_trigger, setup_name etc. don't exist directly on
DeviceInfo — they live in DeviceInfo.compliance_config (ComplianceConfig).
_device_info_to_dict now accesses them via cc = d.compliance_config.
Log line updated to show serial/firmware/model/event_count instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:43:02 -04:00
claude 87fa9c954f fix: make Ctrl-C work on Windows by setting accept() timeout
socket.accept() on Windows blocks indefinitely and ignores KeyboardInterrupt.
Setting a 1-second timeout on the server socket causes the accept loop to wake
up every second and re-check, so Ctrl-C is handled within ~1 second.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:19:36 -04:00
claude 3f7b5c07b5 feat: defer session dir creation and add --allow-ip allowlist
- Session directory and log file are now created ONLY after startup() succeeds.
  Internet scanners and dropped connections no longer litter the output folder.
  Raw bytes are buffered in memory until startup succeeds, then flushed to disk.

- Add --allow-ip IP flag (repeatable) to allowlist specific source IPs.
  Connections from un-listed IPs are rejected immediately (socket closed, no log).
  If no --allow-ip flags are given, all IPs are still accepted (original behavior).
  Usage: --allow-ip 63.43.212.232 --allow-ip 152.1.2.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:17:30 -04:00
claude 3d2ebfc057 fix: correct event count field offset and eliminate count_events() walk
_decode_event_count: read uint16 BE at offset 10 (confirmed 2026-04-10 from
live BE11529 event index — data[10:12]=0x0006=6, matches device LCD).
Previous uint32 at offset 3 always returned 1 regardless of event count.

ach_server.py: use device_info.event_count (already fetched during connect())
instead of calling count_events() separately. This saves 2*N round-trips and
avoids the 1F linked-list walk which was overcounting on some devices.
count_events() kept as fallback when connect() is skipped (--events-only).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:10:49 -04:00
claude 9d9c14af79 fix: replace Unicode chars in log messages, fix DeviceInfo.serial, UTF-8 file log
- Replace all Unicode arrows/checkmarks (->  [OK]  [FAIL]) in ach_server.py
  and client.py log calls — Windows cp1252 console can't encode them
- Fix DeviceInfo attribute: serial_number -> serial
- Fix _device_info_to_dict key: serial_number -> serial
- Demote count_events 1E/1F per-key log lines from WARNING to DEBUG
  (they were flooding the console on devices with many stored events)
- FileHandler now opens with encoding='utf-8' so session log files
  can hold any characters without codec errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:06:27 -04:00
7 changed files with 989 additions and 155 deletions
+101
View File
@@ -4,6 +4,107 @@ All notable changes to seismo-relay are documented here.
---
## v0.9.0 — 2026-04-11
### Added
- **`MiniMateClient.list_event_keys()`** — fast browse-mode walk (1E → 0A → 1F, no waveform
download) that returns the list of event key hex strings currently stored on the device.
Used by the ACH server as a cheap pre-check before deciding whether to call `get_events()`.
- **`get_events(skip_waveform_for_keys=set(...))`** — new optional parameter. For any key in
the set the function performs only 0A + 1F(browse) instead of the full
1E-arm → 0C → POLL×3 → 5A sequence. Eliminates redundant waveform downloads on repeat
call-homes when the device still holds previously downloaded events.
- **`MiniMateClient.delete_all_events()`** — erases all events from device memory using the
confirmed 4-step sequence:
- SUB 0xA3 `begin_erase_all` — initiate erase (token=0xFE) → ack 0x5C
- SUB 0x1C `read_monitor_status` — intermediate status read (Blastware-required)
- SUB 0x06 `read_event_storage_range` — verify storage state (token=0xFE) → 36-byte response
- SUB 0xA2 `confirm_erase_all` — commit erase (token=0xFE) → ack 0x5D
All four steps confirmed from 4-11-26 MITM capture of a live Blastware ACH session.
After a successful call, the device's event counter resets to `0x01110000`.
- **`MiniMateProtocol` erase methods**: `begin_erase_all()`, `confirm_erase_all()`,
`read_event_storage_range()` added to `protocol.py` with documented SUB constants
`SUB_ERASE_ALL_BEGIN = 0xA3` and `SUB_ERASE_ALL_CONFIRM = 0xA2`.
- **`bridges/ach_mitm.py`** — transparent TCP-to-TCP MITM proxy. Listens for inbound unit
connections, connects upstream to a real Blastware ACH server, and saves both directions
to `raw_bw_<ts>.bin` / `raw_s3_<ts>.bin` files matching the existing capture format.
Used to capture the 4-11-26 Blastware ACH session including event deletion.
Usage: `python bridges/ach_mitm.py --bw-host 127.0.0.1 --bw-port 9999 --listen-port 9998`
- **ACH server: key-based state tracking** — `ach_state.json` now stores
`downloaded_keys: [hex_strings]` and `max_downloaded_key: hex_string` per unit instead of
`event_count: N`. This correctly handles the standard workflow where events are deleted
from the device after upload — a count-based approach would see `count=0` on the next
call-home and silently skip new events.
- **ACH server: `--clear-after-download` flag** — after a successful download (at least one
new event saved), erases all events from the device using `delete_all_events()`. Mirrors
the standard Blastware ACH workflow. On success, `downloaded_keys` and
`max_downloaded_key` are reset to empty so the next session starts fresh.
- **ACH server: post-erase key-reuse detection** — after an external erase (Blastware or
manual), device keys restart from `0x01110000`, colliding with previously downloaded keys.
On each browse walk, if `max(device_keys) < max_downloaded_key` (device counter rolled
back), all device keys are treated as new regardless of `seen_keys`. This also catches
erases performed by Blastware between our sessions.
### Protocol / Documentation
- **SUB 0xA3 / SUB 0xA2 — erase-all sequence confirmed** (✅ 4-11-26 MITM capture):
Both frames use `token=0xFE` at `params[7]` and are standard `build_bw_frame` requests
(not write-format). Response SUBs follow the standard formula: 0x5C and 0x5D.
The intermediate 0x1C + 0x06 reads between them are required by Blastware.
- **SUB 0x06 — event storage range read confirmed** (✅ 4-11-26 MITM capture):
Two-step read, data offset = 0x24 (36 bytes). The last 8 bytes of the response contain
the first and last stored event keys (4 bytes each). After a successful erase, both keys
read as `01110000` (device-empty state).
- **Event key counter resets to `0x01110000` after erase** — confirmed by observing key
`01110000` on the device immediately after the MITM erase session.
---
## v0.8.0 — 2026-04-07
### Added
- **Write pipeline end-to-end** — `push_config_raw(event_index_data, compliance_data,
trigger_data, waveform_data)` on `MiniMateClient` orchestrates the full
`68→73 | 71×3→72 | 82→83 | 69→74→72` write sequence.
- **`build_bw_write_frame(sub, data, *, offset, params)`** in `framing.py` — dedicated frame
builder for write commands (SUBs 0x680x83). Doubles only the BW_CMD byte; all other
bytes including offset, params, data, and checksum are written raw. Uses the large-frame
DLE-aware checksum (`sum(b for b in payload[2:] if b != 0x10) + 0x10) & 0xFF`).
- **`MiniMateProtocol` write methods** — `write_event_index()`, `write_compliance()`,
`write_trigger_config()`, `write_waveform_data()`, `write_confirm()`,
`start_monitoring()`, `stop_monitoring()`.
- **`AchSession` inbound server** (`bridges/ach_server.py`) — accepts call-home TCP
connections, runs the full handshake + device-info + event-download sequence, saves
`device_info.json` + `events.json` per session.
### Protocol / Documentation
- **Write frame format confirmed** (✅ 3-11-26 BW TX capture, all 11 frames): only BW_CMD
byte `0x10` is doubled; all other bytes sent raw. Standard `build_bw_frame` DLE-stuffing
is incorrect for write commands.
- **Write ack responses** confirmed as 17-byte zero-data S3 frames.
- **Monitoring SUBs 0x96/0x97** confirmed from 4-8-26 capture.
- **SESSION_RESET signal** (`41 03`) required before POLL for monitoring units.
- **SUB 0x1C monitoring flag** at `section[1]`: `0x00` = idle, `0x10` = monitoring.
Confirmed by byte-diff of all 144 data frames in 4-8-26/2ndtry capture.
---
## v0.7.0 — 2026-04-03
### Added
+81 -5
View File
@@ -2,7 +2,7 @@
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
(Sierra Wireless RV50 / RV55). Current version: **v0.8.0**.
(Sierra Wireless RV50 / RV55). Current version: **v0.9.0**.
---
@@ -25,9 +25,9 @@ CHANGELOG.md ← version history
---
## Current implementation state (v0.8.0)
## Current implementation state (v0.9.0)
Full read pipeline + write pipeline working end-to-end over TCP/cellular:
Full read pipeline + write pipeline + erase pipeline working end-to-end over TCP/cellular:
| Step | SUB | Status |
|---|---|---|
@@ -41,12 +41,15 @@ Full read pipeline + write pipeline working end-to-end over TCP/cellular:
| Waveform record (peaks, timestamp, project) | 0C | ✅ |
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ new v0.6.0 |
| Event advance / next key | 1F | ✅ |
| **Write commands (push config to device)** | **6883** | ✅ **new v0.8.0** |
| **Write commands (push config to device)** | **6883** | ✅ new v0.8.0 |
| **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ **new v0.9.0** |
`get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F`
`push_config_raw()` write sequence: `68→73 | 71×3→72 | 82→83 | 69→74→72`
`delete_all_events()` erase sequence: `0xA3 → 0x1C → 0x06 → 0xA2`
---
## Protocol fundamentals
@@ -720,9 +723,82 @@ Full compliance config encoder is a future task.
---
---
## Erase-all protocol (SUBs 0xA3/0xA2/0x06) — confirmed 2026-04-11
Full sequence confirmed from 4-11-26 MITM capture of a live Blastware ACH session
(`bridges/captures/mitm/ach_mitm_20260411_001912/`).
### Wire sequence
```
BW → device: SUB 0xA3 params=00 00 00 00 00 00 00 FE 00 00 (begin erase)
device → BW: SUB 0x5C (ack)
BW → device: SUB 0x1C probe (offset=0x00)
device → BW: SUB 0xE3 (probe ack)
BW → device: SUB 0x1C data (offset=0x2C)
device → BW: SUB 0xE3 (monitor status response)
BW → device: SUB 0x06 probe (offset=0x00, params same)
device → BW: SUB 0xF9 (probe ack)
BW → device: SUB 0x06 data (offset=0x24)
device → BW: SUB 0xF9 (36-byte storage range response)
BW → device: SUB 0xA2 params=00 00 00 00 00 00 00 FE 00 00 (confirm erase)
device → BW: SUB 0x5D (ack — device memory is now cleared)
```
All frames use standard `build_bw_frame` (not write-format). Response SUBs follow the
standard `0xFF - SUB` formula; no exceptions.
### SUB 0x06 — event storage range response (36 bytes)
The 36-byte response body ends with two 4-byte event keys:
| Offset (from end) | Field | Notes |
|---|---|---|
| `[-8:-4]` | first stored event key | `01110000` when empty |
| `[-4:]` | last stored event key | `01110000` when empty |
Before erase: ends with `<first_key> <last_key>` (e.g. `0111ea60 0111eaa6`).
After erase: both bytes read `01110000` — device's empty/reset sentinel.
### Post-erase key counter reset
After a successful erase, the device resets its event counter. New events start from
key `0x01110000` again — the same key as the very first event ever recorded. This means
key-based deduplication in the ACH server must account for key reuse:
- After our own erase: `ach_state.json` `downloaded_keys` and `max_downloaded_key` are
cleared so the next session starts fresh.
- After an external erase: the ACH server detects it by comparing `max(device_keys)` to
`max_downloaded_key` from state. If the device max has rolled back below the historical
max, all current device keys are treated as new regardless of `seen_keys`.
### ACH server state format (v0.9.0)
`bridges/captures/ach_state.json`:
```json
{
"BE11529": {
"downloaded_keys": ["01110000", "0111245a"],
"max_downloaded_key": "0111245a",
"last_seen": "2026-04-11T01:04:36",
"serial": "BE11529",
"peer": "63.43.212.232:51920"
}
}
```
`max_downloaded_key` is the high-water mark — the largest key ever downloaded from the
unit. It is NOT reset when events are erased from the device (only when our server does
the erase). Used for post-erase detection.
---
## What's next
- Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
- Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring)
- ACH inbound server — accept call-home connections from field units
- Modem manager — push RV50/RV55 configs via Sierra Wireless API
- RV55 DCD/DTR issue — newer RV55 firmware doesn't assert DCD by default; units don't
resume monitoring after call-home disconnect (`--restart-monitoring` flag deferred)
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
ach_mitm.py — TCP man-in-the-middle proxy for capturing Blastware ACH sessions.
The unit calls home to THIS proxy instead of directly to Blastware. The proxy
forwards every byte in both directions to the real Blastware ACH server and saves
the traffic to separate raw capture files that the Analyzer can load directly.
Setup
-----
1. Start Blastware's ACH server on the BW PC as normal (it listens on its port).
2. Run this proxy on any machine the unit can reach:
python bridges/ach_mitm.py --bw-host 192.168.1.50 --bw-port 9999
3. Point the unit's ACEmanager call-home destination to THIS machine's IP and
the --listen-port (default 9999).
4. Trigger a call-home (or wait for the unit to call in).
5. The proxy transparently forwards everything and saves two files per session:
ach_mitm_<ts>/raw_bw_<ts>.bin -- bytes Blastware sent to unit (BW TX)
ach_mitm_<ts>/raw_s3_<ts>.bin -- bytes unit sent to Blastware (S3 TX)
Both files load directly in the Analyzer (File > Open Capture).
The proxy exits cleanly when either side drops the connection.
Use case: capturing Blastware operations we haven't reverse-engineered yet,
e.g. event deletion, factory reset, firmware update.
"""
from __future__ import annotations
import argparse
import datetime
import logging
import socket
import sys
import threading
from pathlib import Path
log = logging.getLogger("ach_mitm")
def _pipe(src: socket.socket, dst: socket.socket, label: str, outfile) -> None:
"""Forward bytes from src to dst, writing everything to outfile."""
try:
while True:
data = src.recv(4096)
if not data:
break
dst.sendall(data)
outfile.write(data)
outfile.flush()
log.debug("%s %d bytes", label, len(data))
except OSError:
pass
finally:
log.info("%s pipe closed", label)
# Signal the other direction to stop by shutting down our end.
try:
dst.shutdown(socket.SHUT_WR)
except OSError:
pass
def handle(unit_sock: socket.socket, peer: str, bw_host: str, bw_port: int,
output_dir: Path) -> None:
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
session_dir = output_dir / f"ach_mitm_{ts}"
session_dir.mkdir(parents=True, exist_ok=True)
log.info("Session %s unit=%s forwarding to %s:%d", ts, peer, bw_host, bw_port)
# Connect upstream to Blastware.
bw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
bw_sock.connect((bw_host, bw_port))
except OSError as exc:
log.error("Cannot reach Blastware at %s:%d: %s", bw_host, bw_port, exc)
unit_sock.close()
return
log.info("Connected to Blastware at %s:%d", bw_host, bw_port)
bw_path = session_dir / f"raw_bw_{ts}.bin" # Blastware → unit (BW TX)
s3_path = session_dir / f"raw_s3_{ts}.bin" # unit → Blastware (S3 TX)
with open(bw_path, "wb") as bw_fh, open(s3_path, "wb") as s3_fh:
# Two threads: one per direction.
t_bw = threading.Thread(
target=_pipe, args=(bw_sock, unit_sock, "BW->unit", bw_fh), daemon=True
)
t_s3 = threading.Thread(
target=_pipe, args=(unit_sock, bw_sock, "unit->BW", s3_fh), daemon=True
)
t_bw.start()
t_s3.start()
t_bw.join()
t_s3.join()
bw_bytes = bw_path.stat().st_size
s3_bytes = s3_path.stat().st_size
log.info(
"Session %s done BW->unit: %d bytes unit->BW: %d bytes -> %s",
ts, bw_bytes, s3_bytes, session_dir,
)
unit_sock.close()
bw_sock.close()
def serve(args: argparse.Namespace) -> None:
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", args.listen_port))
server.listen(5)
server.settimeout(1.0)
print(f"\n{'='*60}")
print(f" ACH MITM proxy")
print(f" Listening on 0.0.0.0:{args.listen_port}")
print(f" Forwarding to {args.bw_host}:{args.bw_port}")
print(f" Captures in {output_dir.resolve()}/ach_mitm_<ts>/")
print(f"{'='*60}")
print(f"\n Point the unit's ACEmanager call-home to this machine on port {args.listen_port}")
print(f" Ctrl-C to stop\n")
try:
while True:
try:
client_sock, addr = server.accept()
except socket.timeout:
continue
peer = f"{addr[0]}:{addr[1]}"
log.info("Accepted connection from %s", peer)
t = threading.Thread(
target=handle,
args=(client_sock, peer, args.bw_host, args.bw_port, output_dir),
daemon=True,
)
t.start()
except KeyboardInterrupt:
print("\nStopping.")
finally:
server.close()
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--bw-host", required=True,
help="IP or hostname of the Blastware ACH server")
ap.add_argument("--bw-port", type=int, default=9999,
help="Port Blastware is listening on (default: 9999)")
ap.add_argument("--listen-port", type=int, default=9999,
help="Port this proxy listens on (default: 9999)")
ap.add_argument("--output", default="bridges/captures/mitm",
help="Directory for capture files")
ap.add_argument("--log-level", default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"])
args = ap.parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level),
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
stream=sys.stdout,
)
serve(args)
if __name__ == "__main__":
main()
+284 -93
View File
@@ -71,9 +71,27 @@ from minimateplus.models import DeviceInfo, Event
log = logging.getLogger("ach_server")
# ── Per-unit state (high-water mark) ──────────────────────────────────────────
# ── Per-unit state (downloaded-key set) ───────────────────────────────────────
# Persisted as <output_dir>/ach_state.json
# Format: { "BE11529": { "event_count": 5, "last_seen": "2026-04-09T..." }, ... }
# Format:
# {
# "BE11529": {
# "downloaded_keys": ["01110000", "0111245a"], # hex keys already on disk
# "max_downloaded_key": "0111245a", # highest key ever seen
# "last_seen": "2026-04-11T01:04:36"
# }
# }
#
# Key-based deduplication works well within a single "key generation" (between
# erases). After the device memory is erased the event counter resets to
# 0x01110000, so the first new event has the SAME key as the very first event
# we ever downloaded. We detect this situation with max_downloaded_key:
#
# if max(current_device_keys) < max_downloaded_key
# → device was wiped and keys have restarted → treat all device keys as new
#
# After our own erase (--clear-after-download) we also explicitly clear
# downloaded_keys and max_downloaded_key so the next session starts fresh.
_state_lock = threading.Lock()
@@ -103,10 +121,10 @@ class AchSession:
standard connect → get_device_info → get_events sequence.
State tracking (ach_state.json in output_dir):
On each successful download we record how many events the unit had.
On the next call-home we compare: if count hasn't grown, there's nothing
new and we close cleanly without downloading. If it has grown, we
download all events up to the new count and save only the new ones.
On each successful download we record the SET of event keys downloaded.
On the next call-home we compare: if all device keys are already in the
set, there's nothing new. If any key is new (including after the device
was wiped and re-recorded), we download and save only those events.
"""
def __init__(
@@ -118,6 +136,7 @@ class AchSession:
events_only: bool,
max_events: Optional[int],
state_path: Path,
clear_after_download: bool = False,
) -> None:
self.sock = sock
self.peer = peer
@@ -126,70 +145,82 @@ class AchSession:
self.events_only = events_only
self.max_events = max_events
self.state_path = state_path
self.clear_after_download = clear_after_download
def run(self) -> None:
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
session_dir = self.output_dir / f"ach_inbound_{ts}"
session_dir.mkdir(parents=True, exist_ok=True)
log_path = session_dir / f"session_{ts}.log"
raw_path = session_dir / f"raw_rx_{ts}.bin"
# Wire up a file handler so every protocol log line goes to the session log
fh = logging.FileHandler(log_path)
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s %(message)s"))
root_logger = logging.getLogger()
root_logger.addHandler(fh)
# Session dir and file handler are created lazily — only after startup
# succeeds. This prevents internet scanners and dropped connections from
# littering the output directory with empty session folders.
try:
self._run_inner(session_dir, raw_path, ts)
self._run_inner(ts)
except Exception as exc:
log.error("Session failed: %s", exc, exc_info=True)
log.error("Session failed (%s): %s", self.peer, exc, exc_info=True)
finally:
root_logger.removeHandler(fh)
fh.close()
try:
self.sock.close()
except Exception:
pass
def _run_inner(self, session_dir: Path, raw_path: Path, ts: str) -> None:
log.info("="*60)
log.info("Inbound connection from %s", self.peer)
log.info("Session dir: %s", session_dir)
def _run_inner(self, ts: str) -> None:
transport = SocketTransport(self.sock, peer=self.peer)
# Tap the transport: save every raw byte received from the device.
raw_fh = open(raw_path, "wb")
# Collect raw bytes in memory until startup succeeds, then flush to disk.
raw_buf: list[bytes] = []
_orig_read = transport.read
def tapped_read(n: int) -> bytes:
data = _orig_read(n)
if data:
raw_fh.write(data)
raw_fh.flush()
raw_buf.append(data)
return data
transport.read = tapped_read # type: ignore[method-assign]
serial: Optional[str] = None
try:
client = MiniMateClient(transport=transport, timeout=self.timeout)
client.open()
# ── Step 1: startup handshake ─────────────────────────────────────
log.info("Step 1/3: startup handshake (POLL / SUB 5B)")
# ── Step 1: startup handshake ─────────────────────────────────────────
# Do this BEFORE creating the session directory so that scanner probes
# and dropped connections leave no trace on disk.
try:
from minimateplus.protocol import MiniMateProtocol
client = MiniMateClient(transport=transport, timeout=self.timeout)
client.open()
proto = MiniMateProtocol(transport, recv_timeout=self.timeout)
proto.startup()
log.info(" [OK] Startup OK -- pull protocol confirmed")
except Exception as exc:
log.error(" [FAIL] Startup failed: %s", exc)
return
log.warning("Startup failed from %s: %s -- ignoring", self.peer, exc)
return # no session dir created
# Startup succeeded — this is a real unit. Create session dir now.
session_dir = self.output_dir / f"ach_inbound_{ts}"
session_dir.mkdir(parents=True, exist_ok=True)
log_path = session_dir / f"session_{ts}.log"
raw_path = session_dir / f"raw_rx_{ts}.bin"
# Flush buffered raw bytes to file and switch to direct file writes.
raw_fh = open(raw_path, "wb")
for chunk in raw_buf:
raw_fh.write(chunk)
raw_buf.clear()
def tapped_read_file(n: int) -> bytes:
data = _orig_read(n)
if data:
raw_fh.write(data)
raw_fh.flush()
return data
transport.read = tapped_read_file # type: ignore[method-assign]
# Wire up file handler now that the session dir exists.
fh = logging.FileHandler(log_path, encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s %(message)s"))
root_logger = logging.getLogger()
root_logger.addHandler(fh)
try:
# ── Step 2: device info ───────────────────────────────────────────
device_info = None
if not self.events_only:
@@ -199,48 +230,104 @@ class AchSession:
serial = device_info.serial
_save_json(session_dir / "device_info.json", _device_info_to_dict(device_info))
log.info(
" [OK] Device: serial=%s firmware=%s calibration=%s",
" [OK] Device: serial=%s firmware=%s model=%s events=%d",
serial,
device_info.firmware_version,
device_info.calibration_date,
device_info.model,
device_info.event_count or 0,
)
except Exception as exc:
log.error(" [FAIL] Device info failed: %s", exc)
else:
log.info("Step 2/3: skipping device info (--events-only)")
# ── Step 3: check for new events via high-water mark ───────────────
# ── Step 3: check for new events by comparing key sets ────────────
log.info("Step 3/3: checking for new events")
state = _load_state(self.state_path)
unit_key = serial or self.peer # fall back to IP if no serial
last_count = state.get(unit_key, {}).get("event_count", 0)
unit_state = state.get(unit_key, {})
seen_keys: set[str] = set(unit_state.get("downloaded_keys", []))
# Highest event key ever downloaded from this unit (hex string, 8 chars).
# Used to detect post-erase key reuse — see comment block above.
max_seen_key: str = unit_state.get("max_downloaded_key", "00000000")
# Use the event count already read from the event index during connect().
# This is fast (no extra round-trips) and confirmed accurate (matches LCD).
# Falls back to count_events() only if connect() wasn't called.
if device_info is not None:
current_count = device_info.event_count or 0
else:
try:
current_count = client.count_events()
log.info(" Unit has %d stored event(s); last downloaded count: %d",
current_count, last_count)
except Exception as exc:
log.error(" [FAIL] count_events failed: %s", exc)
return
if current_count <= last_count:
log.info(" [OK] No new events since last call-home -- nothing to download")
log.info("Session complete (no new events) -> %s", session_dir)
log.info(" Unit has %d stored event(s); %d key(s) previously downloaded",
current_count, len(seen_keys))
if current_count == 0:
log.info(" [OK] No events on device -- nothing to download")
log.info("Session complete (no events) -> %s", session_dir)
return
new_event_count = current_count - last_count
log.info(" %d new event(s) to download", new_event_count)
# Fast pre-check: walk the event index (browse-mode, no 5A) to get
# the current key list, then bail early if everything is already seen.
# This avoids calling get_events() at all when there's nothing new.
log.info(" Checking device key list (browse walk, no waveform download)...")
try:
device_keys = client.list_event_keys()
except Exception as exc:
log.warning(" list_event_keys failed: %s -- falling back to full download", exc)
device_keys = None
# Download all events up to current_count, apply max_events cap.
# We re-download old events too (get_events always starts from 0),
# but we only SAVE the new ones (the last new_event_count of the list).
if device_keys is not None:
# ── Post-erase detection ──────────────────────────────────────
# After the device memory is erased, new events start from key
# 01110000 again — the same keys we already downloaded. Detect
# this by comparing the device's current highest key against the
# historical maximum. If the device has rolled back below our
# high-water mark, its counter was reset and we must treat all
# its keys as new, regardless of what seen_keys contains.
if device_keys and max_seen_key != "00000000":
max_device_key = max(device_keys) # lexicographic; safe because
# keys share the same 4-char prefix
if max_device_key < max_seen_key:
log.info(
" Post-erase reset detected: "
"device max key %s < historical max %s "
"-- treating all device keys as new",
max_device_key, max_seen_key,
)
seen_keys = set() # discard stale dedup info for this session
new_key_set = set(device_keys) - seen_keys
log.info(" Device has %d key(s): %d new, %d already seen",
len(device_keys), len(new_key_set), len(device_keys) - len(new_key_set))
if not new_key_set:
log.info(" [OK] All events already downloaded -- nothing to do")
# Refresh state timestamp; preserve max_seen_key unchanged.
state[unit_key] = {
"downloaded_keys": sorted(seen_keys | set(device_keys)),
"max_downloaded_key": max_seen_key,
"last_seen": datetime.datetime.now().isoformat(),
"serial": serial,
"peer": self.peer,
}
_save_state(self.state_path, state)
log.info("Session complete (no new events) -> %s", session_dir)
return
else:
new_key_set = None # unknown; proceed with full download
# Apply max_events cap
stop_idx = current_count - 1
if self.max_events is not None:
stop_idx = min(stop_idx, self.max_events - 1)
if self.max_events < current_count:
log.warning(
" max_events=%d cap: will download events 0%d only "
" max_events=%d cap: will download events 0-%d only "
"(unit has %d total)",
self.max_events, stop_idx, current_count,
)
@@ -249,30 +336,83 @@ class AchSession:
all_events = client.get_events(
full_waveform=True,
stop_after_index=stop_idx,
skip_waveform_for_keys=seen_keys if seen_keys else None,
)
# Only the events beyond last_count are genuinely new
new_events = all_events[last_count:]
log.info(" [OK] Downloaded %d total event(s), %d new",
len(all_events), len(new_events))
# Filter to events whose keys we haven't saved before.
new_events = [
e for e in all_events
if e._waveform_key is None
or e._waveform_key.hex() not in seen_keys
]
skipped = len(all_events) - len(new_events)
log.info(" [OK] Downloaded %d event(s): %d new, %d skipped (already seen)",
len(all_events), len(new_events), skipped)
if skipped:
log.info(" (skipped %d already-downloaded event(s))", skipped)
if new_events:
_save_json(session_dir / "events.json", [_event_to_dict(e) for e in new_events])
if last_count > 0 and len(all_events) > len(new_events):
log.info(" (skipped %d already-seen event(s))", last_count)
for i, ev in enumerate(new_events):
for ev in new_events:
pv = ev.peak_values
pi = ev.project_info
key_hex = ev._waveform_key.hex() if ev._waveform_key else "????????"
log.info(
" NEW Event %d: %s Tran=%.4f Vert=%.4f Long=%.4f VS=%.4f",
last_count + i,
ev.timestamp.isoformat() if ev.timestamp else "?",
ev.peaks.transverse if ev.peaks else 0,
ev.peaks.vertical if ev.peaks else 0,
ev.peaks.longitudinal if ev.peaks else 0,
ev.peaks.vector_sum if ev.peaks else 0,
" NEW [%s] %s Tran=%.4f Vert=%.4f Long=%.4f VS=%.4f project=%r",
key_hex,
str(ev.timestamp) if ev.timestamp else "?",
pv.tran if pv else 0,
pv.vert if pv else 0,
pv.long if pv else 0,
pv.peak_vector_sum if pv else 0,
pi.project if pi else "",
)
else:
log.info(" [OK] No new events since last call-home -- nothing to save")
# ── Optional: erase device memory after successful download ────
erased_successfully = False
if self.clear_after_download and new_events:
log.info(" Clearing device memory (--clear-after-download)...")
try:
client.delete_all_events()
log.info(" [OK] Device memory cleared")
erased_successfully = True
except Exception as exc:
log.error(
" [WARN] Event deletion failed: %s -- events NOT cleared",
exc,
)
# Update high-water mark
# ── Update persistent state ───────────────────────────────────
current_keys = [
e._waveform_key.hex()
for e in all_events
if e._waveform_key is not None
]
if erased_successfully:
# Device memory is clear. Reset downloaded_keys and the
# high-water mark so the next call-home starts fresh and
# doesn't mis-identify the recycled key 01110000 as "seen".
updated_keys = []
new_max_key = "00000000"
log.info(
" State reset after erase -- next session will download "
"from key 0 (device counter resets after erase)"
)
else:
# Normal (no erase): union of previously-seen + all keys on
# device now. Includes already-seen survivors so we never
# re-download them if the device somehow keeps old records.
updated_keys = sorted(set(seen_keys) | set(current_keys))
new_max_key = updated_keys[-1] if updated_keys else max_seen_key
state[unit_key] = {
"event_count": current_count,
"downloaded_keys": updated_keys,
"max_downloaded_key": new_max_key,
"last_seen": datetime.datetime.now().isoformat(),
"serial": serial,
"peer": self.peer,
@@ -285,6 +425,8 @@ class AchSession:
finally:
raw_fh.close()
client.close() # closes transport / socket cleanly
root_logger.removeHandler(fh)
fh.close()
log.info("Session complete -> %s", session_dir)
log.info("="*60)
@@ -299,33 +441,38 @@ def _save_json(path: Path, obj: object) -> None:
def _device_info_to_dict(d: DeviceInfo) -> dict:
cc = d.compliance_config
return {
"serial": d.serial,
"firmware_version": d.firmware_version,
"calibration_date": str(d.calibration_date) if d.calibration_date else None,
"aux_trigger": d.aux_trigger,
"setup_name": d.setup_name,
"sample_rate": d.sample_rate,
"record_time": d.record_time,
"trigger_level_geo": d.trigger_level_geo,
"alarm_level_geo": d.alarm_level_geo,
"max_range_geo": d.max_range_geo,
"project": d.project,
"client": d.client,
"operator": d.operator,
"sensor_location": d.sensor_location,
"dsp_version": d.dsp_version,
"model": d.model,
"event_count": d.event_count,
# compliance config fields (None if 1A read failed)
"setup_name": cc.setup_name if cc else None,
"sample_rate": cc.sample_rate if cc else None,
"record_time": cc.record_time if cc else None,
"trigger_level_geo": cc.trigger_level_geo if cc else None,
"alarm_level_geo": cc.alarm_level_geo if cc else None,
"max_range_geo": cc.max_range_geo if cc else None,
"project": cc.project if cc else None,
"client": cc.client if cc else None,
"operator": cc.operator if cc else None,
"sensor_location": cc.sensor_location if cc else None,
}
def _event_to_dict(e: Event) -> dict:
pv = e.peak_values
pi = e.project_info
peaks = {}
if e.peaks:
if pv:
peaks = {
"transverse": e.peaks.transverse,
"vertical": e.peaks.vertical,
"longitudinal": e.peaks.longitudinal,
"vector_sum": e.peaks.vector_sum,
"mic": e.peaks.mic,
"transverse": pv.tran,
"vertical": pv.vert,
"longitudinal": pv.long,
"vector_sum": pv.peak_vector_sum,
"mic": pv.micl,
}
samples = {}
if e.raw_samples:
@@ -335,11 +482,11 @@ def _event_to_dict(e: Event) -> dict:
}
samples["__note__"] = "first 20 sample-sets only; see raw_rx.bin for full waveform"
return {
"timestamp": e.timestamp.isoformat() if e.timestamp else None,
"project": e.project,
"client": e.client,
"operator": e.operator,
"sensor_location": e.sensor_location,
"timestamp": str(e.timestamp) if e.timestamp else None,
"project": pi.project if pi else None,
"client": pi.client if pi else None,
"operator": pi.operator if pi else None,
"sensor_location": pi.sensor_location if pi else None,
"peaks": peaks,
"raw_samples_preview": samples,
}
@@ -356,6 +503,9 @@ def serve(args: argparse.Namespace) -> None:
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind(("0.0.0.0", args.port))
server_sock.listen(5)
# Wake up every second so Ctrl-C is handled promptly on Windows.
# Without this, accept() blocks indefinitely and ignores KeyboardInterrupt.
server_sock.settimeout(1.0)
max_ev = args.max_events
print(f"\n{'='*60}")
@@ -363,17 +513,34 @@ def serve(args: argparse.Namespace) -> None:
print(f" Output: {output_dir.resolve()}/ach_inbound_<timestamp>/")
print(f" State file: {state_path}")
print(f" Max events per session: {max_ev if max_ev else 'unlimited'}")
print(f" Clear device after download: {'YES' if args.clear_after_download else 'no'}")
print(f"{'='*60}")
print(f"\n Point your test unit's ACEmanager call-home settings to:")
print(f" Remote Host: <this machine's LAN IP>")
print(f" Remote Port: {args.port}")
print(f"\n Waiting for inbound connections... (Ctrl-C to stop)\n")
allow_ips = set(args.allow_ips)
if allow_ips:
print(f" Allowlist: {', '.join(sorted(allow_ips))}")
else:
print(" Allowlist: NONE -- accepting all IPs (add --allow-ip to restrict)")
try:
while True:
try:
client_sock, addr = server_sock.accept()
except socket.timeout:
continue # no connection this second; loop back and check for Ctrl-C
try:
peer_ip = addr[0]
peer = f"{addr[0]}:{addr[1]}"
if allow_ips and peer_ip not in allow_ips:
log.info("Rejected connection from %s (not in allowlist)", peer)
client_sock.close()
continue
log.info("Accepted connection from %s", peer)
session = AchSession(
sock=client_sock,
@@ -383,6 +550,7 @@ def serve(args: argparse.Namespace) -> None:
events_only=args.events_only,
max_events=max_ev,
state_path=state_path,
clear_after_download=args.clear_after_download,
)
t = threading.Thread(target=session.run, daemon=True, name=f"ach-{peer}")
t.start()
@@ -434,6 +602,29 @@ def parse_args() -> argparse.Namespace:
"Useful if a unit has many old events stored — prevents a very long first run."
),
)
p.add_argument(
"--allow-ip",
metavar="IP",
action="append",
dest="allow_ips",
default=[],
help=(
"Only accept connections from this IP address (repeat for multiple). "
"Example: --allow-ip 63.43.212.232 "
"If not specified, all IPs are accepted (not recommended for public servers)."
),
)
p.add_argument(
"--clear-after-download",
action="store_true",
default=False,
help=(
"After successfully downloading new events, erase all events from the "
"device memory (SUB 0xA3 → 0x1C → 0x06 → 0xA2 sequence, confirmed from "
"4-11-26 MITM capture). Only fires when at least one new event was saved. "
"This mirrors the standard Blastware ACH workflow."
),
)
p.add_argument(
"--verbose", "-v",
action="store_true",
+108 -6
View File
@@ -99,6 +99,10 @@
| 2026-04-08 | §7.10 | **NEW — SUBs 0x15 and 0x01 observed in sensor-check capture.** SUB 0x15 (serial number short form, data length 0x0A, RSP 0xEA) and SUB 0x01 (device info block, data length 0x98 = 152 bytes, RSP 0xFE) seen in Blastware's "Unit Channel Test" init sequence. Note: SUB 0x01 response SUB 0xFE collides with the existing SUB 0xFE → RSP 0x01 naming convention — they are inverse commands. |
| 2026-04-08 | §12 | **CONFIRMED — Unit partially reachable during on-device sensor check.** 4-8-26/sensor-check capture shows: POLL responds normally throughout; SUB 0x0E channel reads partially served (channels 04 responded), then ~40s silent gap while sensor check ran, then channels 57 responded. On-device sensor check duration ≈ 40 s. SFM `_pollMonitorConfirm()` polls status every 5 s for up to 60 s after start_monitoring. |
| 2026-04-08 | §7.9 (NEW) | **NEW — Compliance config field inventory captured from Blastware UI.** See §7.9 for full field list (Recording Setup, Notes, Special Setups tabs). Most fields NOT yet mapped to raw byte offsets. Confirmed decoded: sample_rate, record_time, trigger_level_geo, alarm_level_geo, max_range_geo, backlight_on_time, power_saving_timeout, monitoring_lcd_cycle, project/client/operator/sensor_location/notes. Sensor Check dropdown (Before monitoring / After each event / Disabled) NOT YET LOCATED in raw config bytes. |
| 2026-04-11 | §5.1, §5.2 | **NEW — Erase-all command sequence confirmed from MITM capture.** SUB 0xA3 (begin erase, token=0xFE → ack 0x5C) + SUB 0xA2 (confirm erase, token=0xFE → ack 0x5D). Standard `build_bw_frame` format (not write-format). Required intermediate steps: 0x1C probe+data (monitor status read) + 0x06 probe+data (event storage range). All response SUBs follow the standard 0xFFSUB formula with no exceptions. |
| 2026-04-11 | §5.1 | **CONFIRMED — SUB 0x06 (CHANNEL CONFIG READ) now confirmed as event storage range.** Two-step read, data offset = 0x24 (36 bytes). Token=0xFE at params[7]. Last 8 bytes of response: first stored event key (bytes 8:4) and last stored event key (bytes 4:). Both equal `01110000` when device memory is empty. Used by Blastware to verify erase completion. |
| 2026-04-11 | §7.11 (NEW) | **NEW — §7.11 Erase-All Protocol added.** Full wire sequence, SUB 0x06 storage range payload layout, post-erase key counter reset (resets to `0x01110000`). Confirmed from 4-11-26 MITM capture of live Blastware ACH session. |
| 2026-04-11 | §14.6 | **RESOLVED — ACH Session Lifecycle is no longer "Future".** `bridges/ach_server.py` fully implements inbound ACH: POLL handshake, device info, event download. State tracked via `ach_state.json` (key-based, with `max_downloaded_key` for post-erase detection). `--clear-after-download` flag added for the standard delete-after-upload workflow. |
---
@@ -243,7 +247,7 @@ Step 4 — Device sends actual data payload:
| `15` | **SERIAL NUMBER REQUEST** | Requests device serial number. | ✅ CONFIRMED |
| `01` | **FULL CONFIG READ** | Requests complete device configuration block (~0x98 bytes). Firmware, model, serial, channel config, scaling factors. | ✅ CONFIRMED |
| `08` | **EVENT INDEX READ** | Requests the event record index (0x58 bytes). Event count and record pointers. | ✅ CONFIRMED |
| `06` | **CHANNEL CONFIG READ** | Requests channel configuration block (0x24 bytes). | ✅ CONFIRMED |
| `06` | **EVENT STORAGE RANGE READ** | Requests event storage range block (0x24 = 36 bytes). Token=0xFE at params[7]. Last 8 bytes of response: first stored event key (`[-8:-4]`) and last stored event key (`[-4:]`). Both equal `01110000` when device is empty. Used by Blastware as part of the erase-all verification step. Previously labelled "CHANNEL CONFIG READ" — function now confirmed from 4-11-26 MITM capture. | ✅ CONFIRMED 2026-04-11 |
| `1C` | **TRIGGER CONFIG READ** | Requests trigger settings block (0x2C bytes). | ✅ CONFIRMED |
| `1E` | **EVENT HEADER READ** | Gets first waveform key. Token byte at params[7] (0x00=browse, 0xFE=download-arm). Key at data[11:15]; trailing offset at data[15:19] (0 = only one event). Two uses: (1) all-zero to get key0; (2) token=0xFE after 0A, before 0C — REQUIRED to arm device for SUB 5A. | ✅ CONFIRMED 2026-04-06 |
| `0A` | **WAVEFORM HEADER READ** | Checks record type for a given waveform key. Variable DATA_LENGTH: 0x30=full bin, 0x26=partial bin. Key at params[4..7]. Required before every 1F call to establish device waveform context. | ✅ CONFIRMED 2026-03-31 |
@@ -260,6 +264,8 @@ Step 4 — Device sends actual data payload:
| `1C` | **MONITOR STATUS READ** | Two-step read, data offset 0x2C (44 bytes). `section[1] == 0x10` → monitoring; `0x00` → idle (CONFIRMED 2026-04-09, 100% accuracy on 144 frames). Payload length: 4647 bytes IDLE, 4849 bytes MONITORING. `frame.data` has checksum stripped — no trailing byte to skip. Battery/memory at end: `section[-10:-8]` = battery×100 (uint16 BE), `section[-8:-4]` = memory_total (uint32 BE), `section[-4:]` = memory_free (uint32 BE). | ✅ CONFIRMED 2026-04-09 |
| `96` | **START MONITORING** | Single write frame, no data payload. Transitions unit from idle to monitoring mode (after optional on-device sensor check ~40 s). | ✅ CONFIRMED 2026-04-08 |
| `97` | **STOP MONITORING** | Single write frame, no data payload. Stops monitoring, unit returns to idle. | ✅ CONFIRMED 2026-04-08 |
| `A3` | **ERASE ALL BEGIN** | Single frame, token=0xFE at params[7]. Initiates device memory erase. Must be followed by 0x1C probe+data + 0x06 probe+data + 0xA2 to complete. Standard `build_bw_frame` (not write-format). Response ack SUB = 0x5C. | ✅ CONFIRMED 2026-04-11 |
| `A2` | **ERASE ALL CONFIRM** | Single frame, token=0xFE at params[7]. Commits the erase initiated by 0xA3. After this ack (SUB 0x5D), device memory is cleared and the event counter resets to `0x01110000`. | ✅ CONFIRMED 2026-04-11 |
All requests use CMD byte `0x02`. All responses use CMD byte `0x10 0x02` (which, after de-stuffing, is just the DLE+CMD combination — see §3).
@@ -273,7 +279,7 @@ All requests use CMD byte `0x02`. All responses use CMD byte `0x10 0x02` (which,
| `15` | `EA` | ✅ CONFIRMED |
| `01` | `FE` | ✅ CONFIRMED |
| `08` | `F7` | ✅ CONFIRMED |
| `06` | `F9` | ✅ CONFIRMED |
| `06` | `F9` | ✅ CONFIRMED 2026-04-11 |
| `1C` | `E3` | ✅ CONFIRMED 2026-04-08 |
| `1E` | `E1` | ✅ CONFIRMED |
| `0A` | `F5` | ✅ CONFIRMED |
@@ -287,6 +293,8 @@ All requests use CMD byte `0x02`. All responses use CMD byte `0x10 0x02` (which,
| `98` | `67` | ✅ CONFIRMED 2026-04-08 |
| `96` | `69` | ✅ CONFIRMED 2026-04-08 |
| `97` | `68` | ✅ CONFIRMED 2026-04-08 |
| `A3` | `5C` | ✅ CONFIRMED 2026-04-11 |
| `A2` | `5D` | ✅ CONFIRMED 2026-04-11 |
---
@@ -1386,6 +1394,77 @@ Contains serial number, firmware bytes, and floating-point calibration fields. F
---
## 7.11 Erase-All Protocol (SUBs 0xA3 / 0xA2 / 0x06) ✅ 2026-04-11
> ✅ **Confirmed 2026-04-11** from MITM capture of a live Blastware ACH session
> (`bridges/captures/mitm/ach_mitm_20260411_001912/`).
Blastware uses a 4-step sequence to erase all stored events from device memory.
All frames use standard `build_bw_frame` format (NOT write-format).
### 7.11.1 Wire Sequence
```
BW → device: SUB 0xA3 offset=0x0000 params=00 00 00 00 00 00 00 FE 00 00
device → BW: SUB 0x5C (begin-erase ack)
BW → device: SUB 0x1C offset=0x0000 params=00 00 00 00 00 00 00 FE 00 00 (probe)
device → BW: SUB 0xE3 (probe ack)
BW → device: SUB 0x1C offset=0x002C params=(same) (data)
device → BW: SUB 0xE3 (44-byte monitor status response)
BW → device: SUB 0x06 offset=0x0000 params=00 00 00 00 00 00 00 FE 00 00 (probe)
device → BW: SUB 0xF9 (probe ack)
BW → device: SUB 0x06 offset=0x0024 params=(same) (data)
device → BW: SUB 0xF9 (36-byte storage range response)
BW → device: SUB 0xA2 offset=0x0000 params=00 00 00 00 00 00 00 FE 00 00
device → BW: SUB 0x5D (confirm-erase ack — device memory is now cleared)
```
All response SUBs follow the standard formula `0xFF request_SUB`. No exceptions.
The `token=0xFE` at `params[7]` is required for 0xA3, 0x06, and 0xA2.
### 7.11.2 SUB 0x06 Storage Range Response (36 bytes)
The 36-byte response from the data step ends with two 4-byte event keys:
| Offset (from response end) | Field | Notes |
|---|---|---|
| `[-8:-4]` | First stored event key | e.g. `0111ea60` before erase |
| `[-4:]` | Last stored event key | e.g. `0111eaa6` before erase |
After a successful erase:
- Both keys read `01110000` (device-empty sentinel)
- The device's internal event counter has reset
Example pre-erase: `... 0111ea60 0111eaa6`
Example post-erase: `... 01110000 01110000`
### 7.11.3 Post-Erase Key Counter Reset
After a successful erase the device resets its event counter. New events start
from key `0x01110000` — the same key as the very first event ever recorded on
the device. This means:
- Any system using event keys for deduplication must clear its "seen keys" state
after an erase, or risk treating fresh events as already downloaded.
- Detection heuristic: if `max(device_keys) < historical_max_key`, the counter
was reset. All device keys should be treated as new regardless of prior state.
The `ach_server.py` implementation stores `max_downloaded_key` in `ach_state.json`
and applies this heuristic on every call-home.
### 7.11.4 Implementation Notes
- `MiniMateClient.delete_all_events()` in `client.py` orchestrates the full sequence.
- `MiniMateProtocol` exposes `begin_erase_all()`, `confirm_erase_all()`, and
`read_event_storage_range()` as separate methods.
- The ACH server `--clear-after-download` flag calls `delete_all_events()` after a
successful event download and resets `ach_state.json` state for the unit.
---
## 8. Timestamp Format
Two timestamp wire formats are used:
@@ -1776,7 +1855,7 @@ The TCP port is **user-configurable** in both Blastware and the modem. There is
---
### 14.6 ACH Session Lifecycle (Call Home Mode — Future)
### 14.6 ACH Session Lifecycle (Call Home Mode) ✅ IMPLEMENTED 2026-04-11
When the unit calls home under ACH, the session lifecycle from the unit's perspective is:
@@ -1785,10 +1864,28 @@ When the unit calls home under ACH, the session lifecycle from the unit's perspe
3. Unit waits for "Wait for Connection" window for first BW frame from server
4. Server sends POLL_PROBE → unit responds with POLL_RESPONSE (same as serial)
5. Server reads serial number, full config, events as needed
6. Server disconnects (or unit disconnects on Serial Idle Time expiry)
7. Unit powers modem down, returns to monitor mode
6. (Optional) Server erases device memory: SUB 0xA3 → 0x1C → 0x06 → 0xA2
7. Server disconnects (or unit disconnects on Serial Idle Time expiry)
8. Unit detects DCD/DTR going low (modem signals line drop), returns to monitor mode automatically
Step 4 onward is **identical to the serial/call-up protocol**. The only difference from our perspective is that we are the **listener** rather than the **connector**. A future `AchServer` class will accept the incoming TCP connection and hand the socket to `TcpTransport` for processing.
Step 4 onward is **identical to the serial/call-up protocol**. The only difference
from our perspective is that we are the **listener** rather than the **connector**.
**Implementation: `bridges/ach_server.py`** — run with `python bridges/ach_server.py`.
Key flags:
- `--clear-after-download` — erase device memory after a successful event download
- `--allow-ip IP` — restrict to specific unit IPs
- `--max-events N` — cap events per session for safety
**State persistence: `ach_state.json`** — tracks `downloaded_keys` (set of event key
hex strings) and `max_downloaded_key` (high-water mark) per unit serial number.
Post-erase key reuse (`0x01110000` recycled) is detected via the high-water mark.
**Note on DCD/DTR:** The MiniMate Plus monitors the RS-232 DCD line. When the TCP
connection closes, the Sierra Wireless modem drops DCD, which the unit interprets as
"serial connection ended" and automatically resumes monitoring. No `start_monitoring()`
(SUB 0x96) command is needed from the server. ⚠️ Newer RV55 firmware may not assert DCD
by default — known issue, not yet resolved.
---
@@ -1841,6 +1938,11 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
| Backlight offset — **RESOLVED: +4B in event index data**, uint8, seconds | RESOLVED | 2026-03-02 | |
| Power save offset — **RESOLVED: +53 in event index data**, uint8, minutes | RESOLVED | 2026-03-02 | |
| Monitoring LCD Cycle — **RESOLVED: +54/+55 in event index data**, uint16 BE, seconds (65500 = disabled) | RESOLVED | 2026-03-02 | |
| **SUB 0x06 purpose — RESOLVED: event storage range.** Previously labeled "CHANNEL CONFIG READ". 4-11-26 MITM capture confirms it returns first/last stored event keys in the final 8 bytes of the 36-byte response. Used by Blastware as part of the erase-all verification step. | RESOLVED | 2026-04-11 | |
| **Erase-all command sequence — RESOLVED.** SUB 0xA3 (begin) + 0x1C (monitor status) + 0x06 (storage range) + 0xA2 (confirm). Confirmed from 4-11-26 MITM capture. All frames standard `build_bw_frame`, token=0xFE. | RESOLVED | 2026-04-11 | |
| **ACH inbound server — RESOLVED.** `bridges/ach_server.py` implements full inbound ACH pipeline. `--clear-after-download` flag for delete-after-upload workflow. Post-erase key-reuse detection via `max_downloaded_key` high-water mark. | RESOLVED | 2026-04-11 | |
| **Sensor Check dropdown byte location** — byte offset in 1A compliance config payload for the "Sensor Check: Before monitoring / After each event / Disabled" setting is NOT YET LOCATED. Confirmed: unit always runs with "Before monitoring" set. Need a capture with "Disabled" to diff. | MEDIUM | 2026-04-08 | Still open |
| **RV55 DCD/DTR default** — newer Sierra Wireless RV55 firmware does not assert DCD/DTR by default, so the MiniMate Plus never detects TCP disconnect and stays idle instead of resuming monitoring. Root cause: RV55 ACEmanager `DCD Control` setting. Workaround not yet found. | MEDIUM | 2026-04-11 | Still open |
---
+142 -34
View File
@@ -216,8 +216,8 @@ class MiniMateClient:
log.warning("count_events: 1E failed: %s — returning 0", exc)
return 0
log.warning(
"count_events: 1E key=%s data8=%s trailing=%s",
log.debug(
"count_events: 1E -> key=%s data8=%s trailing=%s",
key4.hex(), data8.hex(), data8[4:8].hex(),
)
@@ -241,8 +241,8 @@ class MiniMateClient:
break
try:
key4, data8 = proto.advance_event(browse=True)
log.warning(
"count_events: 1F [iter %d] key=%s data8=%s trailing=%s",
log.debug(
"count_events: 1F [iter %d] -> key=%s data8=%s trailing=%s",
count, key4.hex(), data8.hex(), data8[4:8].hex(),
)
except ProtocolError as exc:
@@ -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
@@ -426,7 +545,7 @@ class MiniMateClient:
try:
key4, data8 = proto.advance_event(browse=True)
log.info(
"get_events: 1F(browse) key=%s trailing=%s",
"get_events: 1F(browse) -> key=%s trailing=%s",
key4.hex(), data8[4:8].hex(),
)
except ProtocolError as exc:
@@ -481,7 +600,7 @@ class MiniMateClient:
try:
key4, data8 = proto.advance_event(browse=True)
log.info(
"get_events: 1F key=%s trailing=%s",
"get_events: 1F -> key=%s trailing=%s",
key4.hex(), data8[4:8].hex(),
)
except ProtocolError as exc:
@@ -910,36 +1029,25 @@ def _decode_event_count(data: bytes) -> int:
"""
Extract stored event count from SUB F7 (EVENT_INDEX_RESPONSE) payload.
Layout per §7.4 (offsets from data section start):
+00: 00 58 09 — total index size or record count ❓
+03: 00 00 00 01 — possibly stored event count = 1 ❓
Confirmed 2026-04-10 from live BE11529 event index (88 bytes):
data[10:12] uint16 BE = stored event count (confirmed: 0x0006 = 6, matches LCD)
data[3:7] uint32 BE = 0x00000001 (NOT the count — meaning TBD)
We use bytes +03..+06 interpreted as uint32 BE as the event count.
This is inferred (🔶) — the exact meaning of the first 3 bytes is unclear.
Previous implementation read uint32 at offset 3, which returned 1 regardless
of how many events were stored.
"""
if len(data) < 7:
if len(data) < 12:
log.warning("event index payload too short (%d bytes), assuming 0 events", len(data))
return 0
# Log the full payload so we can reverse-engineer the format
log.warning("event_index raw (%d bytes total):", len(data))
for off in range(0, len(data), 16):
chunk = data[off:off+16]
hex_part = " ".join(f"{b:02x}" for b in chunk)
asc_part = "".join(chr(b) if 0x20 <= b < 0x7f else "." for b in chunk)
log.warning(" [%04x]: %-47s %s", off, hex_part, asc_part)
count = struct.unpack_from(">H", data, 10)[0]
# Try the uint32 at +3 first
count = struct.unpack_from(">I", data, 3)[0]
# Sanity check: MiniMate Plus manual says max ~1000 events
# Sanity check: MiniMate Plus max storage is ~1000 events
if count > 1000:
log.warning(
"event count %d looks unreasonably large — clamping to 0", count
)
log.warning("event count %d looks unreasonably large — clamping to 0", count)
return 0
log.warning("event_index decoded count=%d (uint32 BE at offset +3)", count)
log.debug("event_index decoded count=%d (uint16 BE at offset 10)", count)
return count
@@ -1499,14 +1607,14 @@ def _encode_compliance_config(
log.warning("_encode_compliance_config: anchor not found — cannot write sample_rate")
else:
struct.pack_into(">H", buf, _anc - 6, sample_rate)
log.debug("_encode_compliance_config: sample_rate=%d offset %d", sample_rate, _anc - 6)
log.debug("_encode_compliance_config: sample_rate=%d -> offset %d", sample_rate, _anc - 6)
if record_time is not None:
if _anc < 0 or _anc + 10 > len(buf):
log.warning("_encode_compliance_config: anchor not found — cannot write record_time")
else:
struct.pack_into(">f", buf, _anc + 6, record_time)
log.debug("_encode_compliance_config: record_time=%.3f offset %d", record_time, _anc + 6)
log.debug("_encode_compliance_config: record_time=%.3f -> offset %d", record_time, _anc + 6)
# ── Numeric: channel block (Tran label + unit-string guard) ───────────────
_needs_channel = any(
@@ -1529,13 +1637,13 @@ def _encode_compliance_config(
else:
if max_range_geo is not None:
struct.pack_into(">f", buf, _tran + 28, max_range_geo)
log.debug("_encode_compliance_config: max_range_geo=%.4f offset %d", max_range_geo, _tran + 28)
log.debug("_encode_compliance_config: max_range_geo=%.4f -> offset %d", max_range_geo, _tran + 28)
if trigger_level_geo is not None:
struct.pack_into(">f", buf, _tran + 34, trigger_level_geo)
log.debug("_encode_compliance_config: trigger_level_geo=%.4f offset %d", trigger_level_geo, _tran + 34)
log.debug("_encode_compliance_config: trigger_level_geo=%.4f -> offset %d", trigger_level_geo, _tran + 34)
if alarm_level_geo is not None:
struct.pack_into(">f", buf, _tran + 42, alarm_level_geo)
log.debug("_encode_compliance_config: alarm_level_geo=%.4f offset %d", alarm_level_geo, _tran + 42)
log.debug("_encode_compliance_config: alarm_level_geo=%.4f -> offset %d", alarm_level_geo, _tran + 42)
# ── ASCII strings (64-byte slot, value at label_pos+22) ───────────────────
def _set_string(label: bytes, value: Optional[str]) -> None:
@@ -1548,7 +1656,7 @@ def _encode_compliance_config(
val_bytes = value.encode("ascii", errors="replace")[:_COMPLIANCE_VALUE_MAX - 1]
padded = val_bytes + b"\x00" * (_COMPLIANCE_VALUE_MAX - len(val_bytes))
buf[idx + _COMPLIANCE_VALUE_OFFSET : idx + _COMPLIANCE_SLOT_SIZE] = padded
log.debug("_encode_compliance_config: %r %r", label, value)
log.debug("_encode_compliance_config: %r -> %r", label, value)
_set_string(b"Project:", project)
_set_string(b"Client:", client_name)
+80 -1
View File
@@ -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: