Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9a8e50b3c | |||
| 77d9c17680 | |||
| 8a1bd34551 | |||
| 09788b931a | |||
| e712d68505 | |||
| 8f5da918b5 | |||
| a03c77af09 | |||
| 87fa9c954f | |||
| 3f7b5c07b5 | |||
| 3d2ebfc057 | |||
| 9d9c14af79 |
+101
@@ -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 0x68–0x83). 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
|
||||
|
||||
@@ -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)** | **68–83** | ✅ **new v0.8.0** |
|
||||
| **Write commands (push config to device)** | **68–83** | ✅ 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)
|
||||
|
||||
@@ -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()
|
||||
+300
-109
@@ -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,78 +136,91 @@ class AchSession:
|
||||
events_only: bool,
|
||||
max_events: Optional[int],
|
||||
state_path: Path,
|
||||
clear_after_download: bool = False,
|
||||
) -> None:
|
||||
self.sock = sock
|
||||
self.peer = peer
|
||||
self.output_dir = output_dir
|
||||
self.timeout = timeout
|
||||
self.events_only = events_only
|
||||
self.max_events = max_events
|
||||
self.state_path = state_path
|
||||
self.sock = sock
|
||||
self.peer = peer
|
||||
self.output_dir = output_dir
|
||||
self.timeout = timeout
|
||||
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
|
||||
|
||||
# ── 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()
|
||||
except Exception as exc:
|
||||
log.warning("Startup failed from %s: %s -- ignoring", self.peer, exc)
|
||||
return # no session dir created
|
||||
|
||||
# ── Step 1: startup handshake ─────────────────────────────────────
|
||||
log.info("Step 1/3: startup handshake (POLL / SUB 5B)")
|
||||
try:
|
||||
from minimateplus.protocol import MiniMateProtocol
|
||||
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
|
||||
# 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()
|
||||
except Exception as exc:
|
||||
log.error(" [FAIL] count_events failed: %s", exc)
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
# 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:
|
||||
current_count = client.count_events()
|
||||
log.info(" Unit has %d stored event(s); last downloaded count: %d",
|
||||
current_count, last_count)
|
||||
device_keys = client.list_event_keys()
|
||||
except Exception as exc:
|
||||
log.error(" [FAIL] count_events failed: %s", exc)
|
||||
return
|
||||
log.warning(" list_event_keys failed: %s -- falling back to full download", exc)
|
||||
device_keys = None
|
||||
|
||||
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)
|
||||
return
|
||||
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_event_count = current_count - last_count
|
||||
log.info(" %d new event(s) to download", new_event_count)
|
||||
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
|
||||
|
||||
# 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).
|
||||
# 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,33 +336,86 @@ 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))
|
||||
|
||||
_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)
|
||||
# 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)
|
||||
|
||||
for i, ev in enumerate(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])
|
||||
|
||||
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 [%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 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(
|
||||
" 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,
|
||||
" 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
|
||||
|
||||
# Update high-water mark
|
||||
state[unit_key] = {
|
||||
"event_count": current_count,
|
||||
"last_seen": datetime.datetime.now().isoformat(),
|
||||
"serial": serial,
|
||||
"peer": self.peer,
|
||||
"downloaded_keys": updated_keys,
|
||||
"max_downloaded_key": new_max_key,
|
||||
"last_seen": datetime.datetime.now().isoformat(),
|
||||
"serial": serial,
|
||||
"peer": self.peer,
|
||||
}
|
||||
_save_state(self.state_path, state)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 0–4 responded), then ~40s silent gap while sensor check ran, then channels 5–7 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 0xFF−SUB 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: 46–47 bytes IDLE, 48–49 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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user