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)