@@ -2,7 +2,7 @@
blastware_file.py — Blastware binary file codec for bidirectional interoperability.
Reads and writes the proprietary Instantel/Blastware file formats:
.N00 / .9T0 / .EI0 / etc. — Waveform event (extension encoding UNKNOWN — see below)
Waveform events (.CE0W, .VM0H, .440, .7M0, etc.) (extension encoding UNKNOWN — see below)
.MLG — Monitor log (monitoring session history)
All waveform formats share a common 22-byte file header prefix and identical
@@ -28,7 +28,7 @@ EXTENSION ENCODING — V10.72 firmware FULLY CONFIRMED 2026-04-22:
─── File structure overview ─────────────────────────────────────────────────────
N00 (single-shot waveform, confirmed from example-events/4-3-26-multi/M529LIY6.N00 ):
Waveform file structure ( confirmed from example-events/4-3-26-multi/M529LIY6 (example event) ):
[22B header] [21B STRT record] [body bytes] [26B footer]
@@ -36,7 +36,7 @@ N00 (single-shot waveform, confirmed from example-events/4-3-26-multi/M529LIY6.N
10 00 01 80 00 00 — fixed prefix
49 6e 73 74 61 6e 74 65 6c 00 — b ' Instantel \x00 '
07 2c — fixed
00 12 03 00 — N00 type marker
00 12 03 00 — waveform file type tag (shared by all waveform extensions)
STRT record (21 bytes, immediately follows header):
53 54 52 54 — b ' STRT '
@@ -84,10 +84,10 @@ MLG (monitor log, confirmed from example-events/4-3-26-multi/BE11529.MLG):
─── Critical implementation notes ──────────────────────────────────────────────
N00 body reconstruction algorithm (confirmed 2026-04-21 from verification against
M529LIY6.N00 using raw_s3_20260403_153508.bin capture):
Waveform body reconstruction algorithm (confirmed 2026-04-21 from verification against
M529LIY6 (example event) using raw_s3_20260403_153508.bin capture):
The N00 body bytes come from the A5 frame content, stripped of DLE-framing
The waveform body bytes come from the A5 frame content, stripped of DLE-framing
artifacts. Each A5 frame contributes a different slice of its data section,
with DLE+ { 0x02,0x03,0x04} byte pairs stripped.
@@ -136,8 +136,8 @@ MLG CRC:
All waveform extensions share the same binary format — the extension is set
by blastware_filename() based on the event timestamp and type.
read_n00 (path) → Event
Parse a .N00 file into an Event object with waveform data populated.
read_blastware_file (path) → Event
Parse a Blastware waveform file into an Event object with waveform data populated.
(Not yet implemented — placeholder raises NotImplementedError.)
write_mlg(entries, serial, path)
@@ -160,7 +160,7 @@ from .models import Event, MonitorLogEntry, Timestamp
# ── File header constants ─────────────────────────────────────────────────────
# Common 16-byte prefix shared by N00 and MLG (confirmed from binary inspection).
# Common 16-byte prefix shared by waveform files and MLG (confirmed from binary inspection).
_FILE_HEADER_PREFIX = bytes . fromhex ( " 1000018000004973 " ) + b " tantel \x00 \x07 \x2c "
# = 10 00 01 80 00 00 49 73 74 61 6e 74 65 6c 00 07 2c (17 bytes)
# Confirmed breakdown: 10 00 01 80 00 00 = fixed; "Instantel\x00" = 10B; 07 2c = fixed
@@ -169,19 +169,19 @@ _FILE_HEADER_PREFIX = bytes.fromhex("1000018000004973") + b"tantel\x00\x07\x2c"
_FILE_HEADER_PREFIX = b " \x10 \x00 \x01 \x80 \x00 \x00 Instantel \x00 \x07 \x2c " # 17 bytes
# Waveform file type tag (4 bytes after common prefix) — shared by ALL waveform extensions
_N00_TYPE_TAG = b " \x00 \x12 \x03 \x00 " # confirmed from M529LIY6.N00 — same tag for .CE0W, .VM0H, etc.
_WAVEFORM_TYPE_TAG = b " \x00 \x12 \x03 \x00 " # confirmed from M529LIY6 (example event) — same tag for .CE0W, .VM0H, etc.
# MLG type tag (4 bytes after common prefix)
_MLG_TYPE_TAG = b " \x22 \x01 \x0e \xa0 " # confirmed from BE11529.MLG offset 0x11..0x14
# Total header sizes
_N00_HEADER_SIZE = 22 # 17 + 4 = 21... wait. Let me recalculate.
_WAVEFORM_HEADER_SIZE = 22 # 17 + 4 = 21... wait. Let me recalculate.
# From binary: first 22 bytes = header, then STRT at byte 22.
# 17-byte common prefix + 4-byte type tag = 21 bytes. But observed header is 22B.
# Checking: 6 fixed + 10 "Instantel\x00" + 2 "07 2c" = 18B prefix, then 4B type tag = 22B.
# Re-count: b"\x10\x00\x01\x80\x00\x00" = 6B + b"Instantel\x00" = 10B + b"\x07\x2c" = 2B = 18B prefix.
_FILE_HEADER_PREFIX = b " \x10 \x00 \x01 \x80 \x00 \x00 Instantel \x00 \x07 \x2c " # 18 bytes
_N00_HEADER_SIZE = 22 # 18 + 4 = 22 bytes ✅
_WAVEFORM_HEADER_SIZE = 22 # 18 + 4 = 22 bytes ✅
_MLG_HEADER_SIZE = 308 # confirmed from BE11529.MLG
# MLG record marker (4 bytes after 2-byte CRC at start of each record)
@@ -201,10 +201,10 @@ def _encode_ts_be(ts: Optional[datetime.datetime]) -> bytes:
"""
Encode a datetime as an 8-byte big-endian Blastware timestamp.
Format (N00 and MLG record timestamps):
Format (waveform file and MLG record timestamps):
[day][month][year_HI][year_LO][0x00][hour][min][sec]
Big-endian year confirmed from M529LIY6.N00 footer:
Big-endian year confirmed from M529LIY6 (example event) footer:
footer bytes [2..9] = 01 04 07 ea 00 00 1c 08
→ day=1 month=4 year=0x07ea=2026 hour=0 min=28 sec=8 ✅
@@ -270,7 +270,7 @@ def _strip_inner_frame_dles(data: bytes) -> bytes:
Lone 0x10 bytes not followed by { 0x02, 0x03, 0x04} are kept as-is.
Confirmed correct by verifying reconstructed N00 body against M529LIY6.N00 :
Confirmed correct by verifying reconstructed waveform body against M529LIY6 (example event) :
- 0x10 0x02 in terminator → 0x02 kept ✓
- 0x10 0x04 in terminator (month byte) → 0x04 kept ✓
"""
@@ -290,14 +290,14 @@ def _strip_inner_frame_dles(data: bytes) -> bytes:
def _frame_body_bytes ( frame : S3Frame , skip : int ) - > bytes :
"""
Extract the N00 body contribution from one A5 S3Frame.
Extract the waveform body contribution from one A5 S3Frame.
The contribution is frame.data[skip:] with inner-frame DLE pairs stripped
per _strip_inner_frame_dles(). The chk_byte is temporarily appended before
stripping to handle the split-pair edge case where a DLE at the end of
frame.data is paired with chk_byte.
Split-pair edge case (confirmed for A5[8] of M529LIY6.N00 , 2026-04-21):
Split-pair edge case (confirmed for A5[8] of M529LIY6 (example event) , 2026-04-21):
S3FrameParser appends DLE+XX pairs as two literal bytes when XX ∉ { DLE, ETX}.
When the LAST occurrence of such a pair straddles the payload/checksum boundary
@@ -319,7 +319,7 @@ def _frame_body_bytes(frame: S3Frame, skip: int) -> bytes:
skip: Number of leading bytes in frame.data to exclude (frame header).
Returns:
bytes — the N00 body contribution for this frame.
bytes — the waveform body contribution for this frame.
"""
if skip > = len ( frame . data ) :
return b " "
@@ -383,10 +383,10 @@ _STEM_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
# S353L4H0.9X0W Full Waveform 2025-06-23 14:01:09 AB=9X=357 ✓
#
# OLD FIRMWARE (S338, 3-char extensions ending in '0') — UNKNOWN:
# Observed: .N0 0, .9T 0, .EI 0, .490, .5K 0, .98 0, .ML0
# Observed (old firmware / manual downloads) : .44 0, .47 0, .7M 0, .9T 0, .EI 0, etc.
# The V10.72 formula does NOT apply to these.
# Extension is NOT recording mode (refuted 2026-04-21: continuous → .EI0, not .9T0).
# blastware_filename() returns .N00 as a placeholder for old- firmware units .
# blastware_filename() computes the correct AB0 extension for V10.72 firmware.
#
# WRONG earlier assumption (do not re-introduce):
# Extension was believed to encode recording mode × sample rate.
@@ -502,15 +502,15 @@ def blastware_filename(event: Event, serial: str, ach: bool = False) -> str:
return prefix + stem + ext
# ── N00 file writer ───────────────────────────────────────────────────────────
# ── Waveform file writer ───────────────────────────────────────────────────────────
def write_n00 (
def write_blastware_file (
event : Event ,
a5_frames : list [ S3Frame ] ,
path : Union [ str , Path ] ,
) - > None :
"""
Write a Blastware .N00 waveform file from a downloaded event.
Write a Blastware waveform file from a downloaded event.
Args:
event: Event object (populated by get_events() or download_waveform()).
@@ -520,7 +520,7 @@ def write_n00(
read_bulk_waveform_stream() when collecting frames.
Must have at least 2 frames (probe + terminator).
path: Destination file path. Parent directory must exist.
Extension is not enforced — caller should use " .N00 " .
Extension should be set via blastware_filename().
File layout:
[22B header] [21B STRT] [body bytes] [26B footer]
@@ -529,7 +529,7 @@ def write_n00(
ValueError: if a5_frames is empty or has no terminator (page_key=0).
OSError: if the file cannot be written.
Confirmed correct N00 body reconstruction against M529LIY6.N00 (2026-04-21).
Confirmed correct waveform body reconstruction against M529LIY6 (example event) (2026-04-21).
"""
if not a5_frames :
raise ValueError ( " a5_frames must not be empty " )
@@ -538,11 +538,11 @@ def write_n00(
# ── Extract STRT record from probe frame ────────────────────────────────
# The STRT record (21 bytes) lives verbatim inside A5[0].data[7:].
# It is stored as-is in the N00 file — do NOT reconstruct it from Event
# It is stored as-is in the waveform file — do NOT reconstruct it from Event
# fields, as bytes [10:14] and [14:20] contain device-specific values
# (not simply key4 repeated or zero-padded). Confirmed 2026-04-21.
#
# STRT layout (21 bytes, observed in M529LIY6.N00 ):
# STRT layout (21 bytes, observed in M529LIY6 files ):
# [0:4] b'STRT'
# [4:6] 0xff 0xfe (fixed)
# [6:10] key4 (event key)
@@ -556,7 +556,7 @@ def write_n00(
# Blastware file stores the stripped form, so we must strip before extracting.
#
# Example (M529LK0Y, 2026-04-21): STRT contains value 0x02 encoded as [10 02]
# on the wire. Without stripping, STRT is 22 raw bytes → write_n00 writes the
# on the wire. Without stripping, STRT is 22 raw bytes → write_blastware_file writes the
# DLE prefix into the file AND begins the body 1 byte too early (probe_skip off
# by 1). Stripping fixes both.
#
@@ -594,28 +594,39 @@ def write_n00(
if len ( strt ) != 21 :
raise ValueError ( f " STRT record must be 21 bytes, got { len ( strt ) } " )
# ── Build N00 header ─────────────────────────────────────────────────────
header = _FILE_HEADER_PREFIX + _N00_TYPE_TAG
assert len ( header ) == _N00_HEADER_SIZE , f " N00 header must be { _N00_HEADER_SIZE } bytes "
# ── Build waveform file header ─────────────────────────────────────────────────────
header = _FILE_HEADER_PREFIX + _WAVEFORM_TYPE_TAG
assert len ( header ) == _WAVEFORM_HEADER_SIZE , f " Waveform header must be { _WAVEFORM_HEADER_SIZE } bytes "
# ── Build body from A5 frames ────────────────────────────────────────────
# The N00 body is reconstructed from ALL A5 frames (data + terminator).
# The waveform body is reconstructed from ALL A5 frames (data + terminator).
# The terminator frame's contribution includes the 26-byte footer at its end.
#
# Reconstruction layout (confirmed from M529LIY6.N00 , 2026-04-21):
# Reconstruction layout (confirmed from M529LIY6 captures , 2026-04-21):
# all_bytes = contributions from A5[0..N] + terminator_contribution
# body = all_bytes[:-26] (everything except the last 26 bytes)
# footer = all_bytes[-26:] (last 26 bytes = the N00 footer)
# footer = all_bytes[-26:] (last 26 bytes = the waveform file footer)
#
# The footer bytes come directly from the terminator frame's inner content —
# using them verbatim ensures timestamps match the device's recorded values.
# Separate terminator from data frames
body_frames = a5_frames
term_frame : Optional [ S3Frame ] = None
if a5_frames and a5_frames [ - 1 ] . page_key == 0x0000 :
body_frames = a5_frames [ : - 1 ]
term_frame = a5_frames [ - 1 ]
# Separate terminator from data frames.
# Search from the FRONT for the first terminator (page_key == 0x0000).
# Do NOT use a5_frames[-1] — if _a5_frames contains stray frames from a
# subsequent event (a known get_events side-effect), the last frame will
# not be the terminator and the footer will be mis-identified.
term_idx : Optional [ int ] = None
for _i , _f in enumerate ( a5_frames ) :
if _f . page_key == 0x0000 :
term_idx = _i
break
if term_idx is not None :
body_frames = a5_frames [ : term_idx ]
term_frame = a5_frames [ term_idx ]
else :
body_frames = a5_frames
term_frame = None
all_bytes = bytearray ( )
@@ -660,14 +671,14 @@ def write_n00(
f . write ( footer )
def read_n00 ( path : Union [ str , Path ] ) - > Event :
def read_blastware_file ( path : Union [ str , Path ] ) - > Event :
"""
Parse a Blastware .N00 file into an Event object.
Parse a Blastware waveform file into an Event object.
NOT YET IMPLEMENTED.
Args:
path: Path to the .N00 file.
path: Path to the waveform file.
Returns:
Event object with waveform data populated.
@@ -675,7 +686,7 @@ def read_n00(path: Union[str, Path]) -> Event:
Raises:
NotImplementedError: always (pending implementation).
"""
raise NotImplementedError ( " read_n00 () is not yet implemented " )
raise NotImplementedError ( " read_blastware_file () is not yet implemented " )
# ── MLG file writer ───────────────────────────────────────────────────────────