v0.14.3 - Full waveform DL pipeline tested and working. #15
@@ -4,6 +4,60 @@ All notable changes to seismo-relay are documented here.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.13.0 — 2026-05-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **SUB 5A bulk waveform stream — over-read bug for events ≥ 2 sec.**
|
||||||
|
`read_bulk_waveform_stream` was walking the chunk counter past the actual
|
||||||
|
end of the event, picking up post-event circular-buffer garbage that
|
||||||
|
corrupted reconstructed Blastware files for any waveform > ~1 sec. The
|
||||||
|
loop now extracts the event's `end_offset` from the STRT record at
|
||||||
|
`data[23:27]` of the probe response and stops the chunk walk when the next
|
||||||
|
counter would step past it. Verified against three BW MITM captures
|
||||||
|
(4-27-26 + 5-1-26): 2-sec event drops from 37 over-read chunks to 7
|
||||||
|
bounded chunks; 3-sec drops to 9; non-zero-start "event 2" drops to 9.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `framing.bulk_waveform_term_v2(key4, end_offset, last_chunk_counter)` —
|
||||||
|
computes the corrected SUB 5A TERM frame's `(offset_word, params)` per the
|
||||||
|
formula confirmed across all 3 BW captures. Not yet wired into
|
||||||
|
`read_bulk_waveform_stream` (the legacy TERM is still used to preserve the
|
||||||
|
existing `blastware_file.write_blastware_file` frame-structure expectations);
|
||||||
|
available for the next iteration that switches to BW's 0x0200 chunk step.
|
||||||
|
- `framing.parse_strt_end_offset(a5_data)` — extracts the event-end pointer
|
||||||
|
from the STRT record in an A5 response payload.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **CLAUDE.md and `docs/instantel_protocol_reference.md` extensively
|
||||||
|
rewritten** to reflect the corrected SUB 5A protocol. See:
|
||||||
|
- CLAUDE.md "SUB 5A — chunk counter formula (REWRITTEN 2026-05-01)"
|
||||||
|
- CLAUDE.md "SUB 5A — STRT record encodes end_offset"
|
||||||
|
- CLAUDE.md "SUB 5A — TERM frame formula"
|
||||||
|
- CLAUDE.md "SUB 5A — fixed metadata pages 0x1002 and 0x1004"
|
||||||
|
- CLAUDE.md "SUB 0A — WAVEHDR response length distinguishes events from
|
||||||
|
boundaries" (0x46 = real event, 0x2C = boundary marker)
|
||||||
|
- protocol reference §7.8.5 / §7.8.6 / §7.8.7 / §7.8.8
|
||||||
|
- The previous chunk-counter formula (`max(key4[2:4], 0x0400) + (chunk-1) *
|
||||||
|
0x0400`) is now marked DEPRECATED and explicitly tagged WRONG with
|
||||||
|
pointers to the new sections, so future work doesn't re-derive it.
|
||||||
|
|
||||||
|
### Known minor diffs vs Blastware (deferred to a follow-up)
|
||||||
|
|
||||||
|
- We still use the OLD 0x0400 chunk step rather than BW's 0x0200; switching
|
||||||
|
also requires updating `blastware_file.write_blastware_file`'s skip values
|
||||||
|
and "extra chunk after metadata" logic, which depends on a fresh capture
|
||||||
|
to verify.
|
||||||
|
- We still use the legacy fixed `offset_word=0x005A` TERM frame rather than
|
||||||
|
BW's `end_offset - next_boundary` formula, for the same reason.
|
||||||
|
- Two fixed metadata pages at counter `0x1002` and `0x1004` are not yet
|
||||||
|
read explicitly; under the current 0x0400 walk their content is reachable
|
||||||
|
via the sample chunk that covers buffer addresses `[0x1000, 0x1400)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.12.5 — 2026-04-21
|
## v0.12.5 — 2026-04-21
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
|
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
|
||||||
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
|
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
|
||||||
(Sierra Wireless RV50 / RV55). Current version: **v0.12.3**.
|
(Sierra Wireless RV50 / RV55). Current version: **v0.13.0**.
|
||||||
|
|
||||||
When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document
|
When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ Full read pipeline + write pipeline + erase pipeline + monitor log + call home c
|
|||||||
| Event header / first key | 1E | ✅ |
|
| Event header / first key | 1E | ✅ |
|
||||||
| Waveform header | 0A | ✅ |
|
| Waveform header | 0A | ✅ |
|
||||||
| Waveform record (peaks, timestamp, project) | 0C | ✅ |
|
| Waveform record (peaks, timestamp, project) | 0C | ✅ |
|
||||||
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ new v0.6.0 |
|
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ over-read bug fixed v0.13.0 (chunk loop bounded by STRT end_offset); minor wire diffs vs BW deferred — see "SUB 5A — chunk counter formula" |
|
||||||
| Event advance / next key | 1F | ✅ |
|
| Event advance / next key | 1F | ✅ |
|
||||||
| **Write commands (push config to device)** | **68–83** | ✅ new v0.8.0 |
|
| **Write commands (push config to device)** | **68–83** | ✅ new v0.8.0 |
|
||||||
| **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ new v0.9.0 |
|
| **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ new v0.9.0 |
|
||||||
|
|||||||
+130
-18
@@ -123,8 +123,11 @@ def build_5a_frame(offset_word: int, raw_params: bytes) -> bytes:
|
|||||||
Returns:
|
Returns:
|
||||||
Complete frame bytes: [ACK][STX][stuffed_section][chk][ETX]
|
Complete frame bytes: [ACK][STX][stuffed_section][chk][ETX]
|
||||||
"""
|
"""
|
||||||
if len(raw_params) not in (10, 11):
|
if len(raw_params) not in (10, 11, 12):
|
||||||
raise ValueError(f"raw_params must be 10 or 11 bytes, got {len(raw_params)}")
|
# 10 = termination params; 11 = regular probe / chunk params;
|
||||||
|
# 12 = metadata-page params (extra trailing 0x00 — BW byte-perfect quirk
|
||||||
|
# for the two fixed metadata reads at counter=0x1002 and 0x1004).
|
||||||
|
raise ValueError(f"raw_params must be 10/11/12 bytes, got {len(raw_params)}")
|
||||||
|
|
||||||
# Build stuffed section between STX and checksum
|
# Build stuffed section between STX and checksum
|
||||||
s = bytearray()
|
s = bytearray()
|
||||||
@@ -398,28 +401,21 @@ def bulk_waveform_params(key4: bytes, counter: int, *, is_probe: bool = False) -
|
|||||||
|
|
||||||
def bulk_waveform_term_params(key4: bytes, counter: int) -> bytes:
|
def bulk_waveform_term_params(key4: bytes, counter: int) -> bytes:
|
||||||
"""
|
"""
|
||||||
Build the 10-byte params block for the SUB 5A termination request.
|
DEPRECATED 2026-05-01 — see bulk_waveform_term_v2().
|
||||||
|
|
||||||
The termination request uses offset=0x005A and a DIFFERENT params layout —
|
Build the 10-byte params block for the SUB 5A termination request, OLD layout
|
||||||
the leading 0x00 byte is dropped, key4[0:2] shifts to params[0:2], and the
|
(used in conjunction with the fixed offset_word=0x005A). Kept for backward
|
||||||
counter high byte is at params[2]:
|
compatibility — produces a tiny ~100-byte device-side terminator response
|
||||||
|
rather than the proper partial-last-chunk + footer payload that BW gets.
|
||||||
|
|
||||||
params[0] = key4[0]
|
params[0] = key4[0]
|
||||||
params[1] = key4[1]
|
params[1] = key4[1]
|
||||||
params[2] = (counter >> 8) & 0xFF
|
params[2] = (counter >> 8) & 0xFF
|
||||||
params[3:] = zeros
|
params[3:] = zeros
|
||||||
|
|
||||||
Counter for the termination request = last_regular_counter + 0x0400.
|
Use bulk_waveform_term_v2() for new code — it computes the verified
|
||||||
|
offset_word + params from end_offset (extracted from STRT) and the last
|
||||||
Confirmed from 1-2-26 BW TX capture: final request (frame 83) uses
|
chunk counter.
|
||||||
offset=0x005A, params[0:3] = key4[0:2] + term_counter_hi.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key4: 4-byte waveform key.
|
|
||||||
counter: Termination counter (= last regular counter + 0x0400).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
10-byte params block.
|
|
||||||
"""
|
"""
|
||||||
if len(key4) != 4:
|
if len(key4) != 4:
|
||||||
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
|
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
|
||||||
@@ -430,6 +426,123 @@ def bulk_waveform_term_params(key4: bytes, counter: int) -> bytes:
|
|||||||
return bytes(p)
|
return bytes(p)
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_waveform_term_v2(
|
||||||
|
key4: bytes,
|
||||||
|
end_offset: int,
|
||||||
|
last_chunk_counter: int,
|
||||||
|
) -> tuple[int, bytes]:
|
||||||
|
"""
|
||||||
|
Compute the SUB 5A TERM frame's offset_word and 10-byte params block.
|
||||||
|
|
||||||
|
Confirmed across 3 events (4-27-26 + 5-1-26 captures):
|
||||||
|
|
||||||
|
next_boundary = last_chunk_counter + 0x0200
|
||||||
|
offset_word = end_offset - next_boundary (residual byte count)
|
||||||
|
params[0] = key4[0] (= 0x01 on every observed device)
|
||||||
|
params[1] = key4[1] (= 0x11)
|
||||||
|
params[2] = (next_boundary >> 8) & 0xFF
|
||||||
|
params[3] = next_boundary & 0xFF
|
||||||
|
params[4:10] = zeros
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
| end_offset | last_chunk | next_boundary | offset_word | params[2:4] |
|
||||||
|
| 0x1ABE | 0x1800 | 0x1A00 | 0x00BE | 1A 00 |
|
||||||
|
| 0x21F2 | 0x1E00 | 0x2000 | 0x01F2 | 20 00 |
|
||||||
|
| 0x417E | 0x3E38 | 0x4038 | 0x0146 | 40 38 |
|
||||||
|
|
||||||
|
The device receives `requested_address = (params[2] << 8) | offset_word`
|
||||||
|
and replies with `(end_offset - next_boundary)` bytes of waveform tail
|
||||||
|
starting at `next_boundary` — including the 26-byte file footer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key4: 4-byte waveform key for this event.
|
||||||
|
end_offset: Event-end pointer (= `(end_key[2] << 8) | end_key[3]`
|
||||||
|
from the STRT record at data[23:27] of A5[0]).
|
||||||
|
last_chunk_counter: Counter of the last full 0x0200-byte chunk fetched
|
||||||
|
(the chunk that covers [last_chunk_counter,
|
||||||
|
last_chunk_counter + 0x0200)).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(offset_word, params10) tuple. Pass as
|
||||||
|
`build_5a_frame(offset_word, params)`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: on inconsistent inputs.
|
||||||
|
"""
|
||||||
|
if len(key4) != 4:
|
||||||
|
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
|
||||||
|
next_boundary = last_chunk_counter + 0x0200
|
||||||
|
if next_boundary > 0xFFFF:
|
||||||
|
raise ValueError(
|
||||||
|
f"next_boundary 0x{next_boundary:04X} exceeds uint16; check inputs"
|
||||||
|
)
|
||||||
|
if end_offset <= last_chunk_counter:
|
||||||
|
raise ValueError(
|
||||||
|
f"end_offset 0x{end_offset:04X} must be > "
|
||||||
|
f"last_chunk_counter 0x{last_chunk_counter:04X}"
|
||||||
|
)
|
||||||
|
offset_word = end_offset - next_boundary
|
||||||
|
if offset_word < 0:
|
||||||
|
# Last chunk overshot end_offset; caller should have stopped one chunk
|
||||||
|
# earlier. Treat as zero residual.
|
||||||
|
offset_word = 0
|
||||||
|
if offset_word > 0xFFFF:
|
||||||
|
raise ValueError(
|
||||||
|
f"offset_word 0x{offset_word:04X} exceeds uint16"
|
||||||
|
)
|
||||||
|
p = bytearray(10)
|
||||||
|
p[0] = key4[0]
|
||||||
|
p[1] = key4[1]
|
||||||
|
p[2] = (next_boundary >> 8) & 0xFF
|
||||||
|
p[3] = next_boundary & 0xFF
|
||||||
|
return offset_word, bytes(p)
|
||||||
|
|
||||||
|
|
||||||
|
# ── End-offset extraction from STRT record ────────────────────────────────────
|
||||||
|
|
||||||
|
STRT_MARKER = b"STRT"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_strt_end_offset(a5_data: bytes) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Extract the event-end offset from the STRT record in an A5 response payload.
|
||||||
|
|
||||||
|
The first A5 response (the probe response, or the first chunk for events
|
||||||
|
with non-zero start_key[2:4]) contains a STRT record at byte offset 17 of
|
||||||
|
`data`. Layout:
|
||||||
|
|
||||||
|
data[17:21] "STRT"
|
||||||
|
data[21:23] ff fe sentinel
|
||||||
|
data[23:27] end_key ← 4-byte key of where this event ENDS
|
||||||
|
data[27:31] start_key
|
||||||
|
...
|
||||||
|
|
||||||
|
Returns `(end_key[2] << 8) | end_key[3]` — the absolute device-buffer
|
||||||
|
address where the event ends. Use this to bound the chunk loop and to
|
||||||
|
compute the TERM frame.
|
||||||
|
|
||||||
|
Verified end_offset values:
|
||||||
|
| event start_key | end_key | end_offset |
|
||||||
|
| 01110000 | 01111ABE | 0x1ABE |
|
||||||
|
| 01110000 | 011121F2 | 0x21F2 |
|
||||||
|
| 011121F2 | 0111417E | 0x417E |
|
||||||
|
|
||||||
|
Args:
|
||||||
|
a5_data: The `data` field of an A5 response frame (frame.data).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The end_offset (uint16) if STRT is found, else None.
|
||||||
|
"""
|
||||||
|
pos = a5_data.find(STRT_MARKER)
|
||||||
|
if pos < 0 or pos + 10 > len(a5_data):
|
||||||
|
return None
|
||||||
|
# data[pos+4:pos+6] is "ff fe"; data[pos+6:pos+10] is end_key.
|
||||||
|
end_key = a5_data[pos + 6 : pos + 10]
|
||||||
|
if len(end_key) < 4:
|
||||||
|
return None
|
||||||
|
return (end_key[2] << 8) | end_key[3]
|
||||||
|
|
||||||
|
|
||||||
# ── Pre-built POLL frames ─────────────────────────────────────────────────────
|
# ── Pre-built POLL frames ─────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# POLL (SUB 0x5B) uses the same two-step pattern as all other reads — the
|
# POLL (SUB 0x5B) uses the same two-step pattern as all other reads — the
|
||||||
@@ -470,7 +583,6 @@ class S3Frame:
|
|||||||
|
|
||||||
|
|
||||||
# ── Streaming S3 frame parser ─────────────────────────────────────────────────
|
# ── Streaming S3 frame parser ─────────────────────────────────────────────────
|
||||||
|
|
||||||
class S3FrameParser:
|
class S3FrameParser:
|
||||||
"""
|
"""
|
||||||
Incremental byte-stream parser for S3→BW response frames.
|
Incremental byte-stream parser for S3→BW response frames.
|
||||||
|
|||||||
+45
-10
@@ -35,6 +35,8 @@ from .framing import (
|
|||||||
token_params,
|
token_params,
|
||||||
bulk_waveform_params,
|
bulk_waveform_params,
|
||||||
bulk_waveform_term_params,
|
bulk_waveform_term_params,
|
||||||
|
bulk_waveform_term_v2,
|
||||||
|
parse_strt_end_offset,
|
||||||
POLL_PROBE,
|
POLL_PROBE,
|
||||||
POLL_DATA,
|
POLL_DATA,
|
||||||
SESSION_RESET,
|
SESSION_RESET,
|
||||||
@@ -122,16 +124,21 @@ DATA_LENGTHS: dict[int, int] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# SUB 5A (BULK_WAVEFORM_STREAM) protocol constants.
|
# SUB 5A (BULK_WAVEFORM_STREAM) protocol constants.
|
||||||
# Confirmed from 1-2-26 BW TX capture analysis (2026-04-02).
|
#
|
||||||
_BULK_CHUNK_OFFSET = 0x1004 # offset field for probe + all regular chunk requests ✅
|
# 2026-05-01 minimal-fix: the chunk-counter walk is now bounded by the event's
|
||||||
_BULK_TERM_OFFSET = 0x005A # offset field for termination request ✅
|
# `end_offset` extracted from the STRT record at data[23:27] of the probe
|
||||||
_BULK_COUNTER_STEP = 0x0400 # chunk counter increment per chunk ✅
|
# response. Without this bound the loop kept asking for chunks past the event
|
||||||
# Chunk counter formula: key4[2:4] + (chunk_num - 1) * 0x0400
|
# end and the device responded with post-event circular-buffer garbage,
|
||||||
# where key4[2:4] is the event's circular-buffer base offset ((key4[2]<<8)|key4[3]).
|
# corrupting reconstructed Blastware files for events ≥ 2 sec.
|
||||||
# Earlier captures showed 0x1004 for chunk 1 of key 01110000 — that was a Blastware
|
#
|
||||||
# artifact. For keys where key4[2:4] != 0x0000 (e.g. key 01111884) the old
|
# We keep the OLD 0x0400 chunk step here (BW actually uses 0x0200 — see §7.8.5
|
||||||
# "n * 0x0400" formula sends counters from the wrong buffer region and the device
|
# of the protocol reference for the corrected understanding) because the
|
||||||
# returns data from a different event. Confirmed correct 2026-04-24.
|
# existing blastware_file.py builder relies on the 0x0400-step frame structure
|
||||||
|
# to produce valid files. Switching to BW's 0x0200 step is a separate task
|
||||||
|
# that also requires updating the file builder.
|
||||||
|
_BULK_CHUNK_OFFSET = 0x1004 # offset_word for probe + all chunk requests
|
||||||
|
_BULK_TERM_OFFSET = 0x005A # offset_word for the legacy terminator
|
||||||
|
_BULK_COUNTER_STEP = 0x0400 # chunk counter increment
|
||||||
|
|
||||||
# Default timeout values (seconds).
|
# Default timeout values (seconds).
|
||||||
# MiniMate Plus is a slow device — keep these generous.
|
# MiniMate Plus is a slow device — keep these generous.
|
||||||
@@ -610,6 +617,24 @@ class MiniMateProtocol:
|
|||||||
frames_data.append(rsp)
|
frames_data.append(rsp)
|
||||||
log.debug("5A A5[0] page_key=0x%04X %d bytes", rsp.page_key, len(rsp.data))
|
log.debug("5A A5[0] page_key=0x%04X %d bytes", rsp.page_key, len(rsp.data))
|
||||||
|
|
||||||
|
# ── Parse STRT end_offset from probe response (NEW 2026-05-01) ────────
|
||||||
|
# The first A5 response contains a STRT record at data[17:]. The
|
||||||
|
# bytes at data[23:27] are the event's end-key, whose low 16 bits
|
||||||
|
# are the absolute device-buffer address where the event ends. Use
|
||||||
|
# this to bound the chunk loop and stop the over-read past event end.
|
||||||
|
# See docs/instantel_protocol_reference.md §7.8.5 and CLAUDE.md
|
||||||
|
# "SUB 5A — STRT record encodes end_offset".
|
||||||
|
_end_offset = parse_strt_end_offset(rsp.data)
|
||||||
|
if _end_offset is None:
|
||||||
|
# Defensive fallback — let max_chunks cap the walk.
|
||||||
|
log.warning("5A: STRT not found in probe; cannot bound chunk loop")
|
||||||
|
_end_offset = 0xFFFF
|
||||||
|
else:
|
||||||
|
log.debug(
|
||||||
|
"5A STRT start_offset=0x%04X end_offset=0x%04X size=0x%04X",
|
||||||
|
_key4_offset, _end_offset, _end_offset - _key4_offset,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Step 2: chunk loop ───────────────────────────────────────────────
|
# ── Step 2: chunk loop ───────────────────────────────────────────────
|
||||||
# Counter formula: _chunk_base + (chunk_num - 1) * 0x0400
|
# Counter formula: _chunk_base + (chunk_num - 1) * 0x0400
|
||||||
# where _chunk_base = max(key4[2:4], 0x0400).
|
# where _chunk_base = max(key4[2:4], 0x0400).
|
||||||
@@ -629,6 +654,16 @@ class MiniMateProtocol:
|
|||||||
_chunk_base = max(_key4_offset, _BULK_COUNTER_STEP)
|
_chunk_base = max(_key4_offset, _BULK_COUNTER_STEP)
|
||||||
for chunk_num in range(1, max_chunks + 1):
|
for chunk_num in range(1, max_chunks + 1):
|
||||||
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
|
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
|
||||||
|
# Stop when we'd step past the event end (NEW 2026-05-01). Without
|
||||||
|
# this, the device returns post-event circular-buffer data which
|
||||||
|
# corrupts the reconstructed file for events ≥ 2 sec.
|
||||||
|
if counter >= _end_offset:
|
||||||
|
log.debug(
|
||||||
|
"5A chunk loop done at counter=0x%04X (end=0x%04X); "
|
||||||
|
"%d chunks fetched",
|
||||||
|
counter, _end_offset, len(frames_data),
|
||||||
|
)
|
||||||
|
break
|
||||||
params = bulk_waveform_params(key4, counter)
|
params = bulk_waveform_params(key4, counter)
|
||||||
log.debug("5A chunk %d counter=0x%04X", chunk_num, counter)
|
log.debug("5A chunk %d counter=0x%04X", chunk_num, counter)
|
||||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||||
|
|||||||
+4
-128
@@ -114,8 +114,6 @@ class BridgePanel(tk.Frame):
|
|||||||
on_capture_complete(bw_path, s3_path, label)— a capture segment finished
|
on_capture_complete(bw_path, s3_path, label)— a capture segment finished
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped,
|
|
||||||
on_capture_started=None, on_capture_complete=None, **kw):
|
|
||||||
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped,
|
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped,
|
||||||
on_capture_started=None, on_capture_complete=None, **kw):
|
on_capture_started=None, on_capture_complete=None, **kw):
|
||||||
super().__init__(parent, bg=BG2, **kw)
|
super().__init__(parent, bg=BG2, **kw)
|
||||||
@@ -141,10 +139,6 @@ class BridgePanel(tk.Frame):
|
|||||||
self._cap_history: list[dict] = [] # {label, status, bw, s3}
|
self._cap_history: list[dict] = [] # {label, status, bw, s3}
|
||||||
# mode
|
# mode
|
||||||
self._mode = tk.StringVar(value="serial")
|
self._mode = tk.StringVar(value="serial")
|
||||||
# Capture state
|
|
||||||
self._capturing = False
|
|
||||||
self._cap_label: Optional[str] = None
|
|
||||||
self._cap_history: list[dict] = [] # {label, status, bw, s3}
|
|
||||||
self._build()
|
self._build()
|
||||||
self._poll_stdout()
|
self._poll_stdout()
|
||||||
self._poll_tcp_log()
|
self._poll_tcp_log()
|
||||||
@@ -246,18 +240,6 @@ class BridgePanel(tk.Frame):
|
|||||||
command=self._stop_capture, state="disabled")
|
command=self._stop_capture, state="disabled")
|
||||||
self.stop_cap_btn.pack(side=tk.LEFT, padx=4)
|
self.stop_cap_btn.pack(side=tk.LEFT, padx=4)
|
||||||
|
|
||||||
tk.Frame(btn_row, bg=BG2, width=16).pack(side=tk.LEFT) # spacer
|
|
||||||
|
|
||||||
self.cap_btn = tk.Button(btn_row, text="⬤ New Capture", bg=ORANGE, fg="#000000",
|
|
||||||
relief="flat", padx=10, cursor="hand2", font=MONO_B,
|
|
||||||
command=self._start_capture, state="disabled")
|
|
||||||
self.cap_btn.pack(side=tk.LEFT, padx=4)
|
|
||||||
|
|
||||||
self.stop_cap_btn = tk.Button(btn_row, text="■ Stop Capture", bg=BG3, fg=RED,
|
|
||||||
relief="flat", padx=10, cursor="hand2", font=MONO_B,
|
|
||||||
command=self._stop_capture, state="disabled")
|
|
||||||
self.stop_cap_btn.pack(side=tk.LEFT, padx=4)
|
|
||||||
|
|
||||||
self.mark_btn = tk.Button(btn_row, text="Add Mark", bg=BG3, fg=FG,
|
self.mark_btn = tk.Button(btn_row, text="Add Mark", bg=BG3, fg=FG,
|
||||||
relief="flat", padx=10, cursor="hand2", font=MONO,
|
relief="flat", padx=10, cursor="hand2", font=MONO,
|
||||||
command=self.add_mark, state="disabled")
|
command=self.add_mark, state="disabled")
|
||||||
@@ -319,7 +301,6 @@ class BridgePanel(tk.Frame):
|
|||||||
|
|
||||||
# Log output
|
# Log output
|
||||||
self.log_view = scrolledtext.ScrolledText(
|
self.log_view = scrolledtext.ScrolledText(
|
||||||
self, height=14, font=MONO_SM,
|
|
||||||
self, height=14, font=MONO_SM,
|
self, height=14, font=MONO_SM,
|
||||||
bg=BG, fg=FG, insertbackground=FG,
|
bg=BG, fg=FG, insertbackground=FG,
|
||||||
relief="flat", state="disabled",
|
relief="flat", state="disabled",
|
||||||
@@ -462,15 +443,12 @@ class BridgePanel(tk.Frame):
|
|||||||
self.start_btn.configure(state="disabled")
|
self.start_btn.configure(state="disabled")
|
||||||
self.stop_btn.configure(state="normal", bg=RED)
|
self.stop_btn.configure(state="normal", bg=RED)
|
||||||
self.cap_btn.configure(state="normal")
|
self.cap_btn.configure(state="normal")
|
||||||
self.cap_btn.configure(state="normal")
|
|
||||||
self._append_log(f"== Bridge started [{ts}] ==\n")
|
self._append_log(f"== Bridge started [{ts}] ==\n")
|
||||||
self._append_log(" Click 'New Capture' when ready to record.\n")
|
self._append_log(" Click 'New Capture' when ready to record.\n")
|
||||||
self._on_started(struct_bin_path)
|
|
||||||
|
|
||||||
# Notify parent — no raw files yet, just the structured bin path
|
# Notify parent — no raw files yet, just the structured bin path
|
||||||
self._on_started(struct_bin_path)
|
self._on_started(struct_bin_path)
|
||||||
|
|
||||||
def stop_bridge(self) -> None:
|
def _stop_serial(self) -> None:
|
||||||
if self.process and self.process.poll() is None:
|
if self.process and self.process.poll() is None:
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
try:
|
try:
|
||||||
@@ -480,17 +458,6 @@ class BridgePanel(tk.Frame):
|
|||||||
self._bridge_ended()
|
self._bridge_ended()
|
||||||
self._on_stopped()
|
self._on_stopped()
|
||||||
|
|
||||||
def _bridge_ended(self) -> None:
|
|
||||||
self.status_var.set("Stopped")
|
|
||||||
self.start_btn.configure(state="normal")
|
|
||||||
self.stop_btn.configure(state="disabled", bg=BG3)
|
|
||||||
self.cap_btn.configure(state="disabled")
|
|
||||||
self.stop_cap_btn.configure(state="disabled", bg=BG3)
|
|
||||||
self.mark_btn.configure(state="disabled")
|
|
||||||
self._capturing = False
|
|
||||||
self._cap_label = None
|
|
||||||
self._append_log("== Bridge stopped ==\n")
|
|
||||||
|
|
||||||
def _reader_thread(self) -> None:
|
def _reader_thread(self) -> None:
|
||||||
if not self.process or not self.process.stdout:
|
if not self.process or not self.process.stdout:
|
||||||
return
|
return
|
||||||
@@ -531,9 +498,7 @@ class BridgePanel(tk.Frame):
|
|||||||
# ── capture control ───────────────────────────────────────────────────
|
# ── capture control ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def _start_capture(self) -> None:
|
def _start_capture(self) -> None:
|
||||||
"""Ask for a label and tell the bridge to start writing raw tap files."""
|
"""Ask for a label and start writing raw tap files (serial subprocess or TCP files)."""
|
||||||
if not self.process or self.process.poll() is not None:
|
|
||||||
return
|
|
||||||
label = simpledialog.askstring(
|
label = simpledialog.askstring(
|
||||||
"New Capture",
|
"New Capture",
|
||||||
"Label for this capture\n(e.g. 'recording_mode_continuous').\nLeave blank for timestamp only:",
|
"Label for this capture\n(e.g. 'recording_mode_continuous').\nLeave blank for timestamp only:",
|
||||||
@@ -542,88 +507,6 @@ class BridgePanel(tk.Frame):
|
|||||||
if label is None:
|
if label is None:
|
||||||
return # user hit Cancel
|
return # user hit Cancel
|
||||||
label = label.strip()
|
label = label.strip()
|
||||||
try:
|
|
||||||
self.process.stdin.write(f"CAP_START:{label}\n")
|
|
||||||
self.process.stdin.flush()
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("Error", f"Failed to start capture:\n{e}")
|
|
||||||
return
|
|
||||||
self._capturing = True
|
|
||||||
self._cap_label = label or datetime.datetime.now().strftime("%H%M%S")
|
|
||||||
self.cap_btn.configure(state="disabled")
|
|
||||||
self.stop_cap_btn.configure(state="normal", bg=RED)
|
|
||||||
self.mark_btn.configure(state="normal")
|
|
||||||
self._append_log(f"[CAPTURE] Starting: {self._cap_label!r}...\n")
|
|
||||||
# Add to history as recording (paths filled in when [CAP_START] arrives)
|
|
||||||
self._cap_history.append({"label": self._cap_label, "status": "recording",
|
|
||||||
"bw": None, "s3": None})
|
|
||||||
self._refresh_hist()
|
|
||||||
|
|
||||||
def _stop_capture(self) -> None:
|
|
||||||
"""Tell the bridge to flush and close the current raw tap files."""
|
|
||||||
if not self.process or self.process.poll() is not None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self.process.stdin.write("CAP_STOP\n")
|
|
||||||
self.process.stdin.flush()
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("Error", f"Failed to stop capture:\n{e}")
|
|
||||||
# UI is updated when [CAP_STOP] arrives in stdout
|
|
||||||
|
|
||||||
def _on_cap_started_msg(self, bw_path: str, s3_path: str) -> None:
|
|
||||||
"""Called when bridge confirms capture has started (files are open)."""
|
|
||||||
# Fill in paths for the last 'recording' history entry
|
|
||||||
for entry in reversed(self._cap_history):
|
|
||||||
if entry["status"] == "recording" and entry["bw"] is None:
|
|
||||||
entry["bw"] = bw_path
|
|
||||||
entry["s3"] = s3_path
|
|
||||||
break
|
|
||||||
if self._on_cap_started:
|
|
||||||
self._on_cap_started(bw_path, s3_path, self._cap_label or "")
|
|
||||||
|
|
||||||
def _on_cap_stopped_msg(self, bw_path: str, s3_path: str) -> None:
|
|
||||||
"""Called when bridge confirms capture has stopped (files are closed)."""
|
|
||||||
label = self._cap_label or "capture"
|
|
||||||
# Mark history entry as done
|
|
||||||
for entry in reversed(self._cap_history):
|
|
||||||
if entry["status"] == "recording":
|
|
||||||
entry["status"] = "done"
|
|
||||||
entry["bw"] = bw_path
|
|
||||||
entry["s3"] = s3_path
|
|
||||||
break
|
|
||||||
self._refresh_hist()
|
|
||||||
self._capturing = False
|
|
||||||
self._cap_label = None
|
|
||||||
self.cap_btn.configure(state="normal")
|
|
||||||
self.stop_cap_btn.configure(state="disabled", bg=BG3)
|
|
||||||
self._append_log(f"[CAPTURE] Done: {label!r} — ready in Analyzer\n")
|
|
||||||
if self._on_cap_complete:
|
|
||||||
self._on_cap_complete(bw_path, s3_path, label)
|
|
||||||
|
|
||||||
def _refresh_hist(self) -> None:
|
|
||||||
self._hist_lb.delete(0, tk.END)
|
|
||||||
for entry in self._cap_history:
|
|
||||||
icon = "🔴" if entry["status"] == "recording" else "✅"
|
|
||||||
label = entry["label"] or "(unlabeled)"
|
|
||||||
self._hist_lb.insert(tk.END, f" {icon} {label}")
|
|
||||||
if self._cap_history:
|
|
||||||
self._hist_lb.see(tk.END)
|
|
||||||
|
|
||||||
def _on_hist_dblclick(self, _e=None) -> None:
|
|
||||||
sel = self._hist_lb.curselection()
|
|
||||||
if not sel:
|
|
||||||
return
|
|
||||||
entry = self._cap_history[sel[0]]
|
|
||||||
if entry["status"] == "done" and entry["bw"] and entry["s3"]:
|
|
||||||
if self._on_cap_complete:
|
|
||||||
self._on_cap_complete(entry["bw"], entry["s3"], entry["label"])
|
|
||||||
|
|
||||||
# ── mark ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def add_mark(self) -> None:
|
|
||||||
if not self.process or not self.process.stdin or self.process.poll() is not None:
|
|
||||||
return
|
|
||||||
label = label.strip()
|
|
||||||
self._capturing = True
|
self._capturing = True
|
||||||
self._cap_label = label or datetime.datetime.now().strftime("%H%M%S")
|
self._cap_label = label or datetime.datetime.now().strftime("%H%M%S")
|
||||||
|
|
||||||
@@ -664,6 +547,7 @@ class BridgePanel(tk.Frame):
|
|||||||
self._append_log(f"[CAPTURE] Starting: {self._cap_label!r}...\n")
|
self._append_log(f"[CAPTURE] Starting: {self._cap_label!r}...\n")
|
||||||
|
|
||||||
def _stop_capture(self) -> None:
|
def _stop_capture(self) -> None:
|
||||||
|
"""Flush and close the current raw tap files (TCP) or signal the bridge subprocess (serial)."""
|
||||||
if self._mode.get() == "tcp":
|
if self._mode.get() == "tcp":
|
||||||
with self._tcp_cap_lock:
|
with self._tcp_cap_lock:
|
||||||
bw_path = self._tcp_cap_bw_path
|
bw_path = self._tcp_cap_bw_path
|
||||||
@@ -686,6 +570,7 @@ class BridgePanel(tk.Frame):
|
|||||||
self.process.stdin.flush()
|
self.process.stdin.flush()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messagebox.showerror("Error", f"Failed to stop capture:\n{e}")
|
messagebox.showerror("Error", f"Failed to stop capture:\n{e}")
|
||||||
|
# UI is updated when [CAP_STOP] arrives in stdout
|
||||||
|
|
||||||
# ── TCP mode ──────────────────────────────────────────────────────────
|
# ── TCP mode ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1173,14 +1058,6 @@ class AnalyzerPanel(tk.Frame):
|
|||||||
self.state.bw_path = bwp
|
self.state.bw_path = bwp
|
||||||
self._do_analyze(s3p, bwp)
|
self._do_analyze(s3p, bwp)
|
||||||
|
|
||||||
def _browse_bin(self) -> None:
|
|
||||||
path = filedialog.askopenfilename(
|
|
||||||
title="Select session .bin file",
|
|
||||||
filetypes=[("Binary", "*.bin"), ("All files", "*.*")],
|
|
||||||
)
|
|
||||||
if path:
|
|
||||||
self.bin_var.set(path)
|
|
||||||
|
|
||||||
def _do_analyze(self, s3_path: Path, bw_path: Path) -> None:
|
def _do_analyze(self, s3_path: Path, bw_path: Path) -> None:
|
||||||
self.status_var.set("Parsing...")
|
self.status_var.set("Parsing...")
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
@@ -1611,7 +1488,6 @@ class AnalyzerPanel(tk.Frame):
|
|||||||
w.configure(state="normal"); w.insert(tk.END, "\n"); w.configure(state="disabled")
|
w.configure(state="normal"); w.insert(tk.END, "\n"); w.configure(state="disabled")
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Serial Watch panel — tap the RS-232 line between device and modem
|
# Serial Watch panel — tap the RS-232 line between device and modem
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user