From 9ceff65bfb7ceae195d317cc8600f29044067a42 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 6 Sep 2026 07:58:00 +0000 Subject: [PATCH] fix(sfm): read the serial family prefix from the file, enabling BlastMates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BW filename encodes only the serial NUMBER — `<3 digits>`, so `L895…` is 10895 and nothing more. The two-letter family prefix is not in it: "BE" is a MiniMate Plus, "BA" a BlastMate. Both are Series III and their files are byte-identical in every way that matters — all 1,493 BlastMate binaries in the DL2 archive decode through the existing codec at 100%, same four channels — so the serial string was the only thing standing between SFM and BlastMate support. Two sites synthesised the prefix and got it wrong: - waveform_store `_serial_from_bw_filename` returned f"BE{num}" on import, so a BlastMate event was filed under a unit that does not exist, silently, and Terra-View read it straight through. Split into `_serial_number_from_bw_filename` (the number, which the filename really does carry) and a new `_serial_from_bw_bytes` that reads the serial out of the body and accepts it only when its numeric part agrees with the filename. save_imported_bw now prefers hint -> body -> filename guess. Verified against real archive bytes for BA9229, BA10060, BA10895, BA15957 and BE9558/BE11529/BE18003. - client `_decode_0a_partial_header` searched for a literal b"BE" in the monitor-log partial record. On a BlastMate that returns -1 and skips the whole block, so the geo threshold went missing along with the serial. Now matches any two-letter prefix, and requires the NUL terminator — stricter than the bare two-byte search it replaces. Nothing to migrate: no BlastMate events are in prod. The archive's BA units last recorded 2018-10 (BA9229, BA15957), 2023-08 (BA10895) and 2023-11 (BA10060), and the prod backfill only reaches back to ~May 2025. 21 tests. Suite: 309 passed, same 16 pre-existing failures as at HEAD (15 missing ASCII fixtures + one peak_values assertion, all untouched here). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog --- minimateplus/client.py | 10 +++- sfm/waveform_store.py | 71 +++++++++++++++++++++---- tests/test_serial_prefix.py | 101 ++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 tests/test_serial_prefix.py diff --git a/minimateplus/client.py b/minimateplus/client.py index d82604d..49f3e83 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -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) diff --git a/sfm/waveform_store.py b/sfm/waveform_store.py index 44d7020..8373e8d 100644 --- a/sfm/waveform_store.py +++ b/sfm/waveform_store.py @@ -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): `

.` 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}" diff --git a/tests/test_serial_prefix.py b/tests/test_serial_prefix.py new file mode 100644 index 0000000..9d2d111 --- /dev/null +++ b/tests/test_serial_prefix.py @@ -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)