ef2c38e7db
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>
499 lines
23 KiB
Python
499 lines
23 KiB
Python
"""
|
||
models.py — Plain-Python data models for the MiniMate Plus protocol library.
|
||
|
||
All models are intentionally simple dataclasses with no protocol logic.
|
||
They represent *decoded* device data — the client layer translates raw frame
|
||
bytes into these objects, and the SFM API layer serialises them to JSON.
|
||
|
||
Notes on certainty:
|
||
Fields marked ✅ are confirmed from captured data.
|
||
Fields marked 🔶 are strongly inferred but not formally proven.
|
||
Fields marked ❓ are present in the captured payload but not yet decoded.
|
||
See docs/instantel_protocol_reference.md for full derivation details.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime
|
||
import struct
|
||
from dataclasses import dataclass, field
|
||
from typing import Optional
|
||
|
||
|
||
# ── Timestamp ─────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class Timestamp:
|
||
"""
|
||
Event timestamp decoded from the MiniMate Plus wire format.
|
||
|
||
Two source formats exist:
|
||
|
||
1. 6-byte format (from event index / 1E header — not yet decoded in client):
|
||
[flag:1] [year:2 BE] [unknown:1] [month:1] [day:1]
|
||
Use Timestamp.from_bytes().
|
||
|
||
2. 9-byte format (from Full Waveform Record / 0C, bytes 0–8) ✅ CONFIRMED:
|
||
[day:1] [sub_code:1] [month:1] [year:2 BE] [unknown:1] [hour:1] [min:1] [sec:1]
|
||
Use Timestamp.from_waveform_record().
|
||
|
||
Confirmed 2026-04-01 against Blastware event report (BE11529 thump event):
|
||
raw bytes: 01 10 04 07 ea 00 00 1c 0c
|
||
→ day=1, sub_code=0x10 (Waveform mode), month=4, year=2026,
|
||
hour=0, minute=28, second=12 ← matches Blastware "00:28:12 April 1, 2026"
|
||
|
||
The sub_code at byte[1] is the record-mode indicator:
|
||
0x10 → Waveform (continuous / single-shot) ✅
|
||
other → Histogram (code not yet captured ❓)
|
||
|
||
The year 1995 is the device's factory-default RTC date — it appears
|
||
whenever the battery has been disconnected. Treat 1995 as "clock not set".
|
||
"""
|
||
raw: bytes # raw bytes for round-tripping
|
||
flag: int # byte 0 of 6-byte format, or sub_code from 9-byte format
|
||
year: int # ✅
|
||
unknown_byte: int # separator byte (purpose unclear ❓)
|
||
month: int # ✅
|
||
day: int # ✅
|
||
|
||
# Time fields — populated only from the 9-byte waveform-record format
|
||
hour: Optional[int] = None # ✅ (waveform record format)
|
||
minute: Optional[int] = None # ✅ (waveform record format)
|
||
second: Optional[int] = None # ✅ (waveform record format)
|
||
|
||
@classmethod
|
||
def from_bytes(cls, data: bytes) -> "Timestamp":
|
||
"""
|
||
Decode a 6-byte timestamp (6-byte event-index format).
|
||
|
||
Args:
|
||
data: exactly 6 bytes from the device payload.
|
||
|
||
Returns:
|
||
Decoded Timestamp (no time fields).
|
||
|
||
Raises:
|
||
ValueError: if data is not exactly 6 bytes.
|
||
"""
|
||
if len(data) != 6:
|
||
raise ValueError(f"Timestamp requires exactly 6 bytes, got {len(data)}")
|
||
flag = data[0]
|
||
year = struct.unpack_from(">H", data, 1)[0]
|
||
unknown_byte = data[3]
|
||
month = data[4]
|
||
day = data[5]
|
||
return cls(
|
||
raw=bytes(data),
|
||
flag=flag,
|
||
year=year,
|
||
unknown_byte=unknown_byte,
|
||
month=month,
|
||
day=day,
|
||
)
|
||
|
||
@classmethod
|
||
def from_waveform_record(cls, data: bytes) -> "Timestamp":
|
||
"""
|
||
Decode a 9-byte timestamp from the first bytes of a 210-byte waveform
|
||
record (SUB 0C / Full Waveform Record response).
|
||
|
||
Wire layout (✅ CONFIRMED 2026-04-01 against Blastware event report):
|
||
byte[0]: day (uint8)
|
||
byte[1]: sub_code / mode flag (0x10 = Waveform single-shot)
|
||
byte[2]: month (uint8)
|
||
bytes[3–4]: year (big-endian uint16)
|
||
byte[5]: unknown (0x00 in all observed samples)
|
||
byte[6]: hour (uint8)
|
||
byte[7]: minute (uint8)
|
||
byte[8]: second (uint8)
|
||
|
||
Used for sub_code=0x10 records only. For sub_code=0x03 (continuous
|
||
mode) use from_continuous_record() — the layout is shifted by 1 byte.
|
||
|
||
Args:
|
||
data: at least 9 bytes; only the first 9 are consumed.
|
||
|
||
Returns:
|
||
Decoded Timestamp with hour/minute/second populated.
|
||
|
||
Raises:
|
||
ValueError: if data is fewer than 9 bytes.
|
||
"""
|
||
if len(data) < 9:
|
||
raise ValueError(
|
||
f"Waveform record timestamp requires at least 9 bytes, got {len(data)}"
|
||
)
|
||
day = data[0]
|
||
sub_code = data[1] # 0x10 = Waveform single-shot
|
||
month = data[2]
|
||
year = struct.unpack_from(">H", data, 3)[0]
|
||
unknown_byte = data[5]
|
||
hour = data[6]
|
||
minute = data[7]
|
||
second = data[8]
|
||
return cls(
|
||
raw=bytes(data[:9]),
|
||
flag=sub_code,
|
||
year=year,
|
||
unknown_byte=unknown_byte,
|
||
month=month,
|
||
day=day,
|
||
hour=hour,
|
||
minute=minute,
|
||
second=second,
|
||
)
|
||
|
||
@classmethod
|
||
def from_continuous_record(cls, data: bytes) -> "Timestamp":
|
||
"""
|
||
Decode a 10-byte timestamp from the first bytes of a sub_code=0x03
|
||
(Waveform Continuous) 210-byte record.
|
||
|
||
Wire layout (✅ CONFIRMED 2026-04-03 against Blastware event report,
|
||
event recorded at 15:20:17 April 3 2026, raw: 10 03 10 04 07 ea 00 0f 14 11):
|
||
byte[0]: unknown_a (0x10 observed — meaning TBD)
|
||
byte[1]: day (uint8)
|
||
byte[2]: unknown_b (0x10 observed — meaning TBD)
|
||
bytes[3]: month (uint8)
|
||
bytes[4–5]: year (big-endian uint16)
|
||
byte[6]: unknown (0x00 in all observed samples)
|
||
byte[7]: hour (uint8)
|
||
byte[8]: minute (uint8)
|
||
byte[9]: second (uint8)
|
||
|
||
This is the sub_code=0x10 layout shifted forward by 1 byte, with two
|
||
extra unknown bytes at [0] and [2]. The sub_code (0x03) itself is at
|
||
byte[1] in the raw record, which also encodes the day — but the day
|
||
value (3 = April 3rd) happens to differ from the sub_code (0x03) only
|
||
in semantics; the byte is shared.
|
||
|
||
Args:
|
||
data: at least 10 bytes; only the first 10 are consumed.
|
||
|
||
Returns:
|
||
Decoded Timestamp with hour/minute/second populated.
|
||
|
||
Raises:
|
||
ValueError: if data is fewer than 10 bytes.
|
||
"""
|
||
if len(data) < 10:
|
||
raise ValueError(
|
||
f"Continuous record timestamp requires at least 10 bytes, got {len(data)}"
|
||
)
|
||
unknown_a = data[0] # 0x10 observed; meaning unknown
|
||
day = data[1] # doubles as the sub_code byte (0x03) — day=3 on Apr 3
|
||
unknown_b = data[2] # 0x10 observed; meaning unknown
|
||
month = data[3]
|
||
year = struct.unpack_from(">H", data, 4)[0]
|
||
unknown_byte = data[6]
|
||
hour = data[7]
|
||
minute = data[8]
|
||
second = data[9]
|
||
return cls(
|
||
raw=bytes(data[:10]),
|
||
flag=unknown_a,
|
||
year=year,
|
||
unknown_byte=unknown_byte,
|
||
month=month,
|
||
day=day,
|
||
hour=hour,
|
||
minute=minute,
|
||
second=second,
|
||
)
|
||
|
||
@property
|
||
def clock_set(self) -> bool:
|
||
"""False when year == 1995 (factory default / battery-lost state)."""
|
||
return self.year != 1995
|
||
|
||
def __str__(self) -> str:
|
||
if not self.clock_set:
|
||
return f"CLOCK_NOT_SET ({self.year}-{self.month:02d}-{self.day:02d})"
|
||
date_str = f"{self.year}-{self.month:02d}-{self.day:02d}"
|
||
if self.hour is not None:
|
||
return f"{date_str} {self.hour:02d}:{self.minute:02d}:{self.second:02d}"
|
||
return date_str
|
||
|
||
|
||
# ── Device identity ───────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class DeviceInfo:
|
||
"""
|
||
Combined device identity information gathered during the startup sequence.
|
||
|
||
Populated from three response SUBs:
|
||
- SUB EA (SERIAL_NUMBER_RESPONSE): serial, firmware_minor
|
||
- SUB FE (FULL_CONFIG_RESPONSE): serial (repeat), firmware_version,
|
||
dsp_version, manufacturer, model
|
||
- SUB A4 (POLL_RESPONSE): manufacturer (repeat), model (repeat)
|
||
|
||
All string fields are stripped of null padding before storage.
|
||
"""
|
||
|
||
# ── From SUB EA (SERIAL_NUMBER_RESPONSE) ─────────────────────────────────
|
||
serial: str # e.g. "BE18189" ✅
|
||
firmware_minor: int # 0x11 = 17 for S337.17 ✅
|
||
serial_trail_0: Optional[int] = None # unit-specific byte — purpose unknown ❓
|
||
|
||
# ── From SUB FE (FULL_CONFIG_RESPONSE) ────────────────────────────────────
|
||
firmware_version: Optional[str] = None # e.g. "S337.17" ✅
|
||
dsp_version: Optional[str] = None # e.g. "10.72" ✅
|
||
manufacturer: Optional[str] = None # e.g. "Instantel" ✅
|
||
model: Optional[str] = None # e.g. "MiniMate Plus" ✅
|
||
|
||
# ── From SUB 1A (COMPLIANCE_CONFIG_RESPONSE) ──────────────────────────────
|
||
compliance_config: Optional["ComplianceConfig"] = None # E5 response, read in connect()
|
||
|
||
# ── From SUB 08 (EVENT_INDEX_RESPONSE) ────────────────────────────────────
|
||
event_count: Optional[int] = None # stored event count from F7 response 🔶
|
||
|
||
def __str__(self) -> str:
|
||
fw = self.firmware_version or f"?.{self.firmware_minor}"
|
||
mdl = self.model or "MiniMate Plus"
|
||
return f"{mdl} S/N:{self.serial} FW:{fw}"
|
||
|
||
|
||
# ── Channel threshold / scaling ───────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class ChannelConfig:
|
||
"""
|
||
Per-channel threshold and scaling values from SUB E5 / SUB 71.
|
||
|
||
Floats are stored in the device in imperial units (in/s for geo channels,
|
||
psi for MicL). Unit strings embedded in the payload confirm this.
|
||
|
||
Certainty: ✅ CONFIRMED for trigger_level, alarm_level, unit strings.
|
||
"""
|
||
label: str # e.g. "Tran", "Vert", "Long", "MicL" ✅
|
||
trigger_level: float # in/s (geo) or psi (MicL) ✅
|
||
alarm_level: float # in/s (geo) or psi (MicL) ✅
|
||
max_range: float # full-scale calibration constant (e.g. 6.206) 🔶
|
||
unit_label: str # e.g. "in./s" or "psi" ✅
|
||
|
||
|
||
# ── Peak values for one event ─────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class PeakValues:
|
||
"""
|
||
Per-channel peak particle velocity / pressure for a single event, plus the
|
||
scalar Peak Vector Sum.
|
||
|
||
Extracted from the Full Waveform Record (SUB F3 / 0C response), stored as
|
||
IEEE 754 big-endian floats in the device's native units (in/s / psi).
|
||
|
||
Per-channel PPV location (✅ CONFIRMED 2026-04-01):
|
||
Found by searching for the 4-byte channel label string ("Tran", "Vert",
|
||
"Long", "MicL") and reading the float at label_offset + 6.
|
||
|
||
Peak Vector Sum (✅ CONFIRMED 2026-04-01):
|
||
Fixed offset 87 in the 210-byte record.
|
||
= √(Tran² + Vert² + Long²) at the sample instant of maximum combined
|
||
geo motion. NOT the vector sum of the three per-channel peak values
|
||
(those may occur at different times).
|
||
Matches Blastware's "Peak Vector Sum" display exactly.
|
||
"""
|
||
tran: Optional[float] = None # Transverse PPV (in/s) ✅
|
||
vert: Optional[float] = None # Vertical PPV (in/s) ✅
|
||
long: Optional[float] = None # Longitudinal PPV (in/s) ✅
|
||
micl: Optional[float] = None # Air overpressure (psi) 🔶 (units uncertain)
|
||
peak_vector_sum: Optional[float] = None # Scalar geo PVS (in/s) ✅
|
||
|
||
|
||
# ── Project / operator metadata ───────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class ProjectInfo:
|
||
"""
|
||
Operator-supplied project and location strings from the Full Waveform
|
||
Record (SUB F3) and compliance config block (SUB E5 / SUB 71).
|
||
|
||
All fields are optional — they may be blank if the operator did not fill
|
||
them in through Blastware.
|
||
"""
|
||
setup_name: Optional[str] = None # "Standard Recording Setup"
|
||
project: Optional[str] = None # project description
|
||
client: Optional[str] = None # client name ✅ confirmed offset
|
||
operator: Optional[str] = None # operator / user name
|
||
sensor_location: Optional[str] = None # sensor location string
|
||
notes: Optional[str] = None # extended notes
|
||
|
||
|
||
# ── Compliance Config ──────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class ComplianceConfig:
|
||
"""
|
||
Device compliance and recording configuration from SUB 1A (response E5).
|
||
|
||
Contains device-wide settings like record time, trigger/alarm thresholds,
|
||
and operator-supplied strings. This is read once during connect() and
|
||
cached in DeviceInfo.
|
||
|
||
All fields are optional — some may not be decoded yet or may be absent
|
||
from the device configuration.
|
||
"""
|
||
raw: Optional[bytes] = None # full 2090-byte payload (for debugging)
|
||
|
||
# Recording parameters (✅ CONFIRMED from §7.6)
|
||
record_time: Optional[float] = None # seconds (7.0, 10.0, 13.0, etc.)
|
||
sample_rate: Optional[int] = None # sps (1024, 2048, 4096, etc.) — NOT YET FOUND ❓
|
||
|
||
# Trigger/alarm levels (✅ CONFIRMED per-channel at §7.6)
|
||
# For now we store the first geo channel (Transverse) as representatives;
|
||
# full per-channel data would require structured Channel objects.
|
||
trigger_level_geo: Optional[float] = None # in/s (first geo channel)
|
||
alarm_level_geo: Optional[float] = None # in/s (first geo channel)
|
||
max_range_geo: Optional[float] = None # in/s full-scale range
|
||
|
||
# Project/setup strings (sourced from E5 / SUB 71 write payload)
|
||
# These are the FULL project metadata from compliance config,
|
||
# complementing the sparse ProjectInfo found in the waveform record (SUB 0C).
|
||
setup_name: Optional[str] = None # "Standard Recording Setup"
|
||
project: Optional[str] = None # project description
|
||
client: Optional[str] = None # client name
|
||
operator: Optional[str] = None # operator / user name
|
||
sensor_location: Optional[str] = None # sensor location string
|
||
notes: Optional[str] = None # extended notes / additional info
|
||
|
||
|
||
# ── Event ─────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class Event:
|
||
"""
|
||
A single seismic event record downloaded from the device.
|
||
|
||
Populated progressively across several request/response pairs:
|
||
1. SUB 1E (EVENT_HEADER) → index, timestamp, sample_rate
|
||
2. SUB 0C (FULL_WAVEFORM_RECORD) → peak_values, project_info, record_type
|
||
3. SUB 5A (BULK_WAVEFORM_STREAM) → raw_samples (downloaded on demand)
|
||
|
||
Fields not yet retrieved are None.
|
||
"""
|
||
# ── Identity ──────────────────────────────────────────────────────────────
|
||
index: int # 0-based event number on device
|
||
|
||
# ── From EVENT_HEADER (SUB 1E) ────────────────────────────────────────────
|
||
timestamp: Optional[Timestamp] = None # 6-byte timestamp ✅
|
||
sample_rate: Optional[int] = None # samples/sec (e.g. 1024) 🔶
|
||
|
||
# ── From FULL_WAVEFORM_RECORD (SUB F3) ───────────────────────────────────
|
||
peak_values: Optional[PeakValues] = None
|
||
project_info: Optional[ProjectInfo] = None
|
||
record_type: Optional[str] = None # e.g. "Histogram", "Waveform" 🔶
|
||
|
||
# ── From BULK_WAVEFORM_STREAM (SUB 5A) ───────────────────────────────────
|
||
# Raw ADC samples keyed by channel label. Not fetched unless explicitly
|
||
# requested (large data transfer — up to several MB per event).
|
||
raw_samples: Optional[dict] = None # {"Tran": [...], "Vert": [...], ...}
|
||
total_samples: Optional[int] = None # from STRT record: expected total sample-sets
|
||
pretrig_samples: Optional[int] = None # from STRT record: pre-trigger sample count
|
||
rectime_seconds: Optional[int] = None # from STRT record: record duration (seconds)
|
||
|
||
# ── Debug / introspection ─────────────────────────────────────────────────
|
||
# Raw 210-byte waveform record bytes, set when debug mode is active.
|
||
# Exposed by the SFM server via ?debug=true so field layouts can be verified.
|
||
_raw_record: Optional[bytes] = field(default=None, repr=False)
|
||
|
||
# 4-byte waveform key used to request this event via SUB 5A.
|
||
# Set by get_events(); required by download_waveform().
|
||
_waveform_key: Optional[bytes] = field(default=None, repr=False)
|
||
|
||
def __str__(self) -> str:
|
||
ts = str(self.timestamp) if self.timestamp else "no timestamp"
|
||
ppv = ""
|
||
if self.peak_values:
|
||
pv = self.peak_values
|
||
parts = []
|
||
if pv.tran is not None:
|
||
parts.append(f"T={pv.tran:.4f}")
|
||
if pv.vert is not None:
|
||
parts.append(f"V={pv.vert:.4f}")
|
||
if pv.long is not None:
|
||
parts.append(f"L={pv.long:.4f}")
|
||
if pv.micl is not None:
|
||
parts.append(f"M={pv.micl:.6f}")
|
||
ppv = " [" + ", ".join(parts) + " in/s]"
|
||
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 ─────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class MonitorStatus:
|
||
"""
|
||
Current monitoring state decoded from SUB 0x1C response.
|
||
|
||
Confirmed field locations from 4-8-26/2ndtry BW capture:
|
||
battery_v : data[11 + 0x2F : 11 + 0x31] uint16 BE ÷ 100 e.g. 680 → 6.80 V
|
||
memory_total: data[11 + 0x31 : 11 + 0x35] uint32 BE bytes e.g. 983040 → 960 KB
|
||
memory_free : data[11 + 0x35 : 11 + 0x39] uint32 BE bytes (subset of total)
|
||
is_monitoring: inferred from payload length — idle = 44 bytes, monitoring = 12 bytes
|
||
"""
|
||
is_monitoring: bool # True if unit is actively recording ✅
|
||
battery_v: Optional[float] = None # Battery voltage in volts ✅
|
||
memory_total: Optional[int] = None # Total flash memory in bytes ✅
|
||
memory_free: Optional[int] = None # Free flash memory in bytes ✅
|