v0.10.0 — monitor log entry support (SUB 0x0A partial records)
Add full decode pipeline for 0x2C partial records from the device's event list, representing continuous monitoring intervals where no threshold was crossed. These records appear interleaved with full triggered events in the browse walk and were previously ignored. minimateplus/models.py - Add MonitorLogEntry dataclass: key, start_time, stop_time, serial, geo_threshold_ips, raw_header, duration_seconds property minimateplus/protocol.py - read_waveform_header() now returns (data_rsp.data, length) — full payload including the record-type byte at position 0 — instead of the sliced header. Callers that need the old slice use raw_data[11:11+length] as before. minimateplus/client.py - Add _decode_0a_partial_header(): auto-detects 9-byte (sub_code=0x10) vs 10-byte (sub_code=0x03) timestamp format, handles 1-byte inter-timestamp gap, extracts serial via BE anchor and geo threshold via Geo: anchor. - Add get_monitor_log_entries(skip_keys=None): browse walk (1E → 0A → 1F), decodes partial records, skips full records and already-seen keys. minimateplus/__init__.py - Export MonitorLogEntry bridges/ach_server.py - After get_events(), call get_monitor_log_entries(skip_keys=seen_keys) and save new entries to monitor_log.json in the session directory. - Add _monitor_log_entry_to_dict() helper. - Include monitor log keys in downloaded_keys for state persistence. CLAUDE.md / CHANGELOG.md - Document 0x2C partial record layout (timestamp format, ASCII metadata region, 1-byte gap edge case) confirmed from 4-11-26 MITM capture. - Version bump to v0.10.0; update What's next. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,57 @@ All notable changes to seismo-relay are documented here.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.10.0 — 2026-04-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`MiniMateClient.get_monitor_log_entries(skip_keys=None)`** — browse-mode walk
|
||||||
|
(`1E → 0A → 1F`) that collects partial records (`0x2C` record type) from the device's
|
||||||
|
event list without triggering a full waveform download (no 0C or 5A). Returns
|
||||||
|
`list[MonitorLogEntry]`. Each entry represents one continuous monitoring interval where
|
||||||
|
no threshold was exceeded.
|
||||||
|
|
||||||
|
- **`_decode_0a_partial_header(raw_data, index, key4)`** in `client.py` — decodes a SUB
|
||||||
|
0x0A response payload whose record type is `0x2C`. Extracts:
|
||||||
|
- `start_time` / `stop_time` — two consecutive timestamps; auto-detects 9-byte
|
||||||
|
(sub_code=0x10, single-shot) vs 10-byte (sub_code=0x03, continuous) format from
|
||||||
|
`raw_data[11]`. Handles a 1-byte gap between the two timestamps that occurs when
|
||||||
|
ts1 and ts2 share the same minute:second.
|
||||||
|
- `serial` — device serial string found via `b"BE"` anchor scan.
|
||||||
|
- `geo_threshold_ips` — trigger level found via `b"Geo: "` anchor scan.
|
||||||
|
|
||||||
|
- **`MonitorLogEntry` dataclass** in `models.py` — new model for partial records:
|
||||||
|
`index`, `key`, `start_time`, `stop_time`, `serial`, `geo_threshold_ips`,
|
||||||
|
`raw_header`, and a `duration_seconds` property.
|
||||||
|
|
||||||
|
- **`read_waveform_header()` return value extended** — now returns `(data_rsp.data, length)`
|
||||||
|
(full payload) instead of `(data_rsp.data[11:11+length], length)`. Callers get the
|
||||||
|
complete payload including the record-type byte at position 0. Full records use
|
||||||
|
`raw_data[11:11+length]` as before; partial records are detected by `raw_data[0] == 0x2C`.
|
||||||
|
|
||||||
|
- **ACH server: monitor log collection** — after `get_events()`, calls
|
||||||
|
`get_monitor_log_entries(skip_keys=seen_keys)` and saves new entries to
|
||||||
|
`monitor_log.json` in the session directory. Monitor log keys are included in
|
||||||
|
`downloaded_keys` for state persistence (no re-processing on next call-home).
|
||||||
|
|
||||||
|
- **`_monitor_log_entry_to_dict()`** in `ach_server.py` — serialises a `MonitorLogEntry`
|
||||||
|
to a JSON-compatible dict with ISO-format timestamps.
|
||||||
|
|
||||||
|
### Protocol / Documentation
|
||||||
|
|
||||||
|
- **SUB 0x0A partial record (0x2C) format confirmed** (✅ 4-11-26 MITM capture, 12 frames):
|
||||||
|
- Record type `0x2C` at `raw_data[0]`; length < 64 bytes.
|
||||||
|
- Two timestamps at `raw_data[11:]` — start and stop of the monitoring interval.
|
||||||
|
- ASCII metadata region after timestamps: `BE<serial>\x00Geo: <float> in/s`.
|
||||||
|
- Edge case: 1-byte separator between timestamps when ts1 and ts2 share minute:second.
|
||||||
|
- 10-byte timestamp format (sub_code=0x03) signalled by `raw_data[11] == 0x10`.
|
||||||
|
|
||||||
|
- **Key reuse detection for monitor log entries** — monitor log keys are tracked alongside
|
||||||
|
event keys in `ach_state.json` so the ACH server does not re-process them after a
|
||||||
|
call-home cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.9.0 — 2026-04-11
|
## v0.9.0 — 2026-04-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
|
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
|
||||||
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
|
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
|
||||||
(Sierra Wireless RV50 / RV55). Current version: **v0.9.0**.
|
(Sierra Wireless RV50 / RV55). Current version: **v0.10.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,9 +25,9 @@ CHANGELOG.md ← version history
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current implementation state (v0.9.0)
|
## Current implementation state (v0.10.0)
|
||||||
|
|
||||||
Full read pipeline + write pipeline + erase pipeline working end-to-end over TCP/cellular:
|
Full read pipeline + write pipeline + erase pipeline + monitor log working end-to-end over TCP/cellular:
|
||||||
|
|
||||||
| Step | SUB | Status |
|
| Step | SUB | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -42,7 +42,8 @@ Full read pipeline + write pipeline + erase pipeline working end-to-end over TCP
|
|||||||
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ new v0.6.0 |
|
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ new v0.6.0 |
|
||||||
| Event advance / next key | 1F | ✅ |
|
| 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** |
|
| **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ new v0.9.0 |
|
||||||
|
| **Monitor log entries (partial 0x2C records)** | **0A browse** | ✅ **new v0.10.0** |
|
||||||
|
|
||||||
`get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F`
|
`get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F`
|
||||||
|
|
||||||
@@ -795,8 +796,134 @@ the erase). Used for post-erase detection.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Monitor log entries — SUB 0x0A partial records (confirmed 2026-04-11)
|
||||||
|
|
||||||
|
Confirmed from 4-11-26 MITM capture: 12 partial records (record type `0x2C`) and 7 full
|
||||||
|
event records (record type `0x46`) across 19 total 0x0A responses.
|
||||||
|
|
||||||
|
### Record type detection
|
||||||
|
|
||||||
|
`read_waveform_header()` returns `(raw_data, length)` where `raw_data = data_rsp.data`
|
||||||
|
(the full payload including prefix bytes). The record type is at `raw_data[0]`:
|
||||||
|
|
||||||
|
| Value | Type | How to process |
|
||||||
|
|---|---|---|
|
||||||
|
| `0x46` | Full triggered event | Normal download: 0C → 5A → 1F |
|
||||||
|
| `0x2C` | Monitor log entry (partial) | No 0C/5A; decode inline from 0A payload |
|
||||||
|
|
||||||
|
Length heuristic: `length < 0x40` (64) reliably identifies partial records across all
|
||||||
|
observed captures. Both checks (`raw_data[0] == 0x2C` and `length < 0x40`) are used.
|
||||||
|
|
||||||
|
### SUB 0x0A partial record (0x2C) payload layout
|
||||||
|
|
||||||
|
All offsets are from `raw_data` (the full `data_rsp.data` array including the 11-byte
|
||||||
|
prefix before the actual header bytes start).
|
||||||
|
|
||||||
|
```
|
||||||
|
raw_data[0] = 0x2C ← record type (partial / monitor log)
|
||||||
|
raw_data[1:11] = prefix bytes (vary; contain key4 copy, flags, length)
|
||||||
|
raw_data[11:] = timestamp and ASCII metadata payload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timestamp auto-detection** (confirmed from 4-11-26 capture):
|
||||||
|
|
||||||
|
```
|
||||||
|
raw_data[11] == 0x10 → 10-byte sub_code=0x03 format (continuous mode)
|
||||||
|
raw_data[11] != 0x10 → 9-byte sub_code=0x10 format (single-shot mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
**9-byte timestamp format (sub_code=0x10):**
|
||||||
|
|
||||||
|
| Byte | Field |
|
||||||
|
|---|---|
|
||||||
|
| 0 | day |
|
||||||
|
| 1 | `0x10` (sub_code marker) |
|
||||||
|
| 2 | month |
|
||||||
|
| 3–4 | year (uint16 BE) |
|
||||||
|
| 5 | unknown (0x00) |
|
||||||
|
| 6 | hour |
|
||||||
|
| 7 | minute |
|
||||||
|
| 8 | second |
|
||||||
|
|
||||||
|
**10-byte timestamp format (sub_code=0x03):**
|
||||||
|
|
||||||
|
| Byte | Field |
|
||||||
|
|---|---|
|
||||||
|
| 0 | `0x10` (marker) |
|
||||||
|
| 1 | day |
|
||||||
|
| 2 | `0x10` (marker) |
|
||||||
|
| 3 | month |
|
||||||
|
| 4–5 | year (uint16 BE) |
|
||||||
|
| 6 | unknown (0x00) |
|
||||||
|
| 7 | hour |
|
||||||
|
| 8 | minute |
|
||||||
|
| 9 | second |
|
||||||
|
|
||||||
|
**Two timestamps:** Each partial record contains two timestamps — `start_time` and
|
||||||
|
`stop_time` — stored consecutively:
|
||||||
|
- `ts1` (start) at `raw_data[ts_offset : ts_offset + ts_size]` where `ts_offset = 11`
|
||||||
|
- `ts2` (stop) at `raw_data[ts1_end : ts1_end + ts_size]`
|
||||||
|
|
||||||
|
**Edge case — 1-byte gap between timestamps:** Occurs when ts1 and ts2 share the same
|
||||||
|
minute:second. If `try_ts(raw_data[ts1_end:])` fails, try `try_ts(raw_data[ts1_end+1:])`.
|
||||||
|
Confirmed in frames 121, 161, 165 of the 4-11-26 MITM capture. Frame 121 still shows 0s
|
||||||
|
duration (both decode to 16:02:00) — the extra byte appears in all same-second cases.
|
||||||
|
|
||||||
|
**ASCII metadata after timestamps:**
|
||||||
|
```
|
||||||
|
<separator bytes> BE<serial>\x00Geo: <float> in/s ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- Serial: scan for `b"BE"`, read until `b"\x00"` (e.g. `"BE11529"`)
|
||||||
|
- Geo threshold: scan for `b"Geo: "`, read float until next space (e.g. `0.254` in/s)
|
||||||
|
|
||||||
|
A separator of variable length (4–5 bytes of `\x00` + flags) sits between the two
|
||||||
|
timestamps and the ASCII region. The `b"BE"` anchor scan is robust to separator length
|
||||||
|
variation.
|
||||||
|
|
||||||
|
### `_decode_0a_partial_header(raw_data, index, key4)` — client.py
|
||||||
|
|
||||||
|
Returns a `MonitorLogEntry` or `None`. Called by `get_monitor_log_entries()` for each
|
||||||
|
event key whose 0x0A response has `raw_data[0] == 0x2C` or `length < 0x40`.
|
||||||
|
|
||||||
|
### `MiniMateClient.get_monitor_log_entries(skip_keys=None)` — client.py
|
||||||
|
|
||||||
|
Browse-mode walk: `1E → 0A → check type → decode if partial → 1F`. No 0x0C or 5A reads
|
||||||
|
performed. Full (0x46) records are skipped without decoding. Returns `list[MonitorLogEntry]`.
|
||||||
|
|
||||||
|
`skip_keys` (optional `set[str]`): keys in this set are still advanced through the walk
|
||||||
|
(to avoid disrupting the iteration sequence), but no `MonitorLogEntry` is created for them.
|
||||||
|
|
||||||
|
### `MonitorLogEntry` model — models.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class MonitorLogEntry:
|
||||||
|
index: int # 0-based position
|
||||||
|
key: str # 8-hex event key
|
||||||
|
start_time: Optional[datetime.datetime] = None
|
||||||
|
stop_time: Optional[datetime.datetime] = None
|
||||||
|
serial: Optional[str] = None
|
||||||
|
geo_threshold_ips: Optional[float] = None
|
||||||
|
raw_header: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_seconds(self) -> Optional[float]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### ACH server integration (v0.10.0)
|
||||||
|
|
||||||
|
After `get_events()`, the ACH server calls `get_monitor_log_entries(skip_keys=seen_keys)`.
|
||||||
|
New entries are saved to `monitor_log.json` in the session directory. Monitor log keys are
|
||||||
|
included in `current_keys` for state persistence so they are not re-processed on the next
|
||||||
|
call-home.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What's next
|
## What's next
|
||||||
|
|
||||||
|
- **Database** — SQLite store for events + monitor log entries; dedup by key; queryable
|
||||||
|
- **Histograms** — decode histogram-mode A5 data (noise floor tracking)
|
||||||
- Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
|
- 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)
|
- Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring)
|
||||||
- Modem manager — push RV50/RV55 configs via Sierra Wireless API
|
- Modem manager — push RV50/RV55 configs via Sierra Wireless API
|
||||||
|
|||||||
+53
-2
@@ -67,7 +67,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
|
|
||||||
from minimateplus.transport import SocketTransport
|
from minimateplus.transport import SocketTransport
|
||||||
from minimateplus.client import MiniMateClient
|
from minimateplus.client import MiniMateClient
|
||||||
from minimateplus.models import DeviceInfo, Event
|
from minimateplus.models import DeviceInfo, Event, MonitorLogEntry
|
||||||
|
|
||||||
log = logging.getLogger("ach_server")
|
log = logging.getLogger("ach_server")
|
||||||
|
|
||||||
@@ -372,6 +372,42 @@ class AchSession:
|
|||||||
else:
|
else:
|
||||||
log.info(" [OK] No new events since last call-home -- nothing to save")
|
log.info(" [OK] No new events since last call-home -- nothing to save")
|
||||||
|
|
||||||
|
# ── Monitor log entries (partial records / continuous monitoring) ──
|
||||||
|
# Browse walk (0A + 1F only) to collect monitor log entries for
|
||||||
|
# recording intervals where no threshold was crossed. This is a
|
||||||
|
# second 1E-based pass over the device's record list, separate from
|
||||||
|
# the get_events() download loop above.
|
||||||
|
log.info(" Collecting monitor log entries (browse walk)...")
|
||||||
|
new_monitor_entries: list[MonitorLogEntry] = []
|
||||||
|
try:
|
||||||
|
new_monitor_entries = client.get_monitor_log_entries(
|
||||||
|
skip_keys=seen_keys if seen_keys else None,
|
||||||
|
)
|
||||||
|
if new_monitor_entries:
|
||||||
|
_save_json(
|
||||||
|
session_dir / "monitor_log.json",
|
||||||
|
[_monitor_log_entry_to_dict(e) for e in new_monitor_entries],
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
" [OK] %d new monitor log entry(s) saved",
|
||||||
|
len(new_monitor_entries),
|
||||||
|
)
|
||||||
|
for ml in new_monitor_entries:
|
||||||
|
log.info(
|
||||||
|
" MONLOG [%s] %s → %s (%s)",
|
||||||
|
ml.key,
|
||||||
|
ml.start_time.isoformat() if ml.start_time else "?",
|
||||||
|
ml.stop_time.isoformat() if ml.stop_time else "?",
|
||||||
|
f"{ml.duration_seconds:.0f}s" if ml.duration_seconds is not None else "?s",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.info(" [OK] No new monitor log entries")
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning(
|
||||||
|
" [WARN] Monitor log collection failed: %s -- continuing",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Optional: erase device memory after successful download ────
|
# ── Optional: erase device memory after successful download ────
|
||||||
erased_successfully = False
|
erased_successfully = False
|
||||||
if self.clear_after_download and new_events:
|
if self.clear_after_download and new_events:
|
||||||
@@ -387,11 +423,15 @@ class AchSession:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── Update persistent state ───────────────────────────────────
|
# ── Update persistent state ───────────────────────────────────
|
||||||
current_keys = [
|
# Include both triggered-event keys and monitor-log keys in the
|
||||||
|
# downloaded set so they are not re-processed on the next call-home.
|
||||||
|
current_event_keys = [
|
||||||
e._waveform_key.hex()
|
e._waveform_key.hex()
|
||||||
for e in all_events
|
for e in all_events
|
||||||
if e._waveform_key is not None
|
if e._waveform_key is not None
|
||||||
]
|
]
|
||||||
|
current_monitor_keys = [e.key for e in new_monitor_entries]
|
||||||
|
current_keys = current_event_keys + current_monitor_keys
|
||||||
|
|
||||||
if erased_successfully:
|
if erased_successfully:
|
||||||
# Device memory is clear. Reset downloaded_keys and the
|
# Device memory is clear. Reset downloaded_keys and the
|
||||||
@@ -492,6 +532,17 @@ def _event_to_dict(e: Event) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_log_entry_to_dict(e: MonitorLogEntry) -> dict:
|
||||||
|
return {
|
||||||
|
"key": e.key,
|
||||||
|
"start_time": e.start_time.isoformat() if e.start_time else None,
|
||||||
|
"stop_time": e.stop_time.isoformat() if e.stop_time else None,
|
||||||
|
"duration_seconds": e.duration_seconds,
|
||||||
|
"serial": e.serial,
|
||||||
|
"geo_threshold_ips": e.geo_threshold_ips,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Main server loop ───────────────────────────────────────────────────────────
|
# ── Main server loop ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def serve(args: argparse.Namespace) -> None:
|
def serve(args: argparse.Namespace) -> None:
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ Typical usage (TCP / modem):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .client import MiniMateClient
|
from .client import MiniMateClient
|
||||||
from .models import DeviceInfo, Event
|
from .models import DeviceInfo, Event, MonitorLogEntry
|
||||||
from .transport import SerialTransport, TcpTransport
|
from .transport import SerialTransport, TcpTransport
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
__all__ = ["MiniMateClient", "DeviceInfo", "Event", "SerialTransport", "TcpTransport"]
|
__all__ = ["MiniMateClient", "DeviceInfo", "Event", "MonitorLogEntry", "SerialTransport", "TcpTransport"]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Example (TCP / modem):
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -37,6 +38,7 @@ from .models import (
|
|||||||
ComplianceConfig,
|
ComplianceConfig,
|
||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
Event,
|
Event,
|
||||||
|
MonitorLogEntry,
|
||||||
MonitorStatus,
|
MonitorStatus,
|
||||||
PeakValues,
|
PeakValues,
|
||||||
ProjectInfo,
|
ProjectInfo,
|
||||||
@@ -300,6 +302,96 @@ class MiniMateClient:
|
|||||||
log.info("list_event_keys: %d key(s): %s", len(keys), keys)
|
log.info("list_event_keys: %d key(s): %s", len(keys), keys)
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
|
def get_monitor_log_entries(
|
||||||
|
self,
|
||||||
|
skip_keys: Optional[set] = None,
|
||||||
|
) -> list[MonitorLogEntry]:
|
||||||
|
"""
|
||||||
|
Collect all monitor log entries (partial records, type 0x2C) from the
|
||||||
|
device using the browse-mode 1E → 0A → 1F walk.
|
||||||
|
|
||||||
|
This is the fast path for monitor log data. No 0C or 5A commands are
|
||||||
|
issued — all available monitor log information is in the 0x0A response
|
||||||
|
header alone.
|
||||||
|
|
||||||
|
Full triggered events (0x0A response type 0x46) are silently skipped.
|
||||||
|
Only partial records (type 0x2C) are returned as MonitorLogEntry objects.
|
||||||
|
|
||||||
|
Confirmed from 4-11-26 MITM capture: Blastware's ACH mode performs a
|
||||||
|
full browse walk (Phase 3: 0x0A + 1F × all records) AFTER the triggered-
|
||||||
|
event download phase. The partial records encountered in this walk are
|
||||||
|
the monitor log entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skip_keys: optional set of 8-hex key strings to skip (already seen).
|
||||||
|
Keys in this set still advance the walk (0A + 1F) but are
|
||||||
|
not decoded or returned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of MonitorLogEntry objects in device storage order.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProtocolError: on unrecoverable communication failure.
|
||||||
|
"""
|
||||||
|
proto = self._require_proto()
|
||||||
|
try:
|
||||||
|
key4, data8 = proto.read_event_first()
|
||||||
|
except ProtocolError as exc:
|
||||||
|
log.warning("get_monitor_log_entries: 1E failed: %s -- returning []", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if data8[4:8] == b"\x00\x00\x00\x00":
|
||||||
|
log.info("get_monitor_log_entries: device is empty")
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries: list[MonitorLogEntry] = []
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
while data8[4:8] != b"\x00\x00\x00\x00":
|
||||||
|
cur_key = key4
|
||||||
|
key_hex = cur_key.hex()
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_data, rec_len = proto.read_waveform_header(cur_key)
|
||||||
|
except ProtocolError as exc:
|
||||||
|
log.warning(
|
||||||
|
"get_monitor_log_entries: 0A failed for key=%s: %s -- stopping",
|
||||||
|
key_hex, exc,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Only decode partial records (0x2C); full records (0x46) are silently skipped.
|
||||||
|
if rec_len < 0x40 and raw_data and (not skip_keys or key_hex not in skip_keys):
|
||||||
|
entry = _decode_0a_partial_header(raw_data, idx, cur_key)
|
||||||
|
if entry is not None:
|
||||||
|
entries.append(entry)
|
||||||
|
log.debug(
|
||||||
|
"get_monitor_log_entries: [%d] key=%s %s → %s",
|
||||||
|
idx, key_hex, entry.start_time, entry.stop_time,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.debug(
|
||||||
|
"get_monitor_log_entries: [%d] key=%s type=0x%02X %s",
|
||||||
|
idx, key_hex, rec_len,
|
||||||
|
"skip (already seen)" if skip_keys and key_hex in skip_keys else "skip (full record)",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
key4, data8 = proto.advance_event(browse=True)
|
||||||
|
except ProtocolError as exc:
|
||||||
|
log.warning(
|
||||||
|
"get_monitor_log_entries: 1F failed after %d record(s): %s -- stopping",
|
||||||
|
idx, exc,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"get_monitor_log_entries: walked %d record(s), found %d monitor log entry(s)",
|
||||||
|
idx, len(entries),
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
def delete_all_events(self) -> None:
|
def delete_all_events(self) -> None:
|
||||||
"""
|
"""
|
||||||
Erase all stored events from the device memory.
|
Erase all stored events from the device memory.
|
||||||
@@ -1856,6 +1948,123 @@ def _find_first_string(data: bytes, start: int, end: int, min_len: int) -> Optio
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_0a_partial_header(raw_data: bytes, index: int, key4: bytes) -> Optional[MonitorLogEntry]:
|
||||||
|
"""
|
||||||
|
Decode a SUB 0x0A response for a partial (monitor log) record into a
|
||||||
|
MonitorLogEntry.
|
||||||
|
|
||||||
|
Called when read_waveform_header() returns rec_len < 0x40 (i.e. 0x2C = 44).
|
||||||
|
raw_data is the complete data_rsp.data from the protocol layer.
|
||||||
|
|
||||||
|
Layout of raw_data:
|
||||||
|
[0] = 0x2C (partial record type)
|
||||||
|
[1:5] = 0x00 × 4
|
||||||
|
[5:9] = event key (big-endian)
|
||||||
|
[9:11] = 0x00 × 2
|
||||||
|
[11:] = timestamp_start + timestamp_stop + sep + serial + geo_string
|
||||||
|
|
||||||
|
Timestamp format detection (auto):
|
||||||
|
raw_data[11] == 0x10 → 10-byte sub_code=0x03 continuous format
|
||||||
|
raw_data[12] == 0x10 → 9-byte sub_code=0x10 single-shot format
|
||||||
|
|
||||||
|
Both timestamps use the same format (detected from the first byte).
|
||||||
|
A 1-byte gap can appear between ts1 and ts2 for certain timestamps
|
||||||
|
(observed empirically when both timestamps share the same minute:second).
|
||||||
|
The parser handles this by trying ts2 immediately after ts1, then with
|
||||||
|
a 1-byte skip if that fails.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MonitorLogEntry if decoding succeeds, None on error.
|
||||||
|
"""
|
||||||
|
if len(raw_data) < 20 or raw_data[0] != 0x2C:
|
||||||
|
return None
|
||||||
|
|
||||||
|
key_hex = key4.hex()
|
||||||
|
|
||||||
|
def try_ts9(b: bytes):
|
||||||
|
"""9-byte sub_code=0x10 format. Returns datetime or None."""
|
||||||
|
if len(b) < 9 or b[1] != 0x10:
|
||||||
|
return None
|
||||||
|
day = b[0]; month = b[2]; year = (b[3] << 8) | b[4]
|
||||||
|
hr = b[6]; mn = b[7]; sec = b[8]
|
||||||
|
if not (1 <= day <= 31 and 1 <= month <= 12 and 2000 <= year <= 2050
|
||||||
|
and hr <= 23 and mn <= 59 and sec <= 59):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.datetime(year, month, day, hr, mn, sec)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_ts10(b: bytes):
|
||||||
|
"""10-byte sub_code=0x03 format. Returns datetime or None."""
|
||||||
|
if len(b) < 10 or b[0] != 0x10 or b[2] != 0x10:
|
||||||
|
return None
|
||||||
|
day = b[1]; month = b[3]; year = (b[4] << 8) | b[5]
|
||||||
|
hr = b[7]; mn = b[8]; sec = b[9]
|
||||||
|
if not (1 <= day <= 31 and 1 <= month <= 12 and 2000 <= year <= 2050
|
||||||
|
and hr <= 23 and mn <= 59 and sec <= 59):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.datetime(year, month, day, hr, mn, sec)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ts_offset = 11
|
||||||
|
if len(raw_data) <= ts_offset:
|
||||||
|
return MonitorLogEntry(index=index, key=key_hex, raw_header=raw_data)
|
||||||
|
|
||||||
|
# Detect timestamp format.
|
||||||
|
if raw_data[ts_offset] == 0x10:
|
||||||
|
ts_size = 10
|
||||||
|
try_ts = try_ts10
|
||||||
|
else:
|
||||||
|
ts_size = 9
|
||||||
|
try_ts = try_ts9
|
||||||
|
|
||||||
|
# Parse ts1.
|
||||||
|
ts1 = try_ts(raw_data[ts_offset:ts_offset + ts_size])
|
||||||
|
ts1_end = ts_offset + ts_size
|
||||||
|
|
||||||
|
# Parse ts2 immediately after ts1, then with 1-byte skip if needed.
|
||||||
|
ts2 = try_ts(raw_data[ts1_end:ts1_end + ts_size])
|
||||||
|
if ts2 is None:
|
||||||
|
ts2 = try_ts(raw_data[ts1_end + 1:ts1_end + 1 + ts_size])
|
||||||
|
|
||||||
|
# Extract serial and geo threshold from "BE11529\0" and "Geo: X.XXX in/s\0".
|
||||||
|
serial: Optional[str] = None
|
||||||
|
geo_ips: Optional[float] = None
|
||||||
|
|
||||||
|
serial_pos = raw_data.find(b"BE")
|
||||||
|
if serial_pos >= 0:
|
||||||
|
# Read null-terminated serial starting at serial_pos.
|
||||||
|
null_pos = raw_data.find(b"\x00", serial_pos)
|
||||||
|
if null_pos > serial_pos:
|
||||||
|
serial = raw_data[serial_pos:null_pos].decode("ascii", errors="replace")
|
||||||
|
# Geo string follows the null byte.
|
||||||
|
geo_start = (null_pos + 1) if null_pos > serial_pos else serial_pos + 7
|
||||||
|
geo_bytes = raw_data[geo_start:]
|
||||||
|
# "Geo: X.XXX in/s\0" — extract float after "Geo: ".
|
||||||
|
geo_str_pos = geo_bytes.find(b"Geo: ")
|
||||||
|
if geo_str_pos >= 0:
|
||||||
|
geo_val_bytes = geo_bytes[geo_str_pos + 5:] # after "Geo: "
|
||||||
|
geo_val_end = geo_val_bytes.find(b" ") # before " in/s"
|
||||||
|
if geo_val_end > 0:
|
||||||
|
try:
|
||||||
|
geo_ips = float(geo_val_bytes[:geo_val_end].decode("ascii"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return MonitorLogEntry(
|
||||||
|
index=index,
|
||||||
|
key=key_hex,
|
||||||
|
start_time=ts1,
|
||||||
|
stop_time=ts2,
|
||||||
|
serial=serial,
|
||||||
|
geo_threshold_ips=geo_ips,
|
||||||
|
raw_header=raw_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _decode_monitor_status(data: bytes) -> MonitorStatus:
|
def _decode_monitor_status(data: bytes) -> MonitorStatus:
|
||||||
"""
|
"""
|
||||||
Decode SUB 0x1C response payload into a MonitorStatus object.
|
Decode SUB 0x1C response payload into a MonitorStatus object.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Notes on certainty:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -419,6 +420,65 @@ class Event:
|
|||||||
return f"Event#{self.index} {ts}{ppv}"
|
return f"Event#{self.index} {ts}{ppv}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── MonitorLogEntry ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MonitorLogEntry:
|
||||||
|
"""
|
||||||
|
A monitor log entry decoded from a SUB 0x0A (WAVEFORM_HEADER) response
|
||||||
|
whose first byte is 0x2C (partial record, recording mode = continuous
|
||||||
|
monitoring without a triggered event).
|
||||||
|
|
||||||
|
These are the "partial bins" that Blastware stores between triggered events.
|
||||||
|
Each entry represents one monitoring interval — the span of time during
|
||||||
|
which the unit was actively monitoring but no threshold crossing occurred.
|
||||||
|
|
||||||
|
Confirmed from 4-11-26 MITM capture analysis (2026-04-11):
|
||||||
|
|
||||||
|
Header layout (full response data[0:]):
|
||||||
|
data[0] = 0x2C (partial record type / data length in probe response)
|
||||||
|
data[1:5] = 0x00 × 4
|
||||||
|
data[5:9] = event key (4 bytes, big-endian hex)
|
||||||
|
data[9:11] = 0x00 × 2
|
||||||
|
data[11:] = timestamp_start (9 or 10 bytes depending on recording mode)
|
||||||
|
+ timestamp_stop (same format)
|
||||||
|
+ separator (4–5 bytes, variable)
|
||||||
|
+ serial null-terminated (e.g. "BE11529\\0")
|
||||||
|
+ "Geo: X.XXX in/s\\0" (trigger threshold string)
|
||||||
|
|
||||||
|
Timestamp format detection:
|
||||||
|
data[11] == 0x10 → 10-byte sub_code=0x03 (continuous) format
|
||||||
|
data[12] == 0x10 → 9-byte sub_code=0x10 (single-shot) format
|
||||||
|
|
||||||
|
In contrast to Event (triggered records, type 0x46), MonitorLogEntry
|
||||||
|
records do NOT have a waveform record (SUB 0x0C) or bulk waveform stream
|
||||||
|
(SUB 5A). All available metadata is in the 0x0A header alone.
|
||||||
|
"""
|
||||||
|
index: int # 0-based position in device record list
|
||||||
|
key: str # 8-hex event key (e.g. "01114290") ✅
|
||||||
|
|
||||||
|
start_time: Optional[datetime.datetime] = None # monitoring session start ✅
|
||||||
|
stop_time: Optional[datetime.datetime] = None # monitoring session stop ✅
|
||||||
|
serial: Optional[str] = None # device serial (e.g. "BE11529") ✅
|
||||||
|
geo_threshold_ips: Optional[float] = None # trigger level from "Geo: X.XXX in/s" ✅
|
||||||
|
|
||||||
|
# Raw bytes for debugging / future decoding
|
||||||
|
raw_header: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_seconds(self) -> Optional[float]:
|
||||||
|
"""Duration of monitoring interval in seconds, or None if times unavailable."""
|
||||||
|
if self.start_time and self.stop_time:
|
||||||
|
return (self.stop_time - self.start_time).total_seconds()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
start = self.start_time.isoformat() if self.start_time else "?"
|
||||||
|
stop = self.stop_time.isoformat() if self.stop_time else "?"
|
||||||
|
dur = f" ({self.duration_seconds:.0f}s)" if self.duration_seconds is not None else ""
|
||||||
|
return f"MonitorLog#{self.index} key={self.key} {start}→{stop}{dur}"
|
||||||
|
|
||||||
|
|
||||||
# ── MonitorStatus ─────────────────────────────────────────────────────────────
|
# ── MonitorStatus ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+19
-11
@@ -394,23 +394,32 @@ class MiniMateProtocol:
|
|||||||
Send the SUB 0A (WAVEFORM_HEADER) two-step read for *key4*.
|
Send the SUB 0A (WAVEFORM_HEADER) two-step read for *key4*.
|
||||||
|
|
||||||
The data length for 0A is VARIABLE and must be read from the probe
|
The data length for 0A is VARIABLE and must be read from the probe
|
||||||
response at data[4]. Two known values:
|
response at data[4]. Two confirmed values:
|
||||||
0x30 — full histogram bin (has a waveform record to follow)
|
0x46 (70) — full triggered event (has 0C waveform record to follow)
|
||||||
0x26 — partial histogram bin (no waveform record)
|
0x2C (44) — partial / monitor-log entry (no 0C record; 0A header only)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key4: 4-byte waveform record address from 1E or 1F.
|
key4: 4-byte waveform record address from 1E or 1F.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(header_bytes, record_length) where:
|
(raw_data, record_length) where:
|
||||||
header_bytes — raw data section starting at data[11]
|
raw_data — complete data_rsp.data bytes (full response payload)
|
||||||
record_length — DATA_LENGTH read from probe (0x30 or 0x26)
|
record_length — DATA_LENGTH read from probe (0x46 for full, 0x2C for partial)
|
||||||
|
|
||||||
|
The raw_data layout:
|
||||||
|
raw_data[0] = record type (0x46 = full triggered event, 0x2C = partial/monitor)
|
||||||
|
raw_data[1:5] = 0x00 × 4
|
||||||
|
raw_data[5:9] = event key (4 bytes)
|
||||||
|
raw_data[9:11] = 0x00 × 2
|
||||||
|
raw_data[11:] = timestamps + separator + serial + channel strings
|
||||||
|
(see MonitorLogEntry in models.py for full layout)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
||||||
|
|
||||||
Confirmed from 3-31-26 capture: 0A probe response data[4] carries
|
Confirmed from 4-11-26 MITM capture: 0A probe response data[4] carries
|
||||||
the variable length; data-request uses that length as the offset byte.
|
the variable length; data-request uses that length as the offset byte.
|
||||||
|
record_length == data[0] in virtually all cases (confirmed empirically).
|
||||||
"""
|
"""
|
||||||
rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_HEADER)
|
rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_HEADER)
|
||||||
params = waveform_key_params(key4)
|
params = waveform_key_params(key4)
|
||||||
@@ -420,7 +429,7 @@ class MiniMateProtocol:
|
|||||||
probe_rsp = self._recv_one(expected_sub=rsp_sub)
|
probe_rsp = self._recv_one(expected_sub=rsp_sub)
|
||||||
|
|
||||||
# Variable length — read from probe response data[4]
|
# Variable length — read from probe response data[4]
|
||||||
length = probe_rsp.data[4] if len(probe_rsp.data) > 4 else 0x30
|
length = probe_rsp.data[4] if len(probe_rsp.data) > 4 else 0x46
|
||||||
log.debug("read_waveform_header: 0A data request offset=0x%02X", length)
|
log.debug("read_waveform_header: 0A data request offset=0x%02X", length)
|
||||||
|
|
||||||
if length == 0:
|
if length == 0:
|
||||||
@@ -429,12 +438,11 @@ class MiniMateProtocol:
|
|||||||
self._send(build_bw_frame(SUB_WAVEFORM_HEADER, length, params))
|
self._send(build_bw_frame(SUB_WAVEFORM_HEADER, length, params))
|
||||||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||||||
|
|
||||||
header_bytes = data_rsp.data[11:11 + length]
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"read_waveform_header: key=%s length=0x%02X is_full=%s",
|
"read_waveform_header: key=%s length=0x%02X is_full=%s",
|
||||||
key4.hex(), length, length == 0x30,
|
key4.hex(), length, length >= 0x40,
|
||||||
)
|
)
|
||||||
return header_bytes, length
|
return data_rsp.data, length
|
||||||
|
|
||||||
def read_waveform_data_raw(self) -> bytes:
|
def read_waveform_data_raw(self) -> bytes:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user