diff --git a/CLAUDE.md b/CLAUDE.md index d6f9a5a..32dd6d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1096,7 +1096,7 @@ body) because writing a dial string may require DLE escaping for embedded contro - **Database** — SQLite store for events + monitor log entries; dedup by key; queryable - **Histograms** — decode histogram-mode A5 data (noise floor tracking) -- **Blastware-compatible file output** — `write_n00()` and `write_mlg()` implemented (v0.12.3+). `write_n00` verified byte-perfect vs M529LIY6.N00. Extension mapping: **CONFIRMED FALSE 2026-04-21** — extensions are NOT based on recording mode. A continuous-mode event produced `.EI0`, not `.9T0`. The extension alphabet/encoding scheme is unknown; do not infer recording mode from extension or vice versa. Observed extensions: `.N00`, `.9T0`, `.EI0`, `.490`, `.5K0`, `.980`, `.ML0` — mapping to recording mode × sample rate × other settings is unknown. Filename format: `<4-char-base36-stem>` +- **Blastware-compatible file output** — `write_blastware_file()` and `write_mlg()` implemented. `blastware_filename()` generates correct Blastware filenames (AB0 for direct, AB0W/AB0H for ACH). **Confirmed working for Continuous mode events (2026-04-23):** SFM-generated file opens in Blastware, shows correct PPV/waveform/timestamp. File is ~200 bytes shorter than BW (missing last ADC tail slice) — all measurements correct. Histogram+Continuous mode deferred (5A stream for those events embeds histogram interval records that create spurious STRT markers in the body). Extension mapping: **CONFIRMED FALSE 2026-04-21** — extensions encode timestamp (AB0T for ACH, AB0 for direct), NOT recording mode. Filename format: `<4-char-base36-stem>` **Serial encoding (CONFIRMED 2026-04-22):** `prefix_letter = chr(ord('B') + floor(serial_numeric / 1000))`, `serial3 = f"{serial_numeric % 1000:03d}"`. Examples: BE6907→H907, BE11529→M529, BE14036→P036, BE17353→S353, BE18003→T003. The prefix letter encodes the production generation (batch of 1000 units). diff --git a/docs/instantel_protocol_reference.md b/docs/instantel_protocol_reference.md index 6f64f9e..cd0812e 100644 --- a/docs/instantel_protocol_reference.md +++ b/docs/instantel_protocol_reference.md @@ -1248,9 +1248,25 @@ Two critical differences from `build_bw_frame`: > for all keys encountered on this device. The `stop_after_metadata=True` flag causes the loop to stop as soon as `b"Project:"` is -found in the accumulated A5 frame data, typically after 7–9 chunks. A termination frame +found in the accumulated A5 frame data, typically after 4–9 chunks. A termination frame is always sent before returning. +**IMPORTANT — one extra chunk required after "Project:" for valid file footer (confirmed 2026-04-23):** +When writing a Blastware-compatible waveform file, stopping immediately at "Project:" and +sending termination produces an empty termination response with no footer bytes (`0e 08` +marker missing). Blastware downloads exactly **one more chunk** after finding "Project:" +before sending termination — that extra chunk primes the device to return valid footer +bytes (monitoring start/stop timestamps) in the termination response. + +`read_bulk_waveform_stream(stop_after_metadata=True)` implements this: after the "Project:" +chunk is received, one additional chunk is requested before breaking. The termination +response (`include_terminator=True`) then contains the correct `0e 08` footer. + +**do NOT use `full_waveform=True` for Blastware file writing** — for events with long +post-event silence (35 chunks), the silence chunks contain embedded device-internal +pointer structures that produce spurious STRT markers in the file body. Blastware only +downloads 4–5 chunks (metadata + one signal chunk) regardless of event length. + #### 7.8.3 A5 Frame Layout Each A5 response frame contains a chunk of raw bulk data. Frame 7 of the stream carries the diff --git a/minimateplus/client.py b/minimateplus/client.py index 2810afd..0fa133b 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -449,7 +449,7 @@ class MiniMateClient: proto.confirm_erase_all() log.info("delete_all_events: erase confirmed — device memory cleared") - def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None, skip_waveform_for_keys: Optional[set] = None) -> list[Event]: + def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None, skip_waveform_for_keys: Optional[set] = None, extra_chunks_after_metadata: int = 1) -> list[Event]: """ Download all stored events from the device using the confirmed 1E → 0A → 0C → 5A → 1F event-iterator protocol. @@ -623,6 +623,7 @@ class MiniMateClient: a5_frames = proto.read_bulk_waveform_stream( cur_key, stop_after_metadata=True, include_terminator=True, + extra_chunks_after_metadata=extra_chunks_after_metadata, ) if a5_frames: a5_ok = True diff --git a/minimateplus/protocol.py b/minimateplus/protocol.py index 1359abf..578f11a 100644 --- a/minimateplus/protocol.py +++ b/minimateplus/protocol.py @@ -527,6 +527,7 @@ class MiniMateProtocol: stop_after_metadata: bool = True, max_chunks: int = 32, include_terminator: bool = False, + extra_chunks_after_metadata: int = 1, ) -> list[S3Frame]: """ Download the SUB 5A (BULK_WAVEFORM_STREAM) A5 frames for one event. @@ -651,22 +652,25 @@ class MiniMateProtocol: # and primes the device to return a valid footer in the termination # response. Without it, termination returns an empty ack with no # footer bytes (confirmed 2026-04-23 from HxD comparison). - log.debug("5A A5[%d] metadata found — fetching one more chunk then stopping", chunk_num) - chunk_num += 1 - counter = chunk_num * _BULK_COUNTER_STEP - params = bulk_waveform_params(key4, counter) - self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params)) - try: - extra = self._recv_one(expected_sub=rsp_sub, timeout=10.0) - log.debug("5A A5[%d] extra chunk page_key=0x%04X data_len=%d", - chunk_num, extra.page_key, len(extra.data)) - if extra.page_key == 0x0000: - if include_terminator: - frames_data.append(extra) - return frames_data - frames_data.append(extra) - except TimeoutError: - log.debug("5A extra chunk timed out — end of stream") + log.debug("5A A5[%d] metadata found — fetching %d more chunk(s) then stopping", + chunk_num, extra_chunks_after_metadata) + for _extra_n in range(extra_chunks_after_metadata): + chunk_num += 1 + counter = chunk_num * _BULK_COUNTER_STEP + params = bulk_waveform_params(key4, counter) + self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params)) + try: + extra = self._recv_one(expected_sub=rsp_sub, timeout=10.0) + log.debug("5A A5[%d] extra chunk page_key=0x%04X data_len=%d", + chunk_num, extra.page_key, len(extra.data)) + if extra.page_key == 0x0000: + if include_terminator: + frames_data.append(extra) + return frames_data + frames_data.append(extra) + except TimeoutError: + log.debug("5A extra chunk %d timed out — end of stream", _extra_n + 1) + break break else: log.warning( diff --git a/sfm/server.py b/sfm/server.py index 8241d17..077a3a5 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -885,13 +885,22 @@ def device_event_blastware_file( def _do(): with _build_client(port, baud, host, tcp_port, timeout=120.0) as client: info = client.connect() - # Use full_waveform=False (stop_after_metadata=True) — stops when - # "Project:" is found in the 5A stream. Content is byte-identical to - # BW for Continuous/Single-Shot events; our file is slightly shorter - # (~286 bytes of extra ADC signal BW includes past the metadata). - # full_waveform=True corrupts the body: silence chunks past the event - # contain device-internal pointers that embed extra STRT records. - events = client.get_events(full_waveform=False, stop_after_index=index) + # Calculate extra ADC chunks to download after finding "Project:". + # BW downloads ~2 extra chunks per second of record time. + # Without enough extra chunks the termination response contains no + # footer bytes and Blastware rejects the file. + rectime = 1.0 + try: + rectime = float(info.compliance_config.record_time or 1.0) + except (AttributeError, TypeError, ValueError): + pass + extra_chunks = max(1, round(rectime * 2)) + log.info("blastware_file: rectime=%.1fs → extra_chunks=%d", rectime, extra_chunks) + events = client.get_events( + full_waveform=False, + stop_after_index=index, + extra_chunks_after_metadata=extra_chunks, + ) matching = [ev for ev in events if ev.index == index] return matching[0] if matching else None, info ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host))