Release v0.29.0 — offset detector + false_trigger_reason + BlastMate serials (0.27.0→0.29.0) #36
@@ -30,6 +30,7 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from typing import Optional
|
||||
|
||||
@@ -2532,10 +2533,17 @@ def _decode_0a_partial_header(raw_data: bytes, index: int, key4: bytes) -> Optio
|
||||
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".
|
||||
#
|
||||
# Match any two-letter family prefix, not a literal "BE" — a BlastMate
|
||||
# reports "BA10895", and the old `find(b"BE")` returned -1 on one. That
|
||||
# skipped this whole block, so the geo threshold went missing along with
|
||||
# the serial. Requiring the NUL terminator in the pattern also makes the
|
||||
# match stricter than the bare two-byte search it replaces.
|
||||
serial: Optional[str] = None
|
||||
geo_ips: Optional[float] = None
|
||||
|
||||
serial_pos = raw_data.find(b"BE")
|
||||
serial_match = re.search(rb"[A-Z]{2}\d{3,6}(?=\x00)", raw_data)
|
||||
serial_pos = serial_match.start() if serial_match else -1
|
||||
if serial_pos >= 0:
|
||||
# Read null-terminated serial starting at serial_pos.
|
||||
null_pos = raw_data.find(b"\x00", serial_pos)
|
||||
|
||||
+61
-10
@@ -32,6 +32,7 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import logging
|
||||
import pickle
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
@@ -379,8 +380,16 @@ class WaveformStore:
|
||||
|
||||
# Resolve serial. blastware_filename derives a 4-char prefix from
|
||||
# the numeric serial (e.g. BE11529 → M529); we go the other way
|
||||
# via the source filename if a hint wasn't given.
|
||||
serial = serial_hint or _serial_from_bw_filename(source_path.name) or "UNKNOWN"
|
||||
# if a hint wasn't given. The filename carries only the NUMBER,
|
||||
# so read the family prefix out of the body first — a BlastMate
|
||||
# ("BA") filed as "BE" is a unit that does not exist. The
|
||||
# filename-only decoder stays as the last resort.
|
||||
serial = (
|
||||
serial_hint
|
||||
or _serial_from_bw_bytes(bw_bytes, source_path.name)
|
||||
or _serial_from_bw_filename(source_path.name)
|
||||
or "UNKNOWN"
|
||||
)
|
||||
|
||||
# Use the source filename verbatim — it already encodes timestamp
|
||||
# + record type per BW's AB0T scheme, and we want to preserve it
|
||||
@@ -840,20 +849,24 @@ class WaveformStore:
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _serial_from_bw_filename(name: str) -> Optional[str]:
|
||||
def _serial_number_from_bw_filename(name: str) -> Optional[int]:
|
||||
"""
|
||||
Reverse of `blastware_filename`'s serial-prefix encoding.
|
||||
Reverse of `blastware_filename`'s serial-prefix encoding — the NUMBER only.
|
||||
|
||||
BW filename format (V10.72): `<P><serial3><stem4>.<ext>`
|
||||
where P = chr(ord('B') + floor(serial // 1000))
|
||||
and serial3 = f"{serial % 1000:03d}".
|
||||
|
||||
Examples (from CLAUDE.md verification archive):
|
||||
P036... → BE14036 H907... → BE6907
|
||||
M529... → BE11529 T003... → BE18003
|
||||
P036... → 14036 H907... → 6907
|
||||
M529... → 11529 T003... → 18003
|
||||
L895... → 10895
|
||||
|
||||
Returns the inferred BE-prefix serial (e.g. "BE11529") or None when
|
||||
the filename doesn't match the expected pattern.
|
||||
⚠ The filename encodes **only the number**. The two-letter family
|
||||
prefix is NOT in it — "BE" is a MiniMate Plus, "BA" a BlastMate — so
|
||||
the prefix has to come from the file body (`_serial_from_bw_bytes`)
|
||||
or from an explicit hint. Returns None when the filename doesn't
|
||||
match the expected pattern.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
@@ -866,5 +879,43 @@ def _serial_from_bw_filename(name: str) -> Optional[str]:
|
||||
if prefix_letter < "B":
|
||||
return None
|
||||
thousands = ord(prefix_letter) - ord("B")
|
||||
serial_num = thousands * 1000 + int(base[1:4])
|
||||
return f"BE{serial_num}"
|
||||
return thousands * 1000 + int(base[1:4])
|
||||
|
||||
|
||||
_BW_SERIAL_RE = re.compile(rb"[A-Z]{2}\d{3,6}")
|
||||
|
||||
|
||||
def _serial_from_bw_bytes(data: bytes, name: str) -> Optional[str]:
|
||||
"""
|
||||
Read the real serial — prefix included — out of a BW file body.
|
||||
|
||||
The body carries the serial as a plain ASCII string ("BE9558",
|
||||
"BA10895"). We accept a candidate only when its numeric part matches
|
||||
the number the filename encodes, which keeps a stray byte sequence in
|
||||
the sample stream from being mistaken for a serial.
|
||||
|
||||
Returns None when the filename number can't be derived or no
|
||||
candidate in the body agrees with it — the caller then falls back.
|
||||
"""
|
||||
num = _serial_number_from_bw_filename(name)
|
||||
if num is None or not data:
|
||||
return None
|
||||
for match in _BW_SERIAL_RE.findall(data):
|
||||
candidate = match.decode("ascii", errors="replace")
|
||||
if candidate[2:].lstrip("0") == str(num):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _serial_from_bw_filename(name: str) -> Optional[str]:
|
||||
"""
|
||||
Best-effort serial from the filename alone.
|
||||
|
||||
⚠ The family prefix is a **guess** — the filename does not carry it.
|
||||
"BE" is right for every MiniMate Plus but wrong for a BlastMate, whose
|
||||
serials start "BA". Prefer `_serial_from_bw_bytes` whenever the file
|
||||
body is at hand; this exists for callers that only have a name
|
||||
(log lines, dry-run output).
|
||||
"""
|
||||
num = _serial_number_from_bw_filename(name)
|
||||
return None if num is None else f"BE{num}"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""The BW filename encodes the serial NUMBER, never the family prefix.
|
||||
|
||||
"BE" is a MiniMate Plus; "BA" is a BlastMate. Both are Series III and their
|
||||
files are byte-compatible — the whole archive's 1,493 BlastMate binaries
|
||||
decode through the same codec at 100% — so the only thing that distinguishes
|
||||
them downstream is the serial string, and that lives in the file body.
|
||||
|
||||
Synthesising the prefix as "BE" files a BlastMate under a unit that does not
|
||||
exist. Four units in the DL2 archive are affected: BA9229, BA10060, BA10895
|
||||
and BA15957.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from minimateplus.client import _decode_0a_partial_header
|
||||
from sfm.waveform_store import (
|
||||
_serial_from_bw_bytes,
|
||||
_serial_from_bw_filename,
|
||||
_serial_number_from_bw_filename,
|
||||
)
|
||||
|
||||
|
||||
# ── the filename gives a number, and only a number ──────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("name,num", [
|
||||
("P036L318.C80H", 14036), # BE14036
|
||||
("H907KWRK.WB0H", 6907), # BE6907
|
||||
("M529LKIQ.G10", 11529), # BE11529
|
||||
("T003LQ9K.OE0H", 18003), # BE18003
|
||||
("L895K63F.GE0W", 10895), # BA10895 — a BlastMate
|
||||
("K229HGQI.XO0W", 9229), # BA9229 — a BlastMate
|
||||
])
|
||||
def test_number_from_filename(name, num):
|
||||
assert _serial_number_from_bw_filename(name) == num
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "not_a_bw_file.bin", "AB12", "1234ABCD.XX0W"])
|
||||
def test_number_from_filename_rejects_junk(name):
|
||||
assert _serial_number_from_bw_filename(name) is None
|
||||
|
||||
|
||||
def test_filename_only_decoder_is_a_guess():
|
||||
"""It still answers "BE" — that is why it must not be the first choice."""
|
||||
assert _serial_from_bw_filename("L895K63F.GE0W") == "BE10895"
|
||||
assert _serial_from_bw_filename("M529LKIQ.G10") == "BE11529"
|
||||
assert _serial_from_bw_filename("nonsense") is None
|
||||
|
||||
|
||||
# ── the body carries the truth ──────────────────────────────────────────────
|
||||
|
||||
def _body(serial: bytes) -> bytes:
|
||||
return b"\x00" * 32 + b"STRT" + b"\xff\xfe" + serial + b"\x00Geo: 0.254 in/s\x00"
|
||||
|
||||
|
||||
def test_body_wins_for_a_blastmate():
|
||||
assert _serial_from_bw_bytes(_body(b"BA10895"), "L895K63F.GE0W") == "BA10895"
|
||||
|
||||
|
||||
def test_body_wins_for_a_minimate():
|
||||
assert _serial_from_bw_bytes(_body(b"BE11529"), "M529LKIQ.G10") == "BE11529"
|
||||
|
||||
|
||||
def test_body_candidate_must_match_the_filename_number():
|
||||
"""A serial-shaped byte run that disagrees with the filename is ignored."""
|
||||
assert _serial_from_bw_bytes(_body(b"XX99999"), "L895K63F.GE0W") is None
|
||||
|
||||
|
||||
def test_body_tolerates_a_leading_zero():
|
||||
assert _serial_from_bw_bytes(_body(b"BA09229"), "K229HGQI.XO0W") == "BA09229"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data,name", [
|
||||
(b"", "L895K63F.GE0W"), # no bytes
|
||||
(_body(b"BA10895"), "junk.bin"), # no derivable number
|
||||
])
|
||||
def test_body_returns_none_when_it_cannot_decide(data, name):
|
||||
assert _serial_from_bw_bytes(data, name) is None
|
||||
|
||||
|
||||
# ── the live monitor-log path ───────────────────────────────────────────────
|
||||
|
||||
def _partial_record(serial: bytes) -> bytes:
|
||||
"""0x2C partial record: type, prefix, two 9-byte timestamps, then ASCII."""
|
||||
ts = bytes([11, 0x10, 4, 0x07, 0xE9, 0, 16, 2, 0]) # 2025-04-11 16:02:00
|
||||
return (bytes([0x2C]) + b"\x00" * 10 + ts + ts
|
||||
+ b"\x00\x00\x00\x00" + serial + b"\x00Geo: 0.254 in/s\x00")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("serial", [b"BE11529", b"BA10895", b"UM11719"])
|
||||
def test_monitor_log_reads_any_family_prefix(serial):
|
||||
entry = _decode_0a_partial_header(_partial_record(serial), 0, b"\x01\x11\x00\x00")
|
||||
assert entry is not None
|
||||
assert entry.serial == serial.decode()
|
||||
|
||||
|
||||
def test_monitor_log_geo_threshold_survives_a_blastmate():
|
||||
"""The old find(b"BE") skipped the whole block, losing geo too."""
|
||||
entry = _decode_0a_partial_header(_partial_record(b"BA10895"), 0, b"\x01\x11\x00\x00")
|
||||
assert entry is not None
|
||||
assert entry.geo_threshold_ips == pytest.approx(0.254)
|
||||
Reference in New Issue
Block a user