4 Commits

Author SHA1 Message Date
claude 6eecd0c1d1 client/models/server: wire event_count from SUB 08 event index into connect()
- DeviceInfo.event_count: Optional[int] = None  (new field in models.py)
- connect() now calls proto.read_event_index() after compliance config and
  stores the decoded count in device_info.event_count
- _serialise_device_info() exposes event_count in /device/info and /device/events
  JSON responses

event_count is decoded from uint32 BE at offset +3 of the 88-byte F7 payload
(🔶 inferred — needs live device confirmation against a multi-event device).
Any ProtocolError from the index read is caught and logged; event_count stays
None rather than failing the whole connect().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 02:00:37 -04:00
claude 870a10365e server: fill ev.sample_rate from compliance config for /device/events
sample_rate is a device-level setting stored in the compliance config,
not per-event in the waveform record.  After downloading events, backfill
ev.sample_rate from info.compliance_config.sample_rate for any event
that didn't get it from the waveform record decode path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 02:00:00 -04:00
claude b2d10fd689 client: wire trigger_level_geo, alarm_level_geo, max_range_geo from channel block
The channel block is only present in the full ~2126-byte cfg (when frame D
delivers correctly rather than duplicating frame B's page).  Layout per §7.6:
  [00 00][max_range f32][00 00][trigger f32]["in.\0"][alarm f32]["/s\0\0"][00 01][label]

Relative offsets from the "Tran" label position (label-24/label-18/label-10)
are validated by checking the unit strings "in.\0" at label-14 and "/s\0\0"
at label-6 before reading the floats.  Guard against "Tran2" false-match.

When frame D duplicates, cfg is ~1071 bytes and tran_pos search returns a hit
without the unit string sentinels — we log the miss and leave fields None.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 01:59:56 -04:00
claude ce44852383 protocol: add read_event_index() for SUB 08/F7
Two-step probe+fetch for SUB 08 (EVENT_INDEX), returning the raw 88-byte
(0x58) index block.  SUB_EVENT_INDEX and DATA_LENGTHS[0x08]=0x58 were
already registered — this just wires the method that calls them.

Docstring notes the partially-decoded layout (event count at +3 as uint32 BE,
timestamps at +7) pending live device confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 01:59:48 -04:00
4 changed files with 92 additions and 1 deletions
+46 -1
View File
@@ -121,9 +121,10 @@ class MiniMateClient:
2. SUB 15 — serial number 2. SUB 15 — serial number
3. SUB 01 — full config block (firmware, model strings) 3. SUB 01 — full config block (firmware, model strings)
4. SUB 1A — compliance config (record time, trigger/alarm levels, project strings) 4. SUB 1A — compliance config (record time, trigger/alarm levels, project strings)
5. SUB 08 — event index (stored event count)
Returns: Returns:
Populated DeviceInfo with compliance_config cached. Populated DeviceInfo with compliance_config and event_count cached.
Raises: Raises:
ProtocolError: on any communication failure. ProtocolError: on any communication failure.
@@ -151,6 +152,14 @@ class MiniMateClient:
except ProtocolError as exc: except ProtocolError as exc:
log.warning("connect: compliance config read failed: %s — continuing", exc) log.warning("connect: compliance config read failed: %s — continuing", exc)
log.info("connect: reading event index (SUB 08)")
try:
idx_raw = proto.read_event_index()
device_info.event_count = _decode_event_count(idx_raw)
log.info("connect: device has %d stored event(s)", device_info.event_count)
except ProtocolError as exc:
log.warning("connect: event index read failed: %s — continuing", exc)
log.info("connect: %s", device_info) log.info("connect: %s", device_info)
return device_info return device_info
@@ -682,6 +691,42 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
except Exception as exc: except Exception as exc:
log.warning("compliance_config: project string extraction failed: %s", exc) log.warning("compliance_config: project string extraction failed: %s", exc)
# ── Channel block: trigger_level_geo, alarm_level_geo, max_range_geo ─────
# The channel block is only present in the full cfg (frame D delivered,
# ~2126 bytes). Per §7.6, each channel record ends with the label string:
# [00 00][max_range f32][00 00][trigger f32]["in.\0"][alarm f32]["/s\0\0"][00 01][label]
# Relative offsets from the "Tran" label position:
# trigger = float32_BE at label - 18
# alarm = float32_BE at label - 10
# max_range = float32_BE at label - 24
# Validated by checking unit strings "in.\0" at label-14 and "/s\0\0" at label-6.
# "Tran2" at a later position won't match because its surrounding bytes differ.
try:
tran_pos = data.find(b"Tran", 1000)
if (
tran_pos >= 24
and data[tran_pos + 4 : tran_pos + 5] != b"2" # not "Tran2"
and data[tran_pos - 14 : tran_pos - 10] == b"in.\x00"
and data[tran_pos - 6 : tran_pos - 2 ] == b"/s\x00\x00"
):
config.trigger_level_geo = struct.unpack_from(">f", data, tran_pos - 18)[0]
config.alarm_level_geo = struct.unpack_from(">f", data, tran_pos - 10)[0]
config.max_range_geo = struct.unpack_from(">f", data, tran_pos - 24)[0]
log.debug(
"compliance_config: trigger=%.4f alarm=%.4f max_range=%.4f in/s",
config.trigger_level_geo, config.alarm_level_geo, config.max_range_geo,
)
elif tran_pos >= 0:
log.debug(
"compliance_config: 'Tran' at %d but unit strings absent "
"— channel block not yet in cfg (frame D duplicate?)",
tran_pos,
)
else:
log.debug("compliance_config: channel block not present in cfg (len=%d)", len(data))
except Exception as exc:
log.warning("compliance_config: channel block extraction failed: %s", exc)
info.compliance_config = config info.compliance_config = config
+3
View File
@@ -183,6 +183,9 @@ class DeviceInfo:
# ── From SUB 1A (COMPLIANCE_CONFIG_RESPONSE) ────────────────────────────── # ── From SUB 1A (COMPLIANCE_CONFIG_RESPONSE) ──────────────────────────────
compliance_config: Optional["ComplianceConfig"] = None # E5 response, read in connect() 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: def __str__(self) -> str:
fw = self.firmware_version or f"?.{self.firmware_minor}" fw = self.firmware_version or f"?.{self.firmware_minor}"
mdl = self.model or "MiniMate Plus" mdl = self.model or "MiniMate Plus"
+35
View File
@@ -240,6 +240,41 @@ class MiniMateProtocol:
# ── Event download API ──────────────────────────────────────────────────── # ── Event download API ────────────────────────────────────────────────────
def read_event_index(self) -> bytes:
"""
Send the SUB 08 (EVENT_INDEX) two-step read and return the raw 88-byte
(0x58) index block.
The index block contains:
+0x00 (3 bytes): total index size or record count — purpose partially
decoded; byte [3] may be a high byte of event count.
+0x03 (4 bytes): stored event count as uint32 BE ❓ (inferred from
captures; see §7.4 in protocol reference)
+0x07 onwards: 6-byte event timestamps (see §8), one per event
Caller is responsible for parsing the returned bytes.
Returns:
Raw 88-byte data section (data[11:11+0x58]).
Raises:
ProtocolError: on timeout, bad checksum, or wrong response SUB.
"""
rsp_sub = _expected_rsp_sub(SUB_EVENT_INDEX)
length = DATA_LENGTHS[SUB_EVENT_INDEX] # 0x58
log.debug("read_event_index: 08 probe")
self._send(build_bw_frame(SUB_EVENT_INDEX, 0))
self._recv_one(expected_sub=rsp_sub)
log.debug("read_event_index: 08 data request offset=0x%02X", length)
self._send(build_bw_frame(SUB_EVENT_INDEX, length))
data_rsp = self._recv_one(expected_sub=rsp_sub)
raw = data_rsp.data[11 : 11 + length]
log.debug("read_event_index: got %d bytes", len(raw))
return raw
def read_event_first(self) -> tuple[bytes, bytes]: def read_event_first(self) -> tuple[bytes, bytes]:
""" """
Send the SUB 1E (EVENT_HEADER) two-step read and return the first Send the SUB 1E (EVENT_HEADER) two-step read and return the first
+8
View File
@@ -145,6 +145,7 @@ def _serialise_device_info(info: DeviceInfo) -> dict:
"dsp_version": info.dsp_version, "dsp_version": info.dsp_version,
"manufacturer": info.manufacturer, "manufacturer": info.manufacturer,
"model": info.model, "model": info.model,
"event_count": info.event_count,
"compliance_config": _serialise_compliance_config(info.compliance_config), "compliance_config": _serialise_compliance_config(info.compliance_config),
} }
@@ -315,6 +316,13 @@ def device_events(
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
# Fill sample_rate from compliance config where the event record doesn't supply it.
# sample_rate is a device-level setting, not stored per-event in the waveform record.
if info.compliance_config and info.compliance_config.sample_rate:
for ev in events:
if ev.sample_rate is None:
ev.sample_rate = info.compliance_config.sample_rate
return { return {
"device": _serialise_device_info(info), "device": _serialise_device_info(info),
"event_count": len(events), "event_count": len(events),