Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1a6fd5386 | |||
| 6b875e161b | |||
| f5c81f2cab | |||
| a7585cb5e0 | |||
| ae30a02898 | |||
| 2f084ed105 | |||
| 7976b544ed | |||
| 0415af19b4 | |||
| 35c3f4f945 | |||
| 43c8158493 | |||
| 242666f358 | |||
| 03540fdc00 | |||
| f83fd880c0 | |||
| ab2c11e9a9 | |||
| fa887b85d9 | |||
| ecd980d345 | |||
| bc9f16e503 | |||
| aa2b02535b | |||
| 2a2031c3a9 | |||
| 9e7e0bce2a | |||
| 5e2f3bf2a1 | |||
| 39ebd4bdaa | |||
| 84c87d0b57 | |||
| ec6362cb8e | |||
| 3eeafd24aa | |||
| 8cb8b86192 | |||
| 6dcca4da79 | |||
| c47e3a3af0 | |||
| dfbc9f29c5 | |||
| 4331215e23 | |||
| b3dcfe7239 | |||
| 9b5cdfd857 | |||
| 7129aae279 | |||
| 2186bc238b | |||
| 3fb24e1895 | |||
| 7bdd7c92f2 | |||
| b6ffdcfa87 | |||
| a7aec31915 | |||
| 34df9ec5fa | |||
| eec6c3dc6a | |||
| 702e06873e | |||
| 94767f5a9d | |||
| e04114fd6c | |||
| f10c5c1b86 | |||
| aa28495a43 | |||
| b23cf4bb50 | |||
| 969010b983 | |||
| 5fba9bcff8 |
+132
@@ -4,6 +4,138 @@ All notable changes to seismo-relay are documented here.
|
||||
|
||||
---
|
||||
|
||||
## v0.12.5 — 2026-04-21
|
||||
|
||||
### Changed
|
||||
|
||||
- **`s3_bridge.py` — raw captures always-on by default** — `--raw-bw` and `--raw-s3` now
|
||||
default to `"auto"` instead of `None`. Every bridge session automatically generates
|
||||
timestamped `raw_bw_<ts>.bin` and `raw_s3_<ts>.bin` files alongside the `.bin`/`.log`
|
||||
session files. Pass `--raw-bw ""` (explicit empty string) to disable if needed.
|
||||
|
||||
- **`gui_bridge.py` — raw capture checkboxes pre-checked** — Both "BW→S3 raw" and
|
||||
"S3→BW raw" checkboxes start checked. Path fields are empty by default (bridge auto-names
|
||||
the files). Unchecking a box passes `--raw-bw ""` to explicitly disable capture.
|
||||
|
||||
- **`ach_server.py` — TX capture added (`raw_tx_<ts>.bin`)** — Every ACH inbound session
|
||||
now saves both directions: `raw_rx_<ts>.bin` (device → us, S3 side, as before) and
|
||||
`raw_tx_<ts>.bin` (us → device, BW side). Both files are usable in the Analyzer.
|
||||
TX bytes are buffered in memory until startup handshake succeeds (same as RX), preventing
|
||||
scanner probes from creating empty files.
|
||||
|
||||
---
|
||||
|
||||
## v0.12.4 — 2026-04-21 (protocol analysis / docs only — no code changes)
|
||||
|
||||
### Discovered
|
||||
|
||||
- **compliance_raw is wire-encoded, not logical bytes** — `read_compliance_config()` returns
|
||||
bytes that include DLE prefix bytes (`0x10`) before any `0x03` values (because S3FrameParser
|
||||
preserves DLE+ETX inner-frame pairs as two literal bytes). The previous CLAUDE.md claim that
|
||||
"S3FrameParser handles this transparently so compliance_raw contains logical bytes" was wrong.
|
||||
|
||||
- **anchor-9 behavior per recording mode** (confirmed from 4-20-26 BW write captures):
|
||||
- Single Shot (0x00) / Continuous (0x01): anchor-9 = `0x00`
|
||||
- Histogram (0x03): anchor-9 = `0x10` — the E5 DLE prefix for the `0x03` recording_mode byte
|
||||
- Histogram+Continuous (0x04): anchor-9 = `0x10` — an actual stored config byte for this mode
|
||||
Anchor position shifts by ±1 when recording_mode = `0x03` due to the extra DLE byte; the
|
||||
dynamic anchor search (`buf.find(ANCHOR, 0, 150)`) handles this correctly without code changes.
|
||||
|
||||
- **Write frame ETX escaping** — BW escapes `0x03` bytes in write frame data as `0x10 0x03`
|
||||
on the wire. Our `build_bw_write_frame` sends data bytes raw without ETX escaping. Device
|
||||
accepts our raw writes for all tested modes. Hypothesis: device write parser uses the
|
||||
offset/length field for frame boundaries, not ETX scanning, making ETX escaping optional.
|
||||
Histogram mode (recording_mode = 0x03) write via SFM from a non-Histogram starting state
|
||||
not yet tested.
|
||||
|
||||
- **BW write payload vs E5 read payload are byte-identical** around the anchor region (confirmed
|
||||
by comparing 3-11-26 BW TX and S3 captures). BW does NOT strip DLE prefix bytes before writing;
|
||||
it round-trips the wire-encoded bytes verbatim with only the modified fields changed.
|
||||
|
||||
- **Capture folder content catalogued** — see CLAUDE.md "BW capture reference" table for a
|
||||
summary of all available protocol captures and their contents.
|
||||
|
||||
---
|
||||
|
||||
## v0.12.3 — 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Auto Call Home config protocol** — Full read/write/decode/encode pipeline for the
|
||||
device's Remote Access → Setup Unit ACH settings, confirmed from 4-20-26 call home
|
||||
settings captures.
|
||||
|
||||
**Protocol (new):**
|
||||
- `SUB 0x2C` — Call Home Config READ (response `0xD3`); two-step read; data offset
|
||||
`0x7C` = 124; raw payload 125 bytes (1-byte longer than DATA_LENGTH due to DLE-escaped
|
||||
`\x10\x03` at raw[117:119] representing num_retries = 3)
|
||||
- `SUB 0x7E` — Call Home Config WRITE (response `0x81`); 127-byte payload (125-byte read
|
||||
payload + `\x00\x00`); offset = `data[1]+2 = 0x7E`; write format (DLE-aware checksum)
|
||||
- `SUB 0x7F` — Call Home WRITE CONFIRM (response `0x80`); no data
|
||||
|
||||
**Field map (confirmed from 10-frame BW TX diff):**
|
||||
- `raw[5]` — auto_call_home_enabled (bool)
|
||||
- `raw[6:46]` — dial_string (40-byte null-padded ASCII)
|
||||
- `raw[87]` — after_event_recorded (bool)
|
||||
- `raw[91]` — at_specified_times (bool)
|
||||
- `raw[93]` — time1_enabled / `raw[101]` — time1_hour / `raw[102]` — time1_min
|
||||
- `raw[95]` — time2_enabled / `raw[105]` — time2_hour / `raw[106]` — time2_min
|
||||
- `raw[117:119]` — `\x10\x03` (DLE-escaped 0x03 = num_retries value 3)
|
||||
- `raw[120]` — time_between_retries_sec / `raw[122]` — wait_for_connection_sec / `raw[124]` — warm_up_time_sec
|
||||
|
||||
**Library (`minimateplus/`):**
|
||||
- `models.py` — `CallHomeConfig` dataclass (14 fields; `raw` bytes preserved for
|
||||
round-trip writes)
|
||||
- `protocol.py` — `SUB_CALL_HOME = 0x2C`, `SUB_CALL_HOME_WRITE = 0x7E`,
|
||||
`SUB_CALL_HOME_CONFIRM = 0x7F`; `read_call_home_config()`, `write_call_home_config()`
|
||||
- `client.py` — `get_call_home_config()`, `set_call_home_config()`,
|
||||
`_decode_call_home_config()` (handles DLE prefix at raw[117]),
|
||||
`_encode_call_home_config()` (patches in-place; raises `ValueError` if hour/min = 3)
|
||||
|
||||
**REST API (`sfm/server.py`):**
|
||||
- `GET /device/call_home` — reads and decodes call home config from device
|
||||
- `POST /device/call_home` — reads, patches specified fields, writes back to device
|
||||
- `CallHomeConfigBody` Pydantic model with 9 optional writable fields
|
||||
|
||||
**Web UI (`sfm/sfm_webapp.html`):**
|
||||
- New "Call Home" tab with enable flag, dial string (read-only), after-event trigger,
|
||||
at-specified-times flag, two time slots (enable + HH:MM each), and read-only retry
|
||||
settings (num_retries, time_between_retries_sec, wait_for_connection_sec,
|
||||
warm_up_time_sec)
|
||||
- "Read from Device", "Write to Device", "Clear Form" action buttons
|
||||
- Client-side guard: rejects hour or minute value equal to 3 with a clear message
|
||||
explaining the DLE-encoding limitation
|
||||
|
||||
---
|
||||
|
||||
## v0.12.2 — 2026-04-20
|
||||
|
||||
### Added / Fixed
|
||||
|
||||
- **Geophone sensitivity / maximum range field confirmed** — 4-20-26 geo sensitivity
|
||||
captures (1.25 in/s vs 10 in/s) diffed across all three SUB 71 write chunks and both
|
||||
E5 read payloads. The `geo_range` uint8 field per channel is now fully confirmed:
|
||||
- E5 read offset: `channel_label + 33`; SUB 71 write offset: `channel_label + 29`
|
||||
- `0x00` = Normal 10.000 in/s (standard gain); `0x01` = Sensitive 1.250 in/s (high gain)
|
||||
- **Correction:** previous hypothesis (`channel_label+20`, `0x01`=Normal) was wrong.
|
||||
`channel_label+20` reads `0x01` on ALL captures regardless of range — not this field.
|
||||
- `_decode_compliance_config_into`: read offset corrected from `tran_pos+20` → `tran_pos+33`
|
||||
- `_encode_compliance_config`: added `geo_range` parameter; writes to Tran/Vert/Long at `+29`
|
||||
- `apply_config`: added `geo_range` parameter
|
||||
- `POST /device/config`: added `geo_range` to `DeviceConfigBody`
|
||||
- Web UI Config tab: added "Maximum Range — Geo" select (Normal / Sensitive)
|
||||
- Web UI Device tab: added "Max Range (geo)" row to compliance table
|
||||
|
||||
- **`recording_mode` + `histogram_interval_sec` confirmed and implemented** (4-20-26 captures)
|
||||
- `recording_mode`: uint8 at anchor−8 (E5 read) / anchor−7 (write); enum: 0x00=Single Shot,
|
||||
0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
|
||||
- `histogram_interval_sec`: uint16 BE seconds at anchor−4; same offset in read & write;
|
||||
valid: 2, 5, 15, 60, 300, 900 (matching Blastware dropdown: 2s, 5s, 15s, 1m, 5m, 15m)
|
||||
- Both fields added to `ComplianceConfig`, `_decode_compliance_config_into`,
|
||||
`_encode_compliance_config`, `apply_config`, REST API body, and web UI
|
||||
|
||||
---
|
||||
|
||||
## v0.12.1 — 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# seismo-relay `v0.12.0`
|
||||
# seismo-relay `v0.12.1`
|
||||
|
||||
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
|
||||
software for managing MiniMate Plus seismographs.
|
||||
|
||||
+39
-16
@@ -35,6 +35,7 @@ Output per session
|
||||
device_info.json — serial number, firmware version, calibration date, etc.
|
||||
events.json — all events: timestamp, PPV per channel, peaks, metadata
|
||||
raw_rx_<ts>.bin — raw bytes from the device (S3 side) for Analyzer
|
||||
raw_tx_<ts>.bin — raw bytes we sent to the device (BW side) for Analyzer
|
||||
session_<ts>.log — detailed protocol log
|
||||
|
||||
What to look for
|
||||
@@ -172,16 +173,24 @@ class AchSession:
|
||||
transport = SocketTransport(self.sock, peer=self.peer)
|
||||
|
||||
# Collect raw bytes in memory until startup succeeds, then flush to disk.
|
||||
raw_buf: list[bytes] = []
|
||||
_orig_read = transport.read
|
||||
raw_rx_buf: list[bytes] = [] # device → us (S3 side)
|
||||
raw_tx_buf: list[bytes] = [] # us → device (BW side)
|
||||
_orig_read = transport.read
|
||||
_orig_write = transport.write
|
||||
|
||||
def tapped_read(n: int) -> bytes:
|
||||
data = _orig_read(n)
|
||||
if data:
|
||||
raw_buf.append(data)
|
||||
raw_rx_buf.append(data)
|
||||
return data
|
||||
|
||||
transport.read = tapped_read # type: ignore[method-assign]
|
||||
def tapped_write(data: bytes) -> None:
|
||||
_orig_write(data)
|
||||
if data:
|
||||
raw_tx_buf.append(data)
|
||||
|
||||
transport.read = tapped_read # type: ignore[method-assign]
|
||||
transport.write = tapped_write # type: ignore[method-assign]
|
||||
|
||||
serial: Optional[str] = None
|
||||
|
||||
@@ -201,23 +210,35 @@ class AchSession:
|
||||
# Startup succeeded — this is a real unit. Create session dir now.
|
||||
session_dir = self.output_dir / f"ach_inbound_{ts}"
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = session_dir / f"session_{ts}.log"
|
||||
raw_path = session_dir / f"raw_rx_{ts}.bin"
|
||||
log_path = session_dir / f"session_{ts}.log"
|
||||
raw_rx_path = session_dir / f"raw_rx_{ts}.bin" # device → us (S3 side)
|
||||
raw_tx_path = session_dir / f"raw_tx_{ts}.bin" # us → device (BW side)
|
||||
|
||||
# Flush buffered raw bytes to file and switch to direct file writes.
|
||||
raw_fh = open(raw_path, "wb")
|
||||
for chunk in raw_buf:
|
||||
raw_fh.write(chunk)
|
||||
raw_buf.clear()
|
||||
# Flush buffered bytes to files and switch to direct file writes.
|
||||
raw_rx_fh = open(raw_rx_path, "wb")
|
||||
raw_tx_fh = open(raw_tx_path, "wb")
|
||||
for chunk in raw_rx_buf:
|
||||
raw_rx_fh.write(chunk)
|
||||
for chunk in raw_tx_buf:
|
||||
raw_tx_fh.write(chunk)
|
||||
raw_rx_buf.clear()
|
||||
raw_tx_buf.clear()
|
||||
|
||||
def tapped_read_file(n: int) -> bytes:
|
||||
data = _orig_read(n)
|
||||
if data:
|
||||
raw_fh.write(data)
|
||||
raw_fh.flush()
|
||||
raw_rx_fh.write(data)
|
||||
raw_rx_fh.flush()
|
||||
return data
|
||||
|
||||
transport.read = tapped_read_file # type: ignore[method-assign]
|
||||
def tapped_write_file(data: bytes) -> None:
|
||||
_orig_write(data)
|
||||
if data:
|
||||
raw_tx_fh.write(data)
|
||||
raw_tx_fh.flush()
|
||||
|
||||
transport.read = tapped_read_file # type: ignore[method-assign]
|
||||
transport.write = tapped_write_file # type: ignore[method-assign]
|
||||
|
||||
# Wire up file handler now that the session dir exists.
|
||||
fh = logging.FileHandler(log_path, encoding="utf-8")
|
||||
@@ -530,7 +551,8 @@ class AchSession:
|
||||
log.warning(" [WARN] Failed to restart monitoring: %s", exc)
|
||||
|
||||
finally:
|
||||
raw_fh.close()
|
||||
raw_rx_fh.close()
|
||||
raw_tx_fh.close()
|
||||
client.close() # closes transport / socket cleanly
|
||||
root_logger.removeHandler(fh)
|
||||
fh.close()
|
||||
@@ -561,7 +583,8 @@ def _device_info_to_dict(d: DeviceInfo) -> dict:
|
||||
"record_time": cc.record_time if cc else None,
|
||||
"trigger_level_geo": cc.trigger_level_geo if cc else None,
|
||||
"alarm_level_geo": cc.alarm_level_geo if cc else None,
|
||||
"max_range_geo": cc.max_range_geo if cc else None,
|
||||
"geo_adc_scale": cc.geo_adc_scale if cc else None, # hw scale factor (in/s)/V
|
||||
"geo_range": cc.geo_range if cc else None, # 0x01=Normal 10in/s, 0x00=Sensitive 1.25in/s (unconfirmed)
|
||||
"project": cc.project if cc else None,
|
||||
"client": cc.client if cc else None,
|
||||
"operator": cc.operator if cc else None,
|
||||
|
||||
+34
-29
@@ -58,16 +58,24 @@ class BridgeGUI(tk.Tk):
|
||||
tk.Entry(self, textvariable=self.logdir_var, width=24).grid(row=1, column=3, sticky="we", **pad)
|
||||
tk.Button(self, text="Browse", command=self._choose_dir).grid(row=1, column=4, sticky="w", **pad)
|
||||
|
||||
# Row 2: Raw taps
|
||||
self.raw_bw_var = tk.StringVar(value="")
|
||||
self.raw_s3_var = tk.StringVar(value="")
|
||||
tk.Checkbutton(self, text="Save BW->S3 raw", command=self._toggle_raw_bw, onvalue="1", offvalue="").grid(row=2, column=0, sticky="w", **pad)
|
||||
tk.Entry(self, textvariable=self.raw_bw_var, width=28).grid(row=2, column=1, columnspan=3, sticky="we", **pad)
|
||||
tk.Button(self, text="...", command=lambda: self._choose_file(self.raw_bw_var, "bw")).grid(row=2, column=4, **pad)
|
||||
# Row 2: Raw taps — ON by default; "auto" = timestamped name; blank checkbox = disabled
|
||||
self.raw_bw_enabled = tk.IntVar(value=1)
|
||||
self.raw_s3_enabled = tk.IntVar(value=1)
|
||||
# Path fields: empty means "auto" (bridge picks a timestamped name)
|
||||
self.raw_bw_path_var = tk.StringVar(value="")
|
||||
self.raw_s3_path_var = tk.StringVar(value="")
|
||||
|
||||
tk.Checkbutton(self, text="Save S3->BW raw", command=self._toggle_raw_s3, onvalue="1", offvalue="").grid(row=3, column=0, sticky="w", **pad)
|
||||
tk.Entry(self, textvariable=self.raw_s3_var, width=28).grid(row=3, column=1, columnspan=3, sticky="we", **pad)
|
||||
tk.Button(self, text="...", command=lambda: self._choose_file(self.raw_s3_var, "s3")).grid(row=3, column=4, **pad)
|
||||
tk.Checkbutton(self, text="BW→S3 raw (auto)", variable=self.raw_bw_enabled,
|
||||
command=self._toggle_raw_bw).grid(row=2, column=0, sticky="w", **pad)
|
||||
tk.Entry(self, textvariable=self.raw_bw_path_var, width=28,
|
||||
fg="grey").grid(row=2, column=1, columnspan=3, sticky="we", **pad)
|
||||
tk.Button(self, text="...", command=lambda: self._choose_file(self.raw_bw_path_var, "bw")).grid(row=2, column=4, **pad)
|
||||
|
||||
tk.Checkbutton(self, text="S3→BW raw (auto)", variable=self.raw_s3_enabled,
|
||||
command=self._toggle_raw_s3).grid(row=3, column=0, sticky="w", **pad)
|
||||
tk.Entry(self, textvariable=self.raw_s3_path_var, width=28,
|
||||
fg="grey").grid(row=3, column=1, columnspan=3, sticky="we", **pad)
|
||||
tk.Button(self, text="...", command=lambda: self._choose_file(self.raw_s3_path_var, "s3")).grid(row=3, column=4, **pad)
|
||||
|
||||
# Row 4: Status + buttons
|
||||
self.status_var = tk.StringVar(value="Idle")
|
||||
@@ -102,13 +110,11 @@ class BridgeGUI(tk.Tk):
|
||||
var.set(filename)
|
||||
|
||||
def _toggle_raw_bw(self) -> None:
|
||||
if not self.raw_bw_var.get():
|
||||
# default name
|
||||
self.raw_bw_var.set(os.path.join(self.logdir_var.get(), "raw_bw.bin"))
|
||||
# Checkbox toggled — no path action needed; enabled state drives the flag.
|
||||
pass
|
||||
|
||||
def _toggle_raw_s3(self) -> None:
|
||||
if not self.raw_s3_var.get():
|
||||
self.raw_s3_var.set(os.path.join(self.logdir_var.get(), "raw_s3.bin"))
|
||||
pass
|
||||
|
||||
def start_bridge(self) -> None:
|
||||
if self.process and self.process.poll() is None:
|
||||
@@ -126,23 +132,22 @@ class BridgeGUI(tk.Tk):
|
||||
|
||||
args = [sys.executable, BRIDGE_PATH, "--bw", bw, "--s3", s3, "--baud", baud, "--logdir", logdir]
|
||||
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
# Raw tap flags.
|
||||
# Checkbox on + empty path → pass "auto" (bridge generates timestamped name).
|
||||
# Checkbox on + explicit path → pass that path.
|
||||
# Checkbox off → pass "" to disable (overrides bridge's auto default).
|
||||
raw_bw_explicit = self.raw_bw_path_var.get().strip()
|
||||
raw_s3_explicit = self.raw_s3_path_var.get().strip()
|
||||
|
||||
raw_bw = self.raw_bw_var.get().strip()
|
||||
raw_s3 = self.raw_s3_var.get().strip()
|
||||
if self.raw_bw_enabled.get():
|
||||
args += ["--raw-bw", raw_bw_explicit if raw_bw_explicit else "auto"]
|
||||
else:
|
||||
args += ["--raw-bw", ""] # explicit disable
|
||||
|
||||
# If the user left the default generic name, replace with a timestamped one
|
||||
# so each session gets its own file.
|
||||
if raw_bw:
|
||||
if os.path.basename(raw_bw) in ("raw_bw.bin", "raw_bw"):
|
||||
raw_bw = os.path.join(os.path.dirname(raw_bw) or logdir, f"raw_bw_{ts}.bin")
|
||||
self.raw_bw_var.set(raw_bw)
|
||||
args += ["--raw-bw", raw_bw]
|
||||
if raw_s3:
|
||||
if os.path.basename(raw_s3) in ("raw_s3.bin", "raw_s3"):
|
||||
raw_s3 = os.path.join(os.path.dirname(raw_s3) or logdir, f"raw_s3_{ts}.bin")
|
||||
self.raw_s3_var.set(raw_s3)
|
||||
args += ["--raw-s3", raw_s3]
|
||||
if self.raw_s3_enabled.get():
|
||||
args += ["--raw-s3", raw_s3_explicit if raw_s3_explicit else "auto"]
|
||||
else:
|
||||
args += ["--raw-s3", ""] # explicit disable
|
||||
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
|
||||
@@ -93,8 +93,11 @@ class SessionLogger:
|
||||
self._bin_fh = open(bin_path, "ab", buffering=0)
|
||||
self._lock = threading.Lock()
|
||||
# Optional pure-byte taps (no headers). BW=Blastware tx, S3=device tx.
|
||||
# These can be opened/closed on demand via start_raw_capture/stop_raw_capture.
|
||||
self._raw_bw = open(raw_bw_path, "ab", buffering=0) if raw_bw_path else None
|
||||
self._raw_s3 = open(raw_s3_path, "ab", buffering=0) if raw_s3_path else None
|
||||
self._cap_bw_path: Optional[str] = raw_bw_path
|
||||
self._cap_s3_path: Optional[str] = raw_s3_path
|
||||
|
||||
def log_line(self, line: str) -> None:
|
||||
with self._lock:
|
||||
@@ -124,6 +127,43 @@ class SessionLogger:
|
||||
self.log_line(f"[{ts}] [INFO] {msg}")
|
||||
self.bin_write_record(REC_INFO, msg.encode("utf-8", errors="replace"))
|
||||
|
||||
def start_raw_capture(self, label: str, logdir: str) -> tuple:
|
||||
"""Open new raw tap files for a named capture. Returns (bw_path, s3_path)."""
|
||||
ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in label)[:40] if label else ""
|
||||
suffix = f"_{safe}" if safe else ""
|
||||
bw_path = os.path.join(logdir, f"raw_bw_{ts}{suffix}.bin")
|
||||
s3_path = os.path.join(logdir, f"raw_s3_{ts}{suffix}.bin")
|
||||
with self._lock:
|
||||
# Close any previously open taps first
|
||||
if self._raw_bw:
|
||||
self._raw_bw.close()
|
||||
if self._raw_s3:
|
||||
self._raw_s3.close()
|
||||
self._raw_bw = open(bw_path, "ab", buffering=0)
|
||||
self._raw_s3 = open(s3_path, "ab", buffering=0)
|
||||
self._cap_bw_path = bw_path
|
||||
self._cap_s3_path = s3_path
|
||||
self.log_info(f"raw capture started: label={label!r} bw={bw_path} s3={s3_path}")
|
||||
return bw_path, s3_path
|
||||
|
||||
def stop_raw_capture(self) -> tuple:
|
||||
"""Close raw tap files. Returns (bw_path, s3_path) for the capture just closed."""
|
||||
with self._lock:
|
||||
bw = self._cap_bw_path
|
||||
s3 = self._cap_s3_path
|
||||
if self._raw_bw:
|
||||
self._raw_bw.close()
|
||||
self._raw_bw = None
|
||||
if self._raw_s3:
|
||||
self._raw_s3.close()
|
||||
self._raw_s3 = None
|
||||
self._cap_bw_path = None
|
||||
self._cap_s3_path = None
|
||||
if bw:
|
||||
self.log_info(f"raw capture stopped: bw={bw} s3={s3}")
|
||||
return bw, s3
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
@@ -291,8 +331,18 @@ def forward_loop(
|
||||
time.sleep(0.002)
|
||||
|
||||
|
||||
def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
|
||||
print("[MARK] Type 'm' + Enter to annotate the capture. Ctrl+C to stop.")
|
||||
def annotation_loop(logger: SessionLogger, logdir: str, stop: threading.Event) -> None:
|
||||
"""
|
||||
Reads stdin commands while the bridge runs.
|
||||
|
||||
Commands:
|
||||
m — prompt for a mark label (interactive)
|
||||
CAP_START:<label> — begin a raw tap capture with the given label
|
||||
CAP_STOP — stop the current raw tap capture
|
||||
Responses (printed to stdout, parsed by the GUI):
|
||||
[CAP_START] <bw_path>\\t<s3_path>
|
||||
[CAP_STOP] <bw_path>\\t<s3_path>
|
||||
"""
|
||||
while not stop.is_set():
|
||||
try:
|
||||
line = input()
|
||||
@@ -303,7 +353,21 @@ def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.lower() == "m":
|
||||
if line.startswith("CAP_START:"):
|
||||
label = line[10:].strip()
|
||||
bw_path, s3_path = logger.start_raw_capture(label, logdir)
|
||||
print(f"[CAP_START] {bw_path}\t{s3_path}")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif line == "CAP_STOP":
|
||||
bw_path, s3_path = logger.stop_raw_capture()
|
||||
if bw_path:
|
||||
print(f"[CAP_STOP] {bw_path}\t{s3_path}")
|
||||
else:
|
||||
print("[CAP_STOP] no active capture")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif line.lower() == "m":
|
||||
try:
|
||||
sys.stdout.write(" Label: ")
|
||||
sys.stdout.flush()
|
||||
@@ -315,8 +379,9 @@ def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
|
||||
print(f" [MARK written] {label}")
|
||||
else:
|
||||
print(" (empty label — mark cancelled)")
|
||||
|
||||
else:
|
||||
print(" (type 'm' + Enter to annotate)")
|
||||
print(f" (unknown command: {line!r})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -325,8 +390,14 @@ def main() -> int:
|
||||
ap.add_argument("--s3", default="COM5", help="S3-side COM port (default: COM5)")
|
||||
ap.add_argument("--baud", type=int, default=38400, help="Baud rate (default: 38400)")
|
||||
ap.add_argument("--logdir", default=".", help="Directory to write session logs into (default: .)")
|
||||
ap.add_argument("--raw-bw", default=None, help="Optional file to append raw bytes sent from BW->S3 (no headers)")
|
||||
ap.add_argument("--raw-s3", default=None, help="Optional file to append raw bytes sent from S3->BW (no headers)")
|
||||
ap.add_argument("--raw-bw", default="auto",
|
||||
help="File to append raw bytes sent from BW->S3 (no headers). "
|
||||
"Default 'auto' generates a timestamped name in --logdir. "
|
||||
"Pass an empty string to disable.")
|
||||
ap.add_argument("--raw-s3", default="auto",
|
||||
help="File to append raw bytes sent from S3->BW (no headers). "
|
||||
"Default 'auto' generates a timestamped name in --logdir. "
|
||||
"Pass an empty string to disable.")
|
||||
ap.add_argument("--quiet", action="store_true", help="No console heartbeat output")
|
||||
ap.add_argument("--status-every", type=float, default=0.0, help="Seconds between console heartbeat lines (default: 0 = off)")
|
||||
args = ap.parse_args()
|
||||
@@ -349,12 +420,16 @@ def main() -> int:
|
||||
# If raw tap flags were passed without a path (bare --raw-bw / --raw-s3),
|
||||
# or if the sentinel value "auto" is used, generate a timestamped name.
|
||||
# If a specific path was provided, use it as-is (caller's responsibility).
|
||||
raw_bw_path = args.raw_bw
|
||||
raw_s3_path = args.raw_s3
|
||||
if raw_bw_path in (None, "", "auto"):
|
||||
raw_bw_path = os.path.join(args.logdir, f"raw_bw_{ts}.bin") if args.raw_bw is not None else None
|
||||
if raw_s3_path in (None, "", "auto"):
|
||||
raw_s3_path = os.path.join(args.logdir, f"raw_s3_{ts}.bin") if args.raw_s3 is not None else None
|
||||
# Resolve raw tap paths.
|
||||
# "auto" (default) → timestamped file in logdir (always captured).
|
||||
# Explicit path → use verbatim.
|
||||
# None or "" → disabled (pass --raw-bw "" to suppress capture).
|
||||
raw_bw_path: Optional[str] = args.raw_bw if args.raw_bw else None
|
||||
raw_s3_path: Optional[str] = args.raw_s3 if args.raw_s3 else None
|
||||
if raw_bw_path == "auto":
|
||||
raw_bw_path = os.path.join(args.logdir, f"raw_bw_{ts}.bin")
|
||||
if raw_s3_path == "auto":
|
||||
raw_s3_path = os.path.join(args.logdir, f"raw_s3_{ts}.bin")
|
||||
|
||||
logger = SessionLogger(log_path, bin_path, raw_bw_path=raw_bw_path, raw_s3_path=raw_s3_path)
|
||||
|
||||
@@ -391,7 +466,7 @@ def main() -> int:
|
||||
t_ann = threading.Thread(
|
||||
target=annotation_loop,
|
||||
name="Annotator",
|
||||
args=(logger, stop),
|
||||
args=(logger, args.logdir, stop),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
| 2026-03-02 | §7.4 Event Index Block | **NEW:** `Monitoring LCD Cycle` identified at offsets +84/+85 as uint16 BE. Default value = 65500 (0xFFDC) = effectively disabled / maximum. Confirmed from operator manual §3.13.1g. |
|
||||
| 2026-03-02 | §7.4 Event Index Block | **UPDATED:** Backlight confirmed as uint8 range 0–255 seconds per operator manual §3.13.1e ("adjustable timer, 0 to 255 seconds"). Power save unit confirmed as minutes per operator manual §3.13.1f. |
|
||||
| 2026-03-02 | Global | **NEW SOURCE:** Operator manual (716U0101 Rev 15) added as reference. Cross-referencing settings definitions, ranges, and units. Header updated. |
|
||||
| 2026-03-02 | §14 Open Questions | Float 6.2061 in/s mystery: manual confirms only two geo ranges (1.25 in/s and 10.0 in/s). 6.2061 is NOT a user-selectable range → originally speculated as internal ADC full-scale constant, but this is NOT confirmed. Using it as ADC full-scale produces ~9× PPV overread. Meaning unknown. Downgraded to LOW 2026-03-02, re-escalated to HIGH 2026-04-16. |
|
||||
| 2026-03-02 | §14 Open Questions | Float 6.2061 in/s mystery: manual confirms only two geo ranges (1.25 in/s and 10.0 in/s). 6.2061 is NOT a user-selectable range → originally speculated as internal ADC full-scale constant, but was NOT confirmed at this time. Using it directly as the range produces ~9× PPV overread. Meaning unknown. Downgraded to LOW 2026-03-02, re-escalated to HIGH 2026-04-16. **RESOLVED 2026-04-17 — see §7.6.2 and changelog entry.** |
|
||||
| 2026-03-02 | §14 Open Questions | `0x082A` hypothesis refined: 2090 decimal. At 1024 sps, 2 sec record = 2048 samples. Possible that 0x082A = total samples including 0.25s pre-trigger (256 samples) at some adjusted rate. Needs capture with different record time. |
|
||||
| 2026-03-02 | §14 Open Questions | **NEW items added:** Trigger sample width (default=2), Auto Window (1-9 sec), Aux Trigger (enabled/disabled) — all confirmed settings from operator manual not yet mapped in protocol. |
|
||||
| 2026-03-02 | §14 Open Questions | Monitoring LCD Cycle resolved — removed from open questions. |
|
||||
@@ -92,7 +92,7 @@
|
||||
| 2026-04-06 | §7.8.4 | **NEW — 5A end-of-stream signalling confirmed.** After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to the next chunk request, then goes silent for the full recv timeout. This byte is NOT a complete DLE-framed A5 response — the frame parser accumulates it as `bytes_fed=1` and never assembles a frame. This is the device's natural end-of-stream signal. Handling: on TimeoutError, if `bytes_fed > 0` AND prior chunks were received, treat as graceful end and proceed to the termination frame. A `bytes_fed=0` timeout with no prior chunks is a genuine transport failure and must still raise. |
|
||||
| 2026-04-06 | §7.8.4 | **NEW — 5A chunk timing and count (empirical, BE11529 at 1024 sps).** Each chunk response arrives within ~1 second over TCP/cellular. A 9,306-sample event (≈9.1 s at 1024 sps) produces **35 chunks** before end-of-stream. Chunks 1–16 have varying data lengths (1036–1123 bytes); chunks 17–35 are uniformly 1036 bytes each (post-event silence, all-zero ADC samples). Safe recv timeout for chunk loop: **10 s** (10× typical response time). Default transport timeout (120 s) results in a ~2-minute stall per event at end-of-stream. |
|
||||
| 2026-04-06 | §7.8.3 | **KNOWN ISSUE — `_decode_a5_waveform` hardcoded fi==9 skip.** The decoder contains `elif fi == 9: continue` which was written for the 9-frame original blast capture where frame 9 was a device terminator. For streams with >9 frames (current device produces 35+), frame index 9 is live waveform data — this skip discards ~1,070 bytes (~133 sample-sets) per event. The terminator is now detected via `page_key == 0x0000`, not by frame index. The fi==9 skip should be removed. |
|
||||
| 2026-04-06 | §7.8 | **⚠ PARTIALLY INVALIDATED — ADC count-to-physical-unit conversion.** Raw waveform samples are signed 16-bit integers (counts). Conversion formula `value = counts × (range / 32767)` is believed correct, but the `range` value is UNKNOWN. The compliance config field labeled `max_range_geo` reads 6.206053 (bytes `40 C6 97 FD`), which does NOT match either user-selectable range shown in Blastware UI (1.25 or 10.000 in/s). The meaning and units of the 6.206053 value are unresolved — it may not be the ADC full-scale at all. See open question in §14. |
|
||||
| 2026-04-06 | §7.8 | **⚠ PARTIALLY INVALIDATED — ADC count-to-physical-unit conversion.** Raw waveform samples are signed 16-bit integers (counts). Conversion formula `value = counts × (range / 32767)` is believed correct, but the `range` value was UNKNOWN at time of writing. **UPDATED 2026-04-17:** `max_range_geo` = 6.206053 is confirmed as the ADC-to-velocity scale factor (inverse sensitivity, (in/s)/V). The correct conversion is therefore: `PPV (in/s) = counts × (1.61133 / 32767) × 6.206053` = `counts × 4.982e-5` in/s per count. The earlier ~9× overread from using 6.206053 directly as the range was because the range IS 1.61133 × 6.206053 = 10.000 in/s, not 6.206053. See §7.6.2 for the confirmed field layout. |
|
||||
| 2026-04-08 | §5.1, §7.10, §12 | **NEW — Monitoring commands confirmed.** SUB 0x1C (monitor status), 0x96 (start monitoring), 0x97 (stop monitoring) all confirmed from 4-8-26/2ndtry capture. SESSION_RESET (`41 03`) required before POLL to wake a monitoring unit. |
|
||||
| 2026-04-09 | §7.10 | **CORRECTED — monitoring flag and battery/memory offsets.** `section[1] == 0x10` is the monitoring flag (100% accurate across 144 data frames in 2ndtry capture). Previous note claiming `section[6]` was wrong — section[6] has device-specific non-binary values (0xea/0x07). Battery/memory offsets corrected: `section[-10:-8]` (battery×100), `section[-8:-4]` (memory_total), `section[-4:]` (memory_free). NOTE: `frame.data` has checksum stripped by parser — earlier offsets of `[-11:-9]`/`[-9:-5]`/`[-5:-1]` were wrong because they assumed a trailing checksum byte that isn't there. |
|
||||
| 2026-04-08 | §7.10 | **NEW — SUBs 0x0E (channel sensor data) and 0x98 (trigger test) observed** in 4-8-26/sensor-check capture (Blastware "Unit Channel Test" comms check). SUB 0x0E: 2-step read with channel selector in `params[6:8]`, data length 0x0A per channel, RSP SUB = 0xF1. SUB 0x98: single probe frame with `params[0] = 0xFF`, RSP SUB = 0x67; sent twice per test cycle. Not yet implemented in SFM. |
|
||||
@@ -103,6 +103,13 @@
|
||||
| 2026-04-11 | §5.1 | **CONFIRMED — SUB 0x06 (CHANNEL CONFIG READ) now confirmed as event storage range.** Two-step read, data offset = 0x24 (36 bytes). Token=0xFE at params[7]. Last 8 bytes of response: first stored event key (bytes −8:−4) and last stored event key (bytes −4:). Both equal `01110000` when device memory is empty. Used by Blastware to verify erase completion. |
|
||||
| 2026-04-11 | §7.11 (NEW) | **NEW — §7.11 Erase-All Protocol added.** Full wire sequence, SUB 0x06 storage range payload layout, post-erase key counter reset (resets to `0x01110000`). Confirmed from 4-11-26 MITM capture of live Blastware ACH session. |
|
||||
| 2026-04-11 | §14.6 | **RESOLVED — ACH Session Lifecycle is no longer "Future".** `bridges/ach_server.py` fully implements inbound ACH: POLL handshake, device info, event download. State tracked via `ach_state.json` (key-based, with `max_downloaded_key` for post-erase detection). `--clear-after-download` flag added for the standard delete-after-upload workflow. |
|
||||
| 2026-04-17 | §7.6.2, §14 | **RESOLVED — Float 6.206053 at channel_label+28 is the ADC-to-velocity scale factor.** Confirmed from Series III Interface Handbook §4.5 formula: `Range (×1) = 1.61133 V / Sensitivity (V/unit)`. For the standard Instantel geophone at Normal range (10.000 in/s): Sensitivity = 1.61133 / 10 = 0.161133 V/(in/s). The stored value is the **inverse sensitivity** = 1/0.161133 = **6.206053 (in/s)/V**. Cross-check: 1.61133 V × 6.206053 = 10.000 in/s ✅. The firmware uses it as: `PPV (in/s) = ADC_voltage (V) × 6.206053`. Value is identical on all Instantel standard geophones — it is a hardware/firmware constant, NOT a user-configurable setting. Do NOT write this field. Open question §14 item "Max Geo Range float 6.2061" is now **RESOLVED**. |
|
||||
| 2026-04-20 | §7.6.4 (NEW), §7.9, Appendix B | **CONFIRMED — Recording Mode byte location.** Three targeted captures (4-20-26) confirmed `recording_mode` at anchor−8 in both the E5 read payload and the BW write payload (6-byte anchor `\xbe\x80\x00\x00\x00\x00`). BW write payload and E5 read payload are **byte-identical** around the anchor region — Blastware round-trips the wire-encoded E5 bytes verbatim with only the target field modified. Anchor position varies by ±1 depending on whether recording_mode = 0x03 (Histogram), because E5 wire-encodes `0x03` as the inner DLE+ETX pair `\x10\x03` (2 bytes), which S3FrameParser preserves as two literal bytes in `compliance_raw`. Enum: `0x00`=Single Shot, `0x01`=Continuous, `0x03`=Histogram, `0x04`=Histogram+Continuous. `0x02` value not yet observed. The byte at anchor−9 is `0x00` for Single Shot / Continuous, and `0x10` for Histogram (DLE prefix from E5 encoding) and Histogram+Continuous (actual config byte). See §7.6.4 for full details. |
|
||||
| 2026-04-21 | Appendix D (NEW) | **NEW — Blastware .N00 and .MLG file formats fully decoded.** `minimateplus/blastware_file.py` implements `write_n00()` and `write_mlg()`. N00 file format confirmed: 22B header + 21B STRT record + variable body + 26B footer. Body reconstructed from A5 bulk waveform stream frames with per-frame skip amounts (probe=7+strt_pos+21, A5[1]=13, A5[2+]=12, terminator=11) and DLE strip rule (strip `0x10` before `{0x02,0x03,0x04}`, keep following byte). Footer extracted verbatim from terminator frame's last 26 bytes. Split-pair edge case: when `frame.data[-1]==0x10` and `chk_byte∈{0x02,0x03,0x04}`, reunite both bytes before stripping and always remove trailing chk_byte (`stripped[:-1]`) — chk_byte is checksum, not payload. STRT record must be copied verbatim from A5[0]; bytes [10:20] are device-specific and cannot be reconstructed from Event fields. `write_n00` verified byte-perfect against `M529LIY6.N00` from 4-3-26-multi_event capture. MLG format: 308B header + N×292B records; CRC algorithm unknown (write as 0x0000). |
|
||||
| 2026-04-21 | Appendix D §D.5 (NEW) | **NEW — Blastware filename encoding fully decoded.** Serial prefix: `chr(ord('B') + floor(serial/1000))` + last 3 digits zero-padded. Stem: 4-char base-36 of `floor(total_seconds/1296)`. Extension: `AB0` for manual/direct downloads (3 chars), `AB0W` or `AB0H` for ACH/call-home downloads (4 chars), where `AB` = 2-char base-36 of `total_seconds % 1296` and W/H = waveform/histogram. Epoch = 1985-01-01 00:00:00 device local time. Confirmed against 3,248 files from 10-year production archive with zero errors. 3-day cycle property: same daily recording time cycles through 3 extensions (864s/day shift, period=3 days). `blastware_filename(event, serial, ach=False)` implements full formula. |
|
||||
| 2026-04-21 | §7.6.2, §5.3 | **CORRECTED — compliance_raw contains wire-encoded bytes, NOT logical bytes.** S3FrameParser appends DLE+ETX inner-frame pairs as two literal bytes to the frame body. Any `0x03` values in the compliance config appear in `compliance_raw` as `\x10\x03` (two bytes), not as a single `0x03`. The previous claim "S3FrameParser handles this transparently so compliance_raw contains logical (destuffed) bytes" was wrong. Consequence: `compliance_raw` is the wire-encoded E5 payload; anchor-relative reads work correctly because the anchor position automatically accounts for any DLE-encoded bytes before it. For write-back, round-tripping `compliance_raw` verbatim sends the correct wire bytes to the device. **DLE ETX escaping in write frames:** Blastware escapes `0x03` bytes in write frame data as `\x10\x03` on wire; our `build_bw_write_frame` does not (writes data raw). Device is confirmed to accept raw writes for all tested modes — likely uses the offset/length field for write frame framing, not ETX scanning. |
|
||||
| 2026-04-20 | §7.6.2, §7.9, Appendix B | **CONFIRMED — Geophone maximum range / sensitivity selector byte location.** Two targeted captures (4-20-26, geo sensitivity folder): one at Normal 10.000 in/s, one at Sensitive 1.250 in/s. E5 read payload diff: exactly 3 bytes differ at channel_label+33 for Tran/Vert/Long. Values: `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. Same offset applies to the SUB 71 write payload (which is the same 2126-byte E5-format buffer round-tripped verbatim). **`channel_label+20` reads `0x01` in ALL captures regardless of range setting — it is NOT this field.** Previous hypothesis (uint8 at Tran+20, 0x01=Normal) was WRONG. Stored as `geo_range` in `ComplianceConfig`. Encoded to all three geo channel blocks (Tran/Vert/Long) at label+33. |
|
||||
| 2026-04-20 | §5.1, §5.3, §7.12 (NEW) | **NEW — Auto Call Home config protocol confirmed from 4-20-26 call home settings captures.** SUB 0x2C (Call Home Config READ, response 0xD3, data offset 0x7C=124) and SUB 0x7E/0x7F (WRITE + CONFIRM, response 0x81/0x80) confirmed. Write payload = read payload (125 bytes) + `\x00\x00` (127 bytes total). **DLE-escaped ETX at raw[117:119]:** the device returns logical value 0x03 (num_retries=3) as `\x10\x03` on the wire — S3FrameParser preserves both bytes as two literals, causing a +1 byte shift for all subsequent fields. Write frame sends these bytes verbatim (device interprets `\x10\x03` as literal value 3). Field map confirmed from 10-frame BW TX diff. See §7.12 for full layout. |
|
||||
|
||||
---
|
||||
|
||||
@@ -257,13 +264,14 @@ Step 4 — Device sends actual data payload:
|
||||
| `24` | **WAVEFORM PAGE A?** | Paged waveform read, possibly channel group A. | 🔶 INFERRED |
|
||||
| `25` | **WAVEFORM PAGE B?** | Paged waveform read, possibly channel group B. | 🔶 INFERRED |
|
||||
| `09` | **UNKNOWN READ A** | Read command, response (`F6`) returns 0xCA (202) bytes. Purpose unknown. | 🔶 INFERRED |
|
||||
| `1A` | **COMPLIANCE CONFIG READ** | Multi-step sequence (A+B+C+D frames). Response (E5) carries sample_rate (uint16 BE at anchor−2), record_time (float32 BE at anchor+10), trigger/alarm/max_range floats, and project strings. Anchor: `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`, search cfg[0:150]. Total ~2126 cfg bytes. | ✅ CONFIRMED 2026-04-02 |
|
||||
| `1A` | **COMPLIANCE CONFIG READ** | Multi-step sequence (A+B+C+D frames). Response (E5) carries recording_mode (uint8 at anchor−4 in E5 sf1), sample_rate (uint16 BE at anchor−2), record_time (float32 BE at anchor+10), trigger/alarm/max_range floats, and project strings. Anchor: `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`, search cfg[0:150]. Total ~2126 cfg bytes. See §7.6.4 for recording_mode enum. | ✅ CONFIRMED 2026-04-02; recording_mode added 2026-04-20 |
|
||||
| `2E` | **UNKNOWN READ B** | Read command, response (`D1`) returns 0x1A (26) bytes. Purpose unknown. | 🔶 INFERRED |
|
||||
| `0E` | **CHANNEL SENSOR DATA** | Real-time sensor reading for one channel. Two-step read, data length 0x0A (10 bytes). Channel selector in params[6:8] (0x0000–0x0007 for 8 channels). Response (F1) carries amplitude, frequency, overswing data for that channel. Used by Blastware "Unit Channel Test" comms check. | ✅ CONFIRMED 2026-04-08 |
|
||||
| `98` | **TRIGGER TEST** | Trigger-test command. Single probe frame; `params[0] = 0xFF`. Response (0x67) is all-zero data. Sent twice per Blastware comms-check cycle. Not a full POLL, no monitor state change. | ✅ CONFIRMED 2026-04-08 |
|
||||
| `1C` | **MONITOR STATUS READ** | Two-step read, data offset 0x2C (44 bytes). `section[1] == 0x10` → monitoring; `0x00` → idle (CONFIRMED 2026-04-09, 100% accuracy on 144 frames). Payload length: 46–47 bytes IDLE, 48–49 bytes MONITORING. `frame.data` has checksum stripped — no trailing byte to skip. Battery/memory at end: `section[-10:-8]` = battery×100 (uint16 BE), `section[-8:-4]` = memory_total (uint32 BE), `section[-4:]` = memory_free (uint32 BE). | ✅ CONFIRMED 2026-04-09 |
|
||||
| `96` | **START MONITORING** | Single write frame, no data payload. Transitions unit from idle to monitoring mode (after optional on-device sensor check ~40 s). | ✅ CONFIRMED 2026-04-08 |
|
||||
| `97` | **STOP MONITORING** | Single write frame, no data payload. Stops monitoring, unit returns to idle. | ✅ CONFIRMED 2026-04-08 |
|
||||
| `2C` | **CALL HOME CONFIG READ** | Two-step read, data offset 0x7C (124 bytes + 1-byte DLE artefact = 125 raw bytes). Returns Auto Call Home configuration: enable flag, dial string, scheduled call times, retry settings, modem timing. Response SUB = 0xD3. **DLE note:** logical value 0x03 (num_retries) is returned as `\x10\x03` on the wire, which S3FrameParser preserves as two literal bytes — this shifts all subsequent field positions by +1. See §7.12 for full field map. | ✅ CONFIRMED 2026-04-20 |
|
||||
| `A3` | **ERASE ALL BEGIN** | Single frame, token=0xFE at params[7]. Initiates device memory erase. Must be followed by 0x1C probe+data + 0x06 probe+data + 0xA2 to complete. Standard `build_bw_frame` (not write-format). Response ack SUB = 0x5C. | ✅ CONFIRMED 2026-04-11 |
|
||||
| `A2` | **ERASE ALL CONFIRM** | Single frame, token=0xFE at params[7]. Commits the erase initiated by 0xA3. After this ack (SUB 0x5D), device memory is cleared and the event counter resets to `0x01110000`. | ✅ CONFIRMED 2026-04-11 |
|
||||
|
||||
@@ -293,6 +301,7 @@ All requests use CMD byte `0x02`. All responses use CMD byte `0x10 0x02` (which,
|
||||
| `98` | `67` | ✅ CONFIRMED 2026-04-08 |
|
||||
| `96` | `69` | ✅ CONFIRMED 2026-04-08 |
|
||||
| `97` | `68` | ✅ CONFIRMED 2026-04-08 |
|
||||
| `2C` | `D3` | ✅ CONFIRMED 2026-04-20 |
|
||||
| `A3` | `5C` | ✅ CONFIRMED 2026-04-11 |
|
||||
| `A2` | `5D` | ✅ CONFIRMED 2026-04-11 |
|
||||
|
||||
@@ -314,6 +323,8 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0
|
||||
| `72` | **WRITE CONFIRM A** | Short frame, no data. Likely commit/confirm step after `71`. | `8D` | ✅ CONFIRMED |
|
||||
| `73` | **WRITE CONFIRM B** | Short frame, no data. | `8C` | ✅ CONFIRMED |
|
||||
| `74` | **WRITE CONFIRM C** | Short frame, no data. | `8B` | ✅ CONFIRMED |
|
||||
| `7E` | **CALL HOME CONFIG WRITE** | Writes Auto Call Home configuration (127 bytes: 125-byte read payload + `\x00\x00`). Offset = data[1]+2 = 0x7E. Write format (DLE-aware checksum, only BW_CMD `0x10` doubled on wire). Response SUB = 0x81. Must be followed by SUB 0x7F confirm. | `81` | ✅ CONFIRMED 2026-04-20 |
|
||||
| `7F` | **CALL HOME WRITE CONFIRM** | Short frame, no data. Commits call home config write from SUB 0x7E. Response SUB = 0x80. | `80` | ✅ CONFIRMED 2026-04-20 |
|
||||
| `82` | **TRIGGER CONFIG WRITE** | Writes trigger config block (0x1C bytes, mirrors SUB `1C` read). | `7D` | ✅ CONFIRMED |
|
||||
| `83` | **TRIGGER WRITE CONFIRM** | Short frame, no data. Likely commit step after `82`. | `7C` | ✅ CONFIRMED |
|
||||
|
||||
@@ -327,6 +338,8 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0
|
||||
| `72` | `8D` |
|
||||
| `73` | `8C` |
|
||||
| `74` | `8B` |
|
||||
| `7E` | `81` |
|
||||
| `7F` | `80` |
|
||||
| `82` | `7D` |
|
||||
| `83` | `7C` |
|
||||
|
||||
@@ -528,7 +541,7 @@ The SUB `1A` read response (`E5`) and SUB `71` write block contain per-channel t
|
||||
| Field | Example bytes | Decoded | Certainty |
|
||||
|---|---|---|---|
|
||||
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
|
||||
| Max range float | `40 C6 97 FD` | 6.206 — **value confirmed, meaning and units UNKNOWN** (does NOT match UI range options 1.25/10.000 in/s; not confirmed as ADC full-scale) | ❓ UNKNOWN |
|
||||
| ADC scale factor | `40 C6 97 FD` | **6.206053 (in/s)/V — CONFIRMED 2026-04-17.** This is the inverse sensitivity of the standard Instantel geophone = 1/0.161133. Interface Handbook §4.5: `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Used by firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant — do NOT write. | ✅ CONFIRMED |
|
||||
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
|
||||
| **Trigger level** | `3F 19 99 9A` | **0.600 in/s** — IEEE 754 BE float | ✅ CONFIRMED |
|
||||
| Unit string | `69 6E 2E 00` | `"in.\0"` | ✅ CONFIRMED |
|
||||
@@ -620,6 +633,53 @@ The sample rate bytes sit immediately before a `0x10` (DLE) prefix byte in the r
|
||||
|
||||
---
|
||||
|
||||
### 7.6.4 Recording Mode
|
||||
|
||||
> ✅ **CONFIRMED — 2026-04-20** (BE11529 / firmware S338.17). Three targeted captures in a single Blastware session (4-20-26 directory), changing Recording Mode only between each write.
|
||||
|
||||
Recording mode is stored as a **uint8** with different anchor-relative positions depending on whether you are reading from a device response or constructing a write payload.
|
||||
|
||||
**In the SUB 71 write payload (3-chunk compliance write, `cfg[5]`):**
|
||||
|
||||
| Enum | Mode |
|
||||
|---|---|
|
||||
| `0x00` | Single Shot |
|
||||
| `0x01` | Continuous |
|
||||
| `0x02` | Unknown (not yet observed) |
|
||||
| `0x03` | Histogram |
|
||||
| `0x04` | Histogram + Continuous (combined mode) |
|
||||
|
||||
Anchor-relative position: **anchor − 3** (3 bytes before the 10-byte anchor in the write payload). The write payload layout in the region around the anchor:
|
||||
|
||||
```
|
||||
cfg[anchor - 3] = recording_mode (uint8)
|
||||
cfg[anchor - 2] = sample_rate_hi (uint8, MSB of uint16 BE)
|
||||
cfg[anchor - 1] = sample_rate_lo (uint8, LSB of uint16 BE)
|
||||
cfg[anchor:anchor+10] = \x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00 ← anchor
|
||||
cfg[anchor + 10:anchor + 14] = record_time (float32 BE)
|
||||
```
|
||||
|
||||
**In the E5 read response (sub-frame 1, page=`0x0010`, `data[17]`):**
|
||||
|
||||
The anchor appears at `data[21]` in this sub-frame. Recording mode is at `data[17]` = **anchor − 4** (one position earlier than in the write payload). This is because an extra `0x10` byte is present at `data[18]` in the read format (between recording_mode and sample_rate), which is NOT present in the write payload. The read-format layout:
|
||||
|
||||
```
|
||||
data[17] = recording_mode (uint8)
|
||||
data[18] = 0x10 ← extra byte present in E5 read only; absent in SUB 71 write
|
||||
data[19] = sample_rate_hi (uint8, MSB of uint16 BE)
|
||||
data[20] = sample_rate_lo (uint8, LSB of uint16 BE)
|
||||
data[21:31] = anchor (\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00)
|
||||
data[31:35] = record_time (float32 BE)
|
||||
```
|
||||
|
||||
**Chunk checksum at `cfg[1024]`:** The first of the three SUB 71 write chunks (1027 bytes) contains a running checksum byte at `cfg[1024]` whose delta exactly equals the delta of `cfg[5]` (recording_mode). This byte reflects the cumulative change from `recording_mode` through to its position and should not be mistaken for a second copy of the recording_mode field.
|
||||
|
||||
**Decode path (`_decode_compliance_config_into`):** use `data[anchor_pos - 4]` where `anchor_pos` is the index of the first byte of the anchor in the assembled E5 cfg bytes.
|
||||
|
||||
**Encode path (`_encode_compliance_config`):** use `cfg[anchor_pos - 3]` = recording_mode value (write-payload offset; no extra `0x10` byte).
|
||||
|
||||
---
|
||||
|
||||
### 7.7 Blastware `.set` File Format
|
||||
|
||||
> 🔶 **INFERRED — 2026-03-01** from `Standard_Recording_Setup.set` cross-referenced against known wire payloads.
|
||||
@@ -655,7 +715,7 @@ offset size type value (Tran example) meaning
|
||||
+10 2 uint16 0x0015 = 21 unknown
|
||||
+12 4 bytes 03 02 04 01 flags (recording mode etc.)
|
||||
+16 4 uint32 0x00000003 record time in seconds ✅ CONFIRMED
|
||||
+1A 4 float32 6.2061 ❓ UNKNOWN field — value 6.2061 confirmed; meaning/units unresolved (NOT confirmed as max range or ADC full-scale)
|
||||
+1A 4 float32 6.206053 ✅ CONFIRMED 2026-04-17 — ADC-to-velocity scale factor (= 1/sensitivity = (in/s)/V). Interface Handbook §4.5: Range = 1.61133 V × 6.206053 = 10.000 in/s (Normal range). Firmware uses: PPV (in/s) = ADC_voltage × 6.206053. Hardware constant — identical on all tested units. Do NOT write.
|
||||
+1E 2 00 00 padding
|
||||
+20 4 float32 0.6000 trigger level ✅ CONFIRMED
|
||||
+24 4 char[4] "in.\0" / "psi\0" unit string (geo vs mic)
|
||||
@@ -1171,26 +1231,52 @@ Two critical differences from `build_bw_frame`:
|
||||
| Frame | offset_word | counter | params | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| Probe | `0x1004` | `0x0000` | 10 bytes (`bulk_waveform_params(0)`) | Initiate transfer |
|
||||
| Chunk 1 | `0x1004` | `0x0400` | 11 bytes | First data chunk |
|
||||
| Chunk 2 | `0x1004` | `0x0800` | 11 bytes | Second chunk |
|
||||
| Chunk N | `0x1004` | `N * 0x0400` | 11 bytes | Nth chunk |
|
||||
| Chunk 1 | `0x1004` | `max(key4[2:4], 0x0400)` | 11 bytes | First data chunk |
|
||||
| Chunk 2 | `0x1004` | `max(key4[2:4], 0x0400) + 0x0400` | 11 bytes | Second chunk |
|
||||
| Chunk N | `0x1004` | `max(key4[2:4], 0x0400) + (N-1) * 0x0400` | 11 bytes | Nth chunk |
|
||||
| … | … | … | … | … |
|
||||
| Termination | `0x005A` | `last + 0x0400` | 10 bytes | End transfer |
|
||||
| Termination | `0x005A` | `max(key4[2:4], 0x0400) + N * 0x0400` | 10 bytes | End transfer |
|
||||
|
||||
> ⚠️ **2026-04-06 CORRECTED — chunk counter is monotonic for ALL chunks.**
|
||||
> The 4-2-26 BW TX capture showed counter=0x1004 for chunk 1, which was hardcoded as a
|
||||
> special case. This was a Blastware artifact. Empirically confirmed: counter=0x0400 for
|
||||
> chunk 1 works correctly; counter=0x1004 causes the device to time out. The device does
|
||||
> NOT strictly validate the counter value — it streams data for any valid 5A request for
|
||||
> the given key. Use `chunk_num * 0x0400` (monotonic) for all chunks.
|
||||
> BW's true internal formula is `key4[2:4] + n * 0x0400`. For event 1 (key `01110000`)
|
||||
> this equals `n * 0x0400` since `key4[2:4] = 0x0000`. The monotonic formula is correct
|
||||
> for all keys encountered on this device.
|
||||
> ⚠️ **2026-04-06 CORRECTED — chunk counter is `key4[2:4] + (N-1) * 0x0400`.**
|
||||
> The 4-2-26 BW TX capture showed counter=0x1004 for chunk 1 of key `01110000`, leading to
|
||||
> an interim "monotonic n * 0x0400" formula. This was accidentally correct because
|
||||
> `key4[2:4] == 0x0000` for that event.
|
||||
>
|
||||
> **2026-04-24 CORRECTION:** The counter is an absolute circular-buffer address.
|
||||
> BW's true formula is `key4[2:4] + (chunk_num - 1) * 0x0400` where `key4[2:4]` is the
|
||||
> event's storage base offset (`(key4[2]<<8) | key4[3]`). For keys where
|
||||
> `key4[2:4] != 0x0000` (e.g. key `01111884`), using `n * 0x0400` sends requests into the
|
||||
> wrong buffer region — the device returns data from a completely different event.
|
||||
>
|
||||
> **2026-04-26 FINAL CORRECTION:** The formula `key4[2:4] + (N-1) * 0x0400` is wrong when
|
||||
> `key4[2:4] == 0x0000` (e.g. event key `01110000`, the very first event after a device erase).
|
||||
> Counter=0x0000 for chunk 1 is the same address as the probe frame — the device re-returns
|
||||
> the STRT record data instead of waveform payload (frame 1 has len=1097, same as probe, and
|
||||
> contains `b"STRT\xff\xfe"`, contributing zero waveform bytes).
|
||||
> Final formula: `max(key4[2:4], 0x0400) + (chunk_num - 1) * 0x0400`.
|
||||
> For key `01110000`: chunk 1 = 0x0400 (confirmed working, empirical test 2026-04-06).
|
||||
> For key `0111245a`: chunk 1 = 0x245a (unchanged, confirmed from 4-3-26 capture).
|
||||
|
||||
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
|
||||
@@ -1235,13 +1321,19 @@ TimeoutError caught:
|
||||
|
||||
Chunks with uniform 1,036-byte payload (chunks 17–35 in the observed event) contain all-zero ADC samples — the device continues recording silence until the configured record time expires before terminating the stream.
|
||||
|
||||
**ADC count-to-physical conversion — ⚠ SCALING UNKNOWN:**
|
||||
**ADC count-to-physical conversion — ✅ CONFIRMED 2026-04-17:**
|
||||
|
||||
Raw samples are signed 16-bit integers (−32,768 to +32,767). Source: Interface Handbook §4.5.
|
||||
|
||||
**CONFIRMED 2026-04-17** — The `max_range_geo` field (float32 = 6.206053, bytes `40 C6 97 FD`) is the **ADC-to-velocity scale factor** (inverse sensitivity, (in/s)/V) for the standard Instantel geophone, confirmed from Interface Handbook §4.5. The correct conversion formula is:
|
||||
|
||||
Raw samples are signed 16-bit integers (−32,768 to +32,767). The conversion formula is believed to be:
|
||||
```
|
||||
value_in_s (in/s) = counts × (geo_range / 32767)
|
||||
PPV (in/s) = ADC_voltage (V) × 6.206053
|
||||
= counts × (1.61133 / 32767) × 6.206053
|
||||
= counts × 4.982e-5 (in/s per count at full scale)
|
||||
```
|
||||
However, the correct value of `geo_range` is **unknown**. The compliance config field `max_range_geo` reads 6.206053 (`40 C6 97 FD`) which does NOT match either user-selectable range (1.25 or 10.000 in/s) and produces ~9× too large PPV values compared to the on-device 0C record. Do not use 6.206053 or 10.000 as the scale factor until this is resolved. See §14 open question. Mic channel uses psi units with its own range (also unresolved).
|
||||
|
||||
where `geo_range = 1.61133 V × 6.206053 = 10.000 in/s` is the Normal (Gain=1) full-scale range. The earlier ~9× overread was caused by mistakenly using 6.206053 as the range directly — it is actually the scale factor, and the range itself is `ADC_fullscale × scale_factor = 1.61133 × 6.206053 = 10.000 in/s`. Mic channel uses psi units with its own range (still unresolved).
|
||||
|
||||
**Known decoder issue — fi==9 hardcoded skip:**
|
||||
|
||||
@@ -1257,8 +1349,8 @@ Fields visible in the Blastware "Compliance Setup" dialog. ✅ = byte offset co
|
||||
|
||||
| Field | Values / Type | Status |
|
||||
|---|---|---|
|
||||
| Recording Mode | Continuous / Single Shot / Histogram | ❓ |
|
||||
| Record Stop Mode | Fixed Record Time / Auto / Manual Stop | ❓ |
|
||||
| Recording Mode | Single Shot (`0x00`) / Continuous (`0x01`) / Histogram (`0x03`) / Histogram+Continuous (`0x04`) | ✅ `recording_mode` — write: `cfg[anchor−3]`; read E5 sf1: `data[anchor−4]` — confirmed 2026-04-20 |
|
||||
| Record Stop Mode | Fixed Record Time / Auto / Manual Stop | ❓ Hint: `data[40]` in E5 sf1 changed `01 7F` → `00 00` alongside Continuous → Single Shot; may be related but unconfirmed independently |
|
||||
| Sample Rate | Standard 1024 / Fast 2048 / Faster 4096 sps | ✅ `sample_rate` (anchor−2) |
|
||||
| Record Time | float, seconds (3, 5, 8, 10, 13…) | ✅ `record_time` (anchor+10) |
|
||||
| Histogram Interval | 5 / 15 / 30 / 60 min (mode-gated behind Histogram mode) | ❓ |
|
||||
@@ -1267,7 +1359,8 @@ Fields visible in the Blastware "Compliance Setup" dialog. ✅ = byte offset co
|
||||
| Geophone — Enable all | bool | ❓ |
|
||||
| Geophone — Trigger Source | bool | ❓ |
|
||||
| Chan 1-3 Trigger Level | float, in/s | ✅ `trigger_level_geo` |
|
||||
| Chan 1-3 Maximum Range | Normal 10.000 / 1.25 in/s | ❓ `max_range_geo` offset found, value=6.206053 — does NOT match UI values; meaning unknown |
|
||||
| Chan 1-3 Maximum Range (range selector) | Normal 10.000 / 1.25 in/s | ✅ `geo_range` uint8 — **CONFIRMED 2026-04-20.** Offset = Tran+33 (same in E5 read and SUB 71 write — 2126-byte buffer is round-tripped verbatim). `0x00`=Normal 10 in/s, `0x01`=Sensitive 1.25 in/s. Applied to Tran/Vert/Long. **`Tran+20` is NOT this field** (constant 0x01 on all captures). |
|
||||
| Chan 1-3 ADC Scale Factor | 6.206053 (in/s)/V | ✅ `geo_adc_scale` float32 — **CONFIRMED 2026-04-17.** Offset = Tran+28 (same in E5 read and SUB 71 write). Inverse sensitivity = 1/0.161133. Interface Handbook §4.5: 1.61133 V × 6.206053 = 10.000 in/s. Hardware constant — do NOT write. |
|
||||
| Microphone — Enable all | bool | ❓ |
|
||||
| Microphone — Trigger Source | bool | ❓ |
|
||||
| Chan 4 Trigger Level | float, dB or psi | ❓ |
|
||||
@@ -1465,6 +1558,117 @@ and applies this heuristic on every call-home.
|
||||
|
||||
---
|
||||
|
||||
### 7.12 Auto Call Home Config (SUB 0x2C / 0x7E / 0x7F) — ✅ CONFIRMED 2026-04-20
|
||||
|
||||
> Confirmed from `bridges/captures/4-20-26/call home settings/` — 10 BW TX write frames
|
||||
> diffed against the S3 read payload. Accessible in Blastware via Remote Access → Setup Unit.
|
||||
|
||||
#### 7.12.1 Read Protocol — SUB 0x2C → Response 0xD3
|
||||
|
||||
Standard two-step read:
|
||||
|
||||
| Step | Offset | Purpose |
|
||||
|---|---|---|
|
||||
| Probe | `0x0000` | Get ack (no data returned) |
|
||||
| Data | `0x007C` (124) | Receive 125-byte raw payload |
|
||||
|
||||
`DATA_LENGTHS[SUB_CALL_HOME] = 0x7C`
|
||||
|
||||
The raw payload is accessed as `data_rsp.data[11:]` — this is 125 bytes (not 124) because
|
||||
the device returns logical value 0x03 (num_retries=3) as the two-byte wire sequence
|
||||
`\x10\x03`. S3FrameParser is in `STATE_IN_FRAME` when it sees `0x10`, transitions to
|
||||
`STATE_AFTER_DLE`, and then on `0x03` (ETX qualifier) it would normally end the frame —
|
||||
but in the `_IN_FRAME_DLE` state it instead appends **both** the `0x10` and the `0x03`
|
||||
literally to the payload. The result: `raw[117] = 0x10`, `raw[118] = 0x03`, and all
|
||||
subsequent fields are shifted +1 from their logical positions.
|
||||
|
||||
#### 7.12.2 Raw Payload Field Map (125 bytes, from `data_rsp.data[11:]`)
|
||||
|
||||
> All offsets are into the 125-byte raw array. Offsets ≥ 119 are shifted +1 from logical
|
||||
> due to the DLE-escaped 0x03 at raw[117:119].
|
||||
|
||||
| Raw Offset | Field | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `[5]` | `auto_call_home_enabled` | uint8 | `0x00` = disabled, `0x01` = enabled |
|
||||
| `[6:46]` | `dial_string` | ASCII | 40-byte null-padded, e.g. `"12345"` or phone number |
|
||||
| `[87]` | `after_event_recorded` | uint8 | `0x00` = off, `0x01` = on |
|
||||
| `[91]` | `at_specified_times` | uint8 | `0x00` = off, `0x01` = on |
|
||||
| `[93]` | `time1_enabled` | uint8 | `0x00` = off, `0x01` = on |
|
||||
| `[101]` | `time1_hour` | uint8 | 0–23 |
|
||||
| `[102]` | `time1_min` | uint8 | 0–59 |
|
||||
| `[95]` | `time2_enabled` | uint8 | `0x00` = off, `0x01` = on |
|
||||
| `[105]` | `time2_hour` | uint8 | 0–23 |
|
||||
| `[106]` | `time2_min` | uint8 | 0–59 |
|
||||
| `[117]` | DLE prefix `0x10` | — | Part of `\x10\x03` wire encoding for num_retries value 3 |
|
||||
| `[118]` | `num_retries` (value = 3) | uint8 | Logical value 0x03; check `raw[117] == 0x10` to detect DLE prefix |
|
||||
| `[120]` | `time_between_retries_sec` | uint8 | Shift +1 from logical 119 |
|
||||
| `[122]` | `wait_for_connection_sec` | uint8 | Shift +1 from logical 121 |
|
||||
| `[124]` | `warm_up_time_sec` | uint8 | Shift +1 from logical 123 |
|
||||
|
||||
**Unconfirmed fields** (offsets not yet mapped from captures):
|
||||
- Time slots 3 and 4 (if they exist — Blastware UI only shows 2 time slots in observed sessions)
|
||||
- `modem_power_relay_enabled` (bool)
|
||||
- `storage_mode` (call home trigger on all events vs. triggered only?)
|
||||
|
||||
#### 7.12.3 DLE-Escaped 0x03 — Critical Detail
|
||||
|
||||
The `\x10\x03` sequence at raw[117:119] is **not** a DLE stuffing artifact in the usual
|
||||
sense. Standard DLE stuffing escapes `\x10` → `\x10\x10`. But here the device is encoding
|
||||
the integer value `3` in a position where the byte `\x03` would be indistinguishable from
|
||||
the frame ETX terminator. The device therefore sends `\x10\x03` (DLE + ETX = "inner-frame
|
||||
terminator" in S3 inner-frame syntax). S3FrameParser correctly handles this: in
|
||||
`STATE_AFTER_DLE`, seeing `\x03` (ETX) while **inside** an outer frame causes it to
|
||||
append both `\x10` and `\x03` as literal bytes rather than ending the frame. The outer
|
||||
frame only terminates on a **bare** `\x03` (without the DLE prefix).
|
||||
|
||||
The write frame sends these bytes verbatim — the device accepts `\x10\x03` in the write
|
||||
payload and interprets it as the value 3. No transformation is needed in
|
||||
`_encode_call_home_config()`.
|
||||
|
||||
**Limitation:** Any field that needs to encode the value `3` (0x03) requires this DLE
|
||||
prefix. The current encoder raises `ValueError` if any hour or minute field equals 3,
|
||||
since the encoder does not yet implement DLE-prefixed writes for arbitrary field positions.
|
||||
In practice, 3:00 AM / 3 minutes past are unlikely scheduled call times.
|
||||
|
||||
#### 7.12.4 Write Protocol — SUB 0x7E → 0x7F
|
||||
|
||||
Write format (same as other write commands — only BW_CMD `0x10` doubled on wire;
|
||||
all other bytes written raw; DLE-aware checksum):
|
||||
|
||||
| Step | SUB | Payload | Offset | Response |
|
||||
|---|---|---|---|---|
|
||||
| Data write | `0x7E` | 127 bytes (125-byte read payload + `\x00\x00`) | `data[1]+2 = 0x7E` (126) | `0x81` |
|
||||
| Confirm | `0x7F` | empty | `0x00` | `0x80` |
|
||||
|
||||
**Write payload construction:**
|
||||
```python
|
||||
write_payload = bytearray(raw_125_bytes)
|
||||
write_payload.append(0x00)
|
||||
write_payload.append(0x00)
|
||||
# patch fields in-place, then pass bytes(write_payload) to build_bw_write_frame
|
||||
```
|
||||
|
||||
**Offset formula:** `write_payload[1] = 0x7C` (same as DATA_LENGTH).
|
||||
`offset = write_payload[1] + 2 = 0x7C + 2 = 0x7E = 126`.
|
||||
This follows the identical pattern as SUB 0x68 (event index write) and SUB 0x69 (waveform write).
|
||||
|
||||
**No preceding 0x2C read required** — Blastware sends SUB 0x7E directly using cached
|
||||
state. The `seismo-relay` implementation always reads first (`get_call_home_config()`)
|
||||
before writing for safety.
|
||||
|
||||
#### 7.12.5 Implementation Notes
|
||||
|
||||
- `MiniMateProtocol.read_call_home_config()` — standard two-step read; returns `data_rsp.data[11:]` (125 bytes raw)
|
||||
- `MiniMateProtocol.write_call_home_config(data)` — sends SUB 0x7E (127-byte payload) then SUB 0x7F confirm
|
||||
- `MiniMateClient.get_call_home_config()` → `CallHomeConfig` dataclass
|
||||
- `MiniMateClient.set_call_home_config(...)` — reads current config, patches via `_encode_call_home_config()`, writes back
|
||||
- `_decode_call_home_config(raw)` — handles DLE prefix detection at raw[117]
|
||||
- `_encode_call_home_config(raw, ...)` — patches in-place, appends 2 trailing zeros; raises `ValueError` if any hour/min == 3
|
||||
- REST API: `GET /device/call_home` and `POST /device/call_home` in `sfm/server.py`
|
||||
- Web UI: "Call Home" tab in `sfm/sfm_webapp.html`
|
||||
|
||||
---
|
||||
|
||||
## 8. Timestamp Format
|
||||
|
||||
Two timestamp wire formats are used:
|
||||
@@ -1933,7 +2137,7 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
|
||||
| **Auxiliary Trigger read location** — **RESOLVED:** SUB `FE` offset `0x0109`, uint8, `0x00`=disabled, `0x01`=enabled. Confirmed 2026-03-11 via controlled toggle capture. | RESOLVED | 2026-03-02 | Resolved 2026-03-11 |
|
||||
| **Auxiliary Trigger write path** — Write command not yet captured in a clean session. Inner frame handshake visible in A4 (multiple WRITE_CONFIRM_RESPONSE SUBs appear, TRIGGER_CONFIG_RESPONSE removed), but the BW→S3 write command itself was in a partial session. Likely SUB `15` or similar. Deferred for clean capture. | LOW | 2026-03-11 | NEW |
|
||||
| ~~**SUB `6E` response to SUB `1C`**~~ — ~~RESOLVED 2026-04-08: This was a misidentification.~~ The `1C → 6E` "exception" was misread — likely an inner A4 sub-frame. Confirmed from 4-8-26 capture (338 frames): SUB 0x1C always → 0xE3. No exceptions to the `0xFF − SUB` rule are known. | RESOLVED | 2026-04-08 | CLOSED |
|
||||
| **Max Geo Range float 6.2061** — offset confirmed in channel block (`+1A`, `40 C6 97 FD`). Meaning and units are UNKNOWN. Value does NOT match either user-selectable range (1.25 / 10.0 in/s). Using it as ADC full-scale produces ~9× PPV overread vs on-device 0C values. Not simply metric vs imperial (25.4 factor doesn't reconcile). Needs investigation: examine surrounding channel block bytes, compare with a Blastware waveform CSV export to back-calculate the correct scale. Upgraded to HIGH priority. | HIGH | 2026-02-26 | Upgraded 2026-04-16 |
|
||||
| ~~**Max Geo Range float 6.2061**~~ — **RESOLVED 2026-04-17.** Confirmed as the **ADC-to-velocity scale factor** = inverse sensitivity = 1/0.161133 = **6.206053 (in/s)/V**. Source: Interface Handbook §4.5 formula `Range = 1.61133 V / Sensitivity`. For standard Instantel geo at Normal (Gain=1) range: Sensitivity = 1.61133/10 = 0.161133 V/(in/s), scale = 6.206053. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. The earlier ~9× overread was from using 6.206053 directly as range instead of as scale factor (range = 1.61133 V × 6.206053 = 10.000 in/s). Hardware constant — do NOT write. | RESOLVED | 2026-02-26 | Resolved 2026-04-17 |
|
||||
| MicL channel units — **RESOLVED: psi**, confirmed from `.set` file unit string `"psi\0"` | RESOLVED | 2026-03-01 | |
|
||||
| Backlight offset — **RESOLVED: +4B in event index data**, uint8, seconds | RESOLVED | 2026-03-02 | |
|
||||
| Power save offset — **RESOLVED: +53 in event index data**, uint8, minutes | RESOLVED | 2026-03-02 | |
|
||||
@@ -1962,10 +2166,11 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
|
||||
| Trigger Level (Mic) | §3.8.6 | Channel block, float | float32 BE | 100–148 dB in 1 dB steps |
|
||||
| Alarm Level (Mic) | §3.9.10 | Channel block, float | float32 BE | higher than mic trigger |
|
||||
| Record Time | §3.8.9 | cfg anchor+10, float32 BE (wire); `.set` +16, uint32 LE (file) | float32 BE (wire) | 1–105 s; confirmed 3→`40400000`, 5→`40A00000`, 8→`41000000`, 13→`41500000`. Use anchor §7.6.1/§7.6.3 — NOT fixed offset. |
|
||||
| Max Geo Range | §3.8.4 | Channel block, float | float32 BE | ❓ UNKNOWN — value 6.2061 confirmed at offset, but meaning/units unresolved. Does NOT equal 1.25 or 10.0 in/s. Do NOT use as ADC full-scale. |
|
||||
| ADC Scale Factor (geo_adc_scale) | §3.8.4 / Interface Handbook §4.5 | Channel block, Tran+28 (same in E5 read and SUB 71 write), float32 BE | float32 BE = 6.206053 | ✅ CONFIRMED 2026-04-17 — inverse sensitivity (in/s)/V. `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant, identical on all units. Do NOT write. |
|
||||
| Max Geo Range (geo_range) | §3.8.4 | Channel block, Tran+33 (same in E5 read and SUB 71 write), uint8; applied to Tran/Vert/Long | uint8 | ✅ CONFIRMED 2026-04-20 — `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. **NOTE: `Tran+20` reads `0x01` on ALL captures regardless of range — it is NOT this field.** |
|
||||
| Microphone Units | §3.9.7 | Inline unit string | char[4] | `"psi\0"`, `"pa.\0"`, `"dB\0\0"` |
|
||||
| Sample Rate | §3.8.2 | cfg anchor−2, uint16 BE — anchor=`\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` in cfg[40:100] | uint16 BE | Normal=1024, Fast=2048, Faster=4096 ✅ CONFIRMED 2026-04-01 (BE11529 S338.17). Anchor required — see §7.6.3 DLE jitter. |
|
||||
| Record Mode | §3.8.1 | Unknown | — | Single Shot, Continuous, Manual, Histogram, Histogram Combo |
|
||||
| Record Mode | §3.8.1 | Write: `cfg[anchor−3]`, uint8. Read (E5 sf1): `data[anchor−4]`, uint8. Note: extra `0x10` byte at read `data[anchor−3]` shifts offset by 1 vs write. | uint8 | `0x00`=Single Shot, `0x01`=Continuous, `0x02`=unknown, `0x03`=Histogram, `0x04`=Histogram+Continuous. ✅ CONFIRMED 2026-04-20 |
|
||||
| Trigger Sample Width | §3.13.1h | BW→S3 SUB `0x82` write frame, destuffed `[22]`, uint8 | uint8 | Default=2; confirmed 4=`0x04`, 3=`0x03`. **BW-side write only** — not visible in S3 compliance reads. Mode-gated: only sent in Compliance/Single-Shot/Fixed mode. |
|
||||
| Auto Window | §3.13.1b | **Mode-gated — NOT YET MAPPED** | uint8? | 1–9 seconds; only active when Record Stop Mode = Auto. Capture in Fixed mode produced no wire change. |
|
||||
| Auxiliary Trigger | §3.13.1d | SUB `FE` (FULL_CONFIG_RESPONSE) offset `0x0109` (read); write path not yet isolated | uint8 (bool) | `0x00`=disabled, `0x01`=enabled; confirmed 2026-03-11 |
|
||||
@@ -2068,6 +2273,279 @@ Semantic Interpretation <- settings, events, responses
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Appendix D — Blastware Binary File Formats (.N00 / .MLG / others)
|
||||
|
||||
> ✅ CONFIRMED 2026-04-21 — all fields verified by binary diff of reconstructed vs reference
|
||||
> files from the 4-3-26-multi_event capture (M529LIY6.N00, BE11529.MLG).
|
||||
>
|
||||
> ⚠️ EXTENSION MAPPING REFUTED 2026-04-21 — earlier assumption that extension encodes
|
||||
> recording mode is **FALSE**. A continuous-mode event produced `.EI0`, not `.9T0`.
|
||||
> Extension encoding algorithm is unknown. Do not use extension to infer recording mode.
|
||||
|
||||
### D.1 Common File Header (22 bytes)
|
||||
|
||||
All Blastware files (regardless of type) share an 18-byte prefix followed by a 4-byte type tag.
|
||||
|
||||
| Offset | Length | Value | Description |
|
||||
|---|---|---|---|
|
||||
| 0x00 | 6 | `10 00 01 80 00 00` | Fixed prefix |
|
||||
| 0x06 | 10 | `Instantel\x00` | ASCII string |
|
||||
| 0x10 | 2 | `07 2c` | Fixed suffix |
|
||||
| 0x12 | 4 | varies | File type tag (see below) |
|
||||
|
||||
**Total header: 22 bytes.**
|
||||
|
||||
**Type tags:**
|
||||
|
||||
| Extension | Type tag | Description |
|
||||
|---|---|---|
|
||||
| `.N00` | `00 12 03 00` | Waveform event (confirmed) |
|
||||
| `.9T0` | `00 12 03 00` | Waveform event — same type tag as .N00 (assumed; not independently confirmed) |
|
||||
| `.EI0` | `00 12 03 00` | Waveform event — same type tag (assumed; continuous-mode event observed 2026-04-21) |
|
||||
| `.MLG` | `22 01 0e a0` | Monitor log |
|
||||
|
||||
**Extension encoding — new firmware (V10.72+) FULLY DECODED (confirmed 2026-04-22):**
|
||||
|
||||
The extension differs depending on how the file was saved:
|
||||
|
||||
| Download method | Extension format | Example |
|
||||
|---|---|---|
|
||||
| Manual / direct (Blastware connected to unit) | `AB0` (3 chars) | `.CE0` |
|
||||
| Call-home / ACH | `AB0W` or `AB0H` (4 chars) | `.CE0H` |
|
||||
|
||||
Where:
|
||||
- `AB` = 2-char base-36 of `total_seconds % 1296`; `A = value // 36`, `B = value % 36`
|
||||
- `total_seconds = (event_local_time − 1985-01-01T00:00:00_local)` in seconds
|
||||
- `0` = always literal digit zero
|
||||
- `W` = Full Waveform, `H` = Full Histogram (ACH only)
|
||||
|
||||
Base-36 alphabet: `0–9` = 0–9, `A–Z` = 10–35.
|
||||
|
||||
The 10-year production archive contains only ACH files (all end in W or H). Manual Blastware downloads produce the same `AB0` prefix but without the trailing type character.
|
||||
|
||||
**3-day cycle property (confirmed 2026-04-22):** A unit recording at a fixed daily time cycles through exactly **3 different extensions** with a 3-day period. Each calendar day shifts `total_seconds % 1296` by 864 (since `86400 % 1296 = 864`). The cycle repeats every 3 days because `gcd(1296, 864) = 432`. Confirmed from archive: top 3 extensions `CE0H` (95), `0E0H` (93), `OE0H` (91) are the 3-day cycle of a 06:00:14 daily call-in (seconds-in-window = 446, 14, 878).
|
||||
|
||||
**B character invariance:** `864 = 24 × 36`, so adding one day never changes `value % 36` — the second extension character is invariant for a fixed daily recording time. Only the first character cycles through 3 values.
|
||||
|
||||
**Old firmware (S338):** 3-char extensions observed (`.N00`, `.EI0`, etc.) — may simply be manual downloads under the same AB0 scheme, or a different encoding. Not yet confirmed.
|
||||
|
||||
**Micromate Series 4** uses a different extension format (observed: `IDFH`, `IDFW`). This formula does NOT apply to Micromate units.
|
||||
|
||||
All waveform files share the same `00 12 03 00` type tag regardless of extension. Blastware identifies file type by extension, not by type tag alone.
|
||||
|
||||
### D.2 Timestamp Encoding (Blastware files)
|
||||
|
||||
All timestamps in N00 and MLG files use an **8-byte big-endian format**:
|
||||
|
||||
| Byte | Field |
|
||||
|---|---|
|
||||
| 0 | day (uint8) |
|
||||
| 1 | month (uint8) |
|
||||
| 2–3 | year (uint16 BE) |
|
||||
| 4 | `0x00` (reserved) |
|
||||
| 5 | hour (uint8) |
|
||||
| 6 | minute (uint8) |
|
||||
| 7 | second (uint8) |
|
||||
|
||||
Example: `01 04 07 ea 00 00 1c 08` → April 1, 2026, 00:28:08.
|
||||
|
||||
Note: this differs from the 8-byte protocol timestamp (`[day][sub_code][month][year_HI][year_LO][0x00][hour][min][sec]` = 9 bytes) used in the device's on-wire 0C waveform records. The file format uses a compact 8-byte layout without the `sub_code` byte.
|
||||
|
||||
### D.3 N00 File Format — Single-Shot Waveform Event
|
||||
|
||||
**File layout:** `[22B header] [21B STRT record] [body bytes] [26B footer]`
|
||||
|
||||
#### D.3.1 STRT Record (21 bytes)
|
||||
|
||||
The STRT record immediately follows the 22-byte header.
|
||||
|
||||
| Offset | Length | Field | Notes |
|
||||
|---|---|---|---|
|
||||
| 0 | 4 | `STRT` | ASCII literal |
|
||||
| 4 | 2 | `ff fe` | Fixed |
|
||||
| 6 | 4 | event key (key4) | 4-byte waveform key |
|
||||
| 10 | 4 | device-specific | NOT a repeat of key4 — device-internal field |
|
||||
| 14 | 6 | device-specific | NOT zero-padded — device-internal fields |
|
||||
| 20 | 1 | rectime | uint8 seconds |
|
||||
|
||||
**Critical:** The STRT record must be copied verbatim from A5[0].data[7+strt_pos:] — bytes [10:20] contain device-specific values that cannot be reconstructed from protocol-level Event fields alone.
|
||||
|
||||
#### D.3.2 Body Bytes (variable)
|
||||
|
||||
The body is reconstructed from the raw A5 bulk waveform stream frames by stripping DLE framing markers and taking the appropriate slice of each frame's data section.
|
||||
|
||||
**Per-frame contribution (from `frame.data`):**
|
||||
|
||||
| Frame | Skip amount | Notes |
|
||||
|---|---|---|
|
||||
| A5[0] (probe) | `7 + strt_pos_in_w0 + 21` | Skip frame.data prefix + STRT record |
|
||||
| A5[1] | 13 | 7-byte prefix + 6-byte first-chunk header |
|
||||
| A5[2..N] | 12 | 7-byte prefix + 5-byte chunk header |
|
||||
| Terminator (page_key=0x0000) | 11 | 7-byte prefix + 4-byte terminator header |
|
||||
|
||||
**DLE strip rule:** For each frame's contribution (`frame.data[skip:]`), strip any `0x10` byte immediately followed by `0x02`, `0x03`, or `0x04`. Only the `0x10` is stripped; the following byte is kept as payload.
|
||||
|
||||
**Split-pair edge case:** When `frame.data[-1] == 0x10` AND `frame.chk_byte ∈ {0x02, 0x03, 0x04}`, the S3FrameParser split a DLE+XX pair at the payload/checksum boundary. Reunite the bytes before stripping (`relevant + bytes([chk_byte])`), then always remove the trailing chk_byte from the result (`stripped[:-1]`) — chk_byte is the wire checksum, never payload.
|
||||
|
||||
**Body/footer split:** Accumulate all frame contributions (data frames + terminator) into `all_bytes`. Then:
|
||||
- `body = all_bytes[:-26]` (variable length)
|
||||
- `footer = all_bytes[-26:]` (always 26 bytes — extracted from terminator content)
|
||||
|
||||
#### D.3.3 Footer (26 bytes)
|
||||
|
||||
The footer terminates the N00 file. Its bytes come directly from the terminator A5 frame's inner content — do NOT reconstruct from event metadata.
|
||||
|
||||
| Offset | Length | Field | Notes |
|
||||
|---|---|---|---|
|
||||
| 0 | 2 | `0e 08` | Fixed marker |
|
||||
| 2 | 8 | ts1 | Start timestamp (8B big-endian) |
|
||||
| 10 | 8 | ts2 | Stop timestamp (8B big-endian) |
|
||||
| 18 | 6 | `00 01 00 02 00 00` | Fixed |
|
||||
| 24 | 2 | CRC | 2-byte CRC — algorithm unconfirmed |
|
||||
|
||||
**CRC:** The 2-byte CRC at footer[24:26] has an unconfirmed algorithm. In M529LIY6.N00 it reads `fe da`. Attempts to match CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), and 40+ polynomial/init combinations all failed. The writer copies it verbatim from the terminator frame.
|
||||
|
||||
### D.4 MLG File Format — Monitor Log
|
||||
|
||||
**File layout:** `[308B header] [N × 292B records]`
|
||||
|
||||
#### D.4.1 MLG Header (308 bytes)
|
||||
|
||||
| Offset | Length | Field | Notes |
|
||||
|---|---|---|---|
|
||||
| 0x00 | 22 | common header | prefix + `22 01 0e a0` type tag |
|
||||
| 0x16 | 16 | unknown | observed as zeros in BE11529.MLG |
|
||||
| 0x2A | 8 | serial number | null-padded ASCII (e.g. `"BE11529"`) |
|
||||
| 0x32 | remainder | zero pad | pads to 308 bytes total |
|
||||
|
||||
#### D.4.2 MLG Record (292 bytes each)
|
||||
|
||||
| Offset | Length | Field | Notes |
|
||||
|---|---|---|---|
|
||||
| 0 | 2 | CRC | 2-byte CRC — algorithm unconfirmed; write as `00 00` |
|
||||
| 2 | 4 | `22 01 0e 80` | Record marker |
|
||||
| 6 | 8 | ts1 | Start timestamp (8B big-endian) |
|
||||
| 14 | 8 | ts2 | Stop timestamp (8B big-endian); zeros if no stop |
|
||||
| 22 | 4 | flags | Record type flags (see below) |
|
||||
| 26 | 10 | serial | Null-padded ASCII serial number |
|
||||
| 36 | variable | text | Type-dependent content |
|
||||
| — | remainder | zero pad | pads to 292 bytes total |
|
||||
|
||||
**Record flags:**
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `ff ff 00 00` | Monitoring start with no stop recorded |
|
||||
| `01 00 02 00` | Triggered event (has ts1 + ts2) |
|
||||
| `02 00 00 00` | Monitoring interval (has ts1 + ts2) |
|
||||
|
||||
**Text content for triggered events (`flags = 01 00 02 00`):**
|
||||
|
||||
| Byte | Field |
|
||||
|---|---|
|
||||
| 0 | `0x08` |
|
||||
| 1–8 | ts1 copy (8B big-endian) |
|
||||
| 9+ | `"Geo: X.XXX in/s\x00"` ASCII geo threshold |
|
||||
|
||||
#### D.4.3 MLG CRC
|
||||
|
||||
The 2-byte CRC at record[0:2] uses an unconfirmed algorithm. Tested against CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), word sums, XOR variants, and 40+ polynomial/init combinations — none matched. The writer emits `00 00`. Blastware may reject files with incorrect CRCs (impact on import unknown — TODO: test).
|
||||
|
||||
### D.5 Filename Encoding ✅ PARTIALLY CONFIRMED 2026-04-22
|
||||
|
||||
Blastware assigns waveform filenames of the form `<prefix_letter><serial3><stem><ext>`, where:
|
||||
|
||||
#### D.5.1 Serial Prefix ✅ CONFIRMED 2026-04-22
|
||||
|
||||
The first 4 characters of the filename encode the full device serial number:
|
||||
|
||||
```
|
||||
prefix_letter = chr(ord('B') + floor(serial_numeric / 1000))
|
||||
serial3 = f"{serial_numeric % 1000:03d}" (last 3 digits, zero-padded)
|
||||
```
|
||||
|
||||
Where `serial_numeric` is the integer after the "BE" device-type prefix.
|
||||
|
||||
Examples (all confirmed from archive):
|
||||
|
||||
| Serial | serial_numeric / 1000 | prefix_letter | serial3 | Filename prefix |
|
||||
|--------|----------------------|---------------|---------|-----------------|
|
||||
| BE6907 | 6 | H | 907 | H907 |
|
||||
| BE7145 | 7 | I | 145 | I145 |
|
||||
| BE11529 | 11 | M | 529 | M529 |
|
||||
| BE14036 | 14 | P | 036 | P036 |
|
||||
| BE17353 | 17 | S | 353 | S353 |
|
||||
| BE18003 | 18 | T | 003 | T003 |
|
||||
| BE18191 | 18 | T | 191 | T191 |
|
||||
| BE18676 | 18 | T | 676 | T676 |
|
||||
|
||||
**Interpretation:** The prefix letter encodes the production generation (batch of 1000 units). B=generation 0 (serials 0–999), C=generation 1 (1000–1999), etc. No units with prefix A have been observed — the earliest known units start around serial 2000+ (prefix D).
|
||||
|
||||
**Note:** The "BE" device-type prefix is implicit. The filename only encodes the numeric part of the serial. Other Instantel device types (Micromate, Blastmate) may use a different scheme.
|
||||
|
||||
#### D.5.2 Stem + Extension — full timestamp encoding ✅ FULLY CONFIRMED 2026-04-22
|
||||
|
||||
The stem (4 chars) and AB extension (2 chars) together form a 6-digit base-36 number encoding a complete second-resolution timestamp:
|
||||
|
||||
```python
|
||||
total_seconds = stem_int * 1296 + ab_int
|
||||
event_local_time = datetime(1985, 1, 1) + timedelta(seconds=total_seconds)
|
||||
```
|
||||
|
||||
- **Epoch:** `1985-01-01 00:00:00` **device local time** ✅ CONFIRMED — verified against 3,248 files from a 10-year production archive; zero errors (only 2 mismatches were Micromate `IDFH`/`IDFW` files which use a completely different naming scheme)
|
||||
- **Unit:** 1296 seconds = 36² ≈ 21.6 minutes per stem increment
|
||||
- **Alphabet:** `"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (digits then uppercase letters)
|
||||
- **Collision:** Events within the same 21.6-minute window share a stem; extension distinguishes them
|
||||
|
||||
**Decoding example — `P036L318.C80H` (BE14036, Full Histogram):**
|
||||
```
|
||||
stem L318 = 21×36³ + 3×36² + 1×36 + 8 = 983,708
|
||||
AB C8 = 12×36 + 8 = 440
|
||||
total_sec = 983,708 × 1296 + 440 = 1,274,886,008
|
||||
event_time = 1985-01-01 + 1,274,886,008s = 2025-05-26 15:00:08 local
|
||||
```
|
||||
|
||||
**Note on local time:** The device's onboard clock is set to the local timezone of the deployment site. The epoch and all timestamps are in that same local time — there is no UTC conversion. Files moved between timezones will decode to the original deployment timezone.
|
||||
|
||||
#### D.5.3 Extension taxonomy
|
||||
|
||||
Third character of extension is always `'0'`. File type is identified by extension, not by the type tag in the header (all waveform extensions share type tag `00 12 03 00`).
|
||||
|
||||
| Extension | Recording mode | Sample rate | Status |
|
||||
|---|---|---|---|
|
||||
| `.N00` | Single Shot (0x00) | 1024 sps | ✅ CONFIRMED |
|
||||
| `.9T0` | Continuous (0x01) | 1024 sps | ✅ CONFIRMED |
|
||||
| `.490` | ? | ? | ❓ observed from M529LJ8V.490 |
|
||||
| `.5K0` | ? | ? | ❓ observed from M529LJDY.5K0 |
|
||||
| `.980` | ? | ? | ❓ observed from M529LJDY.980 |
|
||||
| `.ML0` | ? | ? | ❓ observed from M529LJDY.ML0 (167s duration; possibly Histogram) |
|
||||
|
||||
**Why 5 extensions for "Continuous"?** Binary analysis of all 6 example files shows that `.9T0`, `.490`, `.5K0`, `.980`, `.ML0` are byte-for-byte identical in all metadata regions (compliance anchor block, channel descriptor blocks `Tran/Vert/Long/MicL`). The A5 frame 7 body reflects the **session-start** compliance config, not the per-event capture config. All 5 files show recording_mode=0x01 and sample_rate=1024 in the body. The extension must therefore encode the **capture-time** compliance state — likely a combination of recording mode, sample rate, and possibly mic units or other options. This cannot be determined from file body alone without capture-time compliance data from the 0C record sub_code and the actual waveform sample count.
|
||||
|
||||
**DLE-shift offset note for reading recording_mode from N00/9T0 body:**
|
||||
|
||||
The compliance block in the file body has been through `_strip_inner_frame_dles`. The 0x10 constant at logical `anchor−7` (between recording_mode and sample_rate_HI) gets stripped when sample_rate_HI = `0x04` (1024 sps), because `0x10` precedes `0x04 ∈ {0x02,0x03,0x04}`. After stripping, the anchor shifts left by 1, so:
|
||||
|
||||
| 1024 sps (strip occurs) | 2048 or 4096 sps (no strip) |
|
||||
|---|---|
|
||||
| `file[anc−7]` = recording_mode | `file[anc−8]` = recording_mode |
|
||||
| `file[anc−6:anc−4]` = sample_rate | `file[anc−6:anc−4]` = sample_rate |
|
||||
|
||||
For 1024 sps files, the expected file bytes around the anchor are:
|
||||
```
|
||||
file[anc−9]: mode_prefix (0x00 for Single Shot/Continuous; 0x10 for Histogram)
|
||||
file[anc−8]: 0x00 (was recording_mode, but shifted away — now reads 0x00 for mode_prefix)
|
||||
file[anc−7]: recording_mode (0x00=Single Shot, 0x01=Continuous, etc.)
|
||||
file[anc−6]: 0x04 (sample_rate_HI for 1024 sps)
|
||||
file[anc−5]: 0x00 (sample_rate_LO)
|
||||
file[anc−4]: histogram_interval_HI
|
||||
file[anc−3]: histogram_interval_LO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*All findings reverse-engineered from live RS-232 bridge captures.*
|
||||
*Cross-referenced from 2026-03-02 with Instantel MiniMate Plus Operator Manual (716U0101 Rev 15).*
|
||||
*This is a living document — append changelog entries and timestamps as new findings are confirmed or corrected.*
|
||||
@@ -0,0 +1,949 @@
|
||||
"""
|
||||
blastware_file.py — Blastware binary file codec for bidirectional interoperability.
|
||||
|
||||
Reads and writes the proprietary Instantel/Blastware file formats:
|
||||
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
|
||||
internal binary structure (same type tag 00 12 03 00, same STRT record layout).
|
||||
Blastware identifies the file type by extension, not by a magic marker.
|
||||
|
||||
EXTENSION ENCODING — V10.72 firmware FULLY CONFIRMED 2026-04-22:
|
||||
|
||||
Direct / manual download: AB0 (3-char, no type character)
|
||||
Call-home (ACH) download: AB0W or AB0H (4-char, W=waveform H=histogram)
|
||||
|
||||
AB = 2-char base-36 of (total_seconds % 1296), where
|
||||
total_seconds = (event_local_time − 1985-01-01T00:00:00_local).
|
||||
0 = always literal digit zero.
|
||||
Verified against 3,248 call-home files from a 10-year production archive.
|
||||
|
||||
The 10-year archive contains only ACH files (all end in W or H).
|
||||
Manual Blastware downloads produce 3-char AB0 extensions — same encoding
|
||||
but without the trailing type character.
|
||||
|
||||
Old firmware (S338, 3-char extensions): encoding unknown / same as manual?
|
||||
Micromate Series 4 uses a different scheme (literal datetime in filename).
|
||||
|
||||
─── File structure overview ─────────────────────────────────────────────────────
|
||||
|
||||
Waveform file structure (confirmed from example-events/4-3-26-multi/M529LIY6 (example event)):
|
||||
|
||||
[22B header] [21B STRT record] [body bytes] [26B footer]
|
||||
|
||||
Header (22 bytes):
|
||||
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 — waveform file type tag (shared by all waveform extensions)
|
||||
|
||||
STRT record (21 bytes, immediately follows header):
|
||||
53 54 52 54 — b'STRT'
|
||||
ff fe — fixed (2 bytes)
|
||||
[key4] — 4-byte waveform event key
|
||||
[key4] — 4-byte waveform event key (repeated)
|
||||
[zeros] — 7 bytes padding
|
||||
[rectime] — uint8 record time in seconds
|
||||
|
||||
Body (variable — reconstructed from A5 frame data):
|
||||
The body bytes are derived from the raw A5 frame wire content, specifically
|
||||
from the DLE-decoded representation of each frame's contribution. See the
|
||||
_frame_body_bytes() helper for the exact algorithm.
|
||||
|
||||
Footer (26 bytes):
|
||||
0e 08
|
||||
[ts1: 8B big-endian timestamp] — start timestamp
|
||||
[ts2: 8B big-endian timestamp] — stop timestamp
|
||||
00 01 00 02 00 00
|
||||
[crc: 2B] — CRC (algorithm unconfirmed; written as 0x00 0x00 placeholder)
|
||||
|
||||
Timestamp format (big-endian, 8 bytes):
|
||||
[day] [month] [year_HI] [year_LO] [0x00] [hour] [min] [sec]
|
||||
|
||||
MLG (monitor log, confirmed from example-events/4-3-26-multi/BE11529.MLG):
|
||||
|
||||
[308B header] [N × 292B records]
|
||||
|
||||
Header (308 bytes):
|
||||
Offset 0x00: 10 00 01 80 00 00 Instantel\x00 07 2c 22 01 0e a0 — fixed (16B)
|
||||
Offset 0x10: ... (unknown structure, written as zeros + serial)
|
||||
Offset 0x2A: serial number (8 bytes, null-padded ASCII, e.g. "BE11529")
|
||||
... zero-padded to 308 bytes total
|
||||
|
||||
Record (292 bytes each):
|
||||
[2B CRC] — unknown algorithm; written as 0x00 0x00
|
||||
22 01 0e 80 — record marker
|
||||
[ts1: 8B big-endian timestamp] — start time
|
||||
[ts2: 8B big-endian timestamp] — stop time (zeros if no stop)
|
||||
[4B flags] — see MLG_FLAGS_* constants below
|
||||
[10B serial] — null-padded serial number ASCII
|
||||
[text] — for trigger records: [0x08][8B ts1_copy] then ASCII "Geo: X.XXX in/s"
|
||||
for monitoring records: b'' (or minimal separator)
|
||||
[zero-padded to 292 bytes]
|
||||
|
||||
─── Critical implementation notes ──────────────────────────────────────────────
|
||||
|
||||
Waveform body reconstruction algorithm (confirmed 2026-04-21 from verification against
|
||||
M529LIY6 (example event) using raw_s3_20260403_153508.bin capture):
|
||||
|
||||
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.
|
||||
|
||||
Skip amounts per frame index (offsets into frame.data):
|
||||
A5[0] (probe): data[strt_pos + 21 + 7] (skip header + STRT record)
|
||||
strt_pos found by searching frame.data[7:] for b'STRT';
|
||||
the contribution starts at strt_pos + 21 within data[7:]
|
||||
which equals strt_pos + 21 + 7 within frame.data.
|
||||
A5[1]: data[13] (skip 7-byte frame.data prefix + 6 header bytes)
|
||||
A5[2..N]: data[12] (skip 7-byte frame.data prefix + 5 header bytes)
|
||||
Terminator A5: data[11] (1 byte less than chunk frames; terminator inner header
|
||||
is 4 bytes instead of 5 — confirmed 2026-04-21)
|
||||
|
||||
DLE strip rule (applied AFTER slicing):
|
||||
Strip any 0x10 byte that is immediately followed by 0x02, 0x03, or 0x04.
|
||||
This undoes the DLE-escape that S3FrameParser preserves as literal pairs.
|
||||
Applied to frame.data[skip:] + bytes([frame.chk_byte]) together, then
|
||||
conditionally exclude the trailing chk_byte from the output.
|
||||
|
||||
chk_byte absorption:
|
||||
When frame.data[-1] == 0x10 AND frame.chk_byte ∈ {0x02, 0x03, 0x04},
|
||||
the last byte of frame.data is the DLE prefix of a split DLE+chk pair.
|
||||
Including chk_byte in the strip buffer allows the pair to be stripped as
|
||||
a unit. After stripping, the trailing chk_byte is ALWAYS removed — because
|
||||
_strip_inner_frame_dles keeps the byte after the DLE (the chk_byte value),
|
||||
and that value is the checksum, never payload. This applies to all three
|
||||
cases (chk ∈ {0x02, 0x03, 0x04}) identically.
|
||||
|
||||
MLG CRC:
|
||||
The algorithm that produces the 2-byte CRC at the start of each MLG record
|
||||
is unknown. All examined records use non-zero values that do not match
|
||||
CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), word sums, XOR variants, or
|
||||
any of the 40+ polynomial/init combinations tested. The writer emits 0x0000.
|
||||
This produces files that Blastware may reject or display without the CRC check —
|
||||
the exact impact on BW import is unknown (TODO: test).
|
||||
|
||||
─── Public API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
blastware_filename(event, serial)
|
||||
Return the correct Blastware filename for an event (e.g. "M529LIY6.CE0W").
|
||||
Full AB0T extension encoding confirmed 2026-04-22 against 3,248 archive files.
|
||||
Extension matches what Blastware itself would generate for the same event.
|
||||
|
||||
write_blastware_file(event, a5_frames, path)
|
||||
Create a Blastware waveform file from an Event and the full A5 frame list.
|
||||
All waveform extensions share the same binary format — the extension is set
|
||||
by blastware_filename() based on the event timestamp and type.
|
||||
|
||||
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)
|
||||
Create a .MLG file from a list of MonitorLogEntry objects.
|
||||
|
||||
read_mlg(path) → list[MonitorLogEntry]
|
||||
Parse a .MLG file into MonitorLogEntry objects.
|
||||
(Not yet implemented — placeholder raises NotImplementedError.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from .framing import S3Frame
|
||||
from .models import Event, MonitorLogEntry, Timestamp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── File header constants ─────────────────────────────────────────────────────
|
||||
|
||||
# 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
|
||||
|
||||
# Simpler construction:
|
||||
_FILE_HEADER_PREFIX = b"\x10\x00\x01\x80\x00\x00Instantel\x00\x07\x2c" # 17 bytes
|
||||
|
||||
# Waveform file type tag (4 bytes after common prefix) — shared by ALL waveform extensions
|
||||
_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
|
||||
_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\x00Instantel\x00\x07\x2c" # 18 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)
|
||||
_MLG_RECORD_MARKER = b"\x22\x01\x0e\x80"
|
||||
_MLG_RECORD_SIZE = 292 # bytes per record (confirmed from BE11529.MLG)
|
||||
|
||||
# MLG record flags (4 bytes at record[22:26])
|
||||
# Confirmed from BE11529.MLG binary inspection:
|
||||
MLG_FLAGS_START_ONLY = b"\xff\xff\x00\x00" # monitoring start with no stop
|
||||
MLG_FLAGS_TRIGGER = b"\x01\x00\x02\x00" # triggered event (has ts1 + ts2)
|
||||
MLG_FLAGS_INTERVAL = b"\x02\x00\x00\x00" # monitoring interval (has ts1 + ts2)
|
||||
|
||||
|
||||
# ── Timestamp helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _encode_ts_be(ts: Optional[datetime.datetime]) -> bytes:
|
||||
"""
|
||||
Encode a datetime as an 8-byte big-endian Blastware timestamp.
|
||||
|
||||
Format (waveform file and MLG record timestamps):
|
||||
[day][month][year_HI][year_LO][0x00][hour][min][sec]
|
||||
|
||||
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 ✅
|
||||
|
||||
Returns 8 zero bytes if ts is None.
|
||||
"""
|
||||
if ts is None:
|
||||
return bytes(8)
|
||||
return bytes([
|
||||
ts.day,
|
||||
ts.month,
|
||||
(ts.year >> 8) & 0xFF,
|
||||
ts.year & 0xFF,
|
||||
0x00,
|
||||
ts.hour,
|
||||
ts.minute,
|
||||
ts.second,
|
||||
])
|
||||
|
||||
|
||||
def _decode_ts_be(raw: bytes) -> Optional[datetime.datetime]:
|
||||
"""
|
||||
Decode an 8-byte big-endian Blastware timestamp.
|
||||
|
||||
Returns None if the bytes are all zero or structurally invalid.
|
||||
"""
|
||||
if len(raw) < 8 or raw == bytes(8):
|
||||
return None
|
||||
day = raw[0]
|
||||
month = raw[1]
|
||||
year = (raw[2] << 8) | raw[3]
|
||||
hour = raw[5]
|
||||
minute = raw[6]
|
||||
sec = raw[7]
|
||||
try:
|
||||
return datetime.datetime(year, month, day, hour, minute, sec)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _ts_from_model(ts: Optional[Timestamp]) -> Optional[datetime.datetime]:
|
||||
"""Convert a models.Timestamp to datetime.datetime, or None."""
|
||||
if ts is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime(ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ── DLE strip helper ──────────────────────────────────────────────────────────
|
||||
|
||||
def _strip_inner_frame_dles(data: bytes) -> bytes:
|
||||
"""
|
||||
Strip DLE (0x10) framing markers from A5 inner-frame content.
|
||||
|
||||
The A5 (bulk waveform stream) response body contains DLE-encoded sub-frame
|
||||
structure. S3FrameParser preserves DLE+XX pairs as two literal bytes in
|
||||
frame.data. Only the DLE marker byte needs to be removed; the following
|
||||
byte is actual payload content.
|
||||
|
||||
Rule: when 0x10 is immediately followed by {0x02, 0x03, 0x04}, strip the
|
||||
0x10 (DLE marker) and keep the following byte as payload.
|
||||
|
||||
Lone 0x10 bytes not followed by {0x02, 0x03, 0x04} are kept as-is.
|
||||
|
||||
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 ✓
|
||||
"""
|
||||
out = bytearray()
|
||||
i = 0
|
||||
while i < len(data):
|
||||
b = data[i]
|
||||
if b == 0x10 and i + 1 < len(data) and data[i + 1] in {0x02, 0x03, 0x04}:
|
||||
# Strip the DLE marker; the next byte is payload and will be appended
|
||||
# in the next loop iteration.
|
||||
i += 1
|
||||
continue
|
||||
out.append(b)
|
||||
i += 1
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _frame_body_bytes(frame: S3Frame, skip: int) -> bytes:
|
||||
"""
|
||||
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 (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
|
||||
(i.e., DLE is the last byte of raw_payload and XX is the checksum), the parser
|
||||
splits them:
|
||||
- DLE ends up as the last byte of frame.data (frame.data[-1] == 0x10)
|
||||
- XX is stored as frame.chk_byte
|
||||
|
||||
To strip the pair correctly, we reunite the bytes before calling the strip
|
||||
function. Since chk_byte is the checksum (not payload data), it is excluded
|
||||
from the final output regardless of whether it was part of a pair.
|
||||
|
||||
Post-strip chk_byte removal (ALL cases):
|
||||
_strip_inner_frame_dles strips the 0x10 and KEEPS chk_byte in all cases.
|
||||
Chk_byte is always the checksum (not payload), so always strip it off.
|
||||
|
||||
Args:
|
||||
frame: S3Frame with frame.data and frame.chk_byte populated.
|
||||
skip: Number of leading bytes in frame.data to exclude (frame header).
|
||||
|
||||
Returns:
|
||||
bytes — the waveform body contribution for this frame.
|
||||
"""
|
||||
if skip >= len(frame.data):
|
||||
return b""
|
||||
|
||||
relevant = frame.data[skip:]
|
||||
|
||||
# Detect split DLE+chk pair at the frame boundary.
|
||||
has_split_pair = (
|
||||
len(relevant) > 0
|
||||
and relevant[-1] == 0x10
|
||||
and frame.chk_byte in {0x02, 0x03, 0x04}
|
||||
)
|
||||
|
||||
if has_split_pair:
|
||||
# Reunite the split pair so the strip function sees both bytes together.
|
||||
buf = relevant + bytes([frame.chk_byte])
|
||||
stripped = _strip_inner_frame_dles(buf)
|
||||
# _strip_inner_frame_dles strips the DLE (0x10) and KEEPS chk_byte.
|
||||
# chk_byte is the received checksum — never payload — so remove it.
|
||||
# This is correct for all values in {0x02, 0x03, 0x04}.
|
||||
if stripped:
|
||||
stripped = stripped[:-1]
|
||||
return stripped
|
||||
else:
|
||||
return _strip_inner_frame_dles(relevant)
|
||||
|
||||
|
||||
# ── Filename helper ───────────────────────────────────────────────────────────
|
||||
|
||||
_INSTANTEL_EPOCH = datetime.datetime(1985, 1, 1, 0, 0, 0)
|
||||
"""
|
||||
Instantel timestamp epoch — January 1, 1985, 00:00:00 local time.
|
||||
Confirmed 2026-04-21: stem values for 6 independent events (April 1–9, 2026)
|
||||
all converge to this epoch when decoded as floor(seconds_since_epoch / 1296).
|
||||
1985 is the year Instantel was founded.
|
||||
"""
|
||||
|
||||
_STEM_UNIT_SEC = 1296 # = 36^2 seconds ≈ 21.6 minutes per stem unit
|
||||
|
||||
_STEM_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
# ── Waveform file extension encoding ─────────────────────────────────────────
|
||||
#
|
||||
# NEW FIRMWARE (V10.72+) — FULLY DECODED (confirmed 2026-04-21, 10-year archive):
|
||||
#
|
||||
# Extension format: AB0T (4 characters)
|
||||
# AB = 2-char base-36 encoding of (seconds_since_epoch % 1296)
|
||||
# i.e. the number of seconds into the current 21.6-minute stem window
|
||||
# Range: 0 ("00") to 1295 ("ZZ")
|
||||
# 0 = always literal '0'
|
||||
# T = event type: 'W' = Full Waveform, 'H' = Full Histogram
|
||||
#
|
||||
# Combined with the 4-char stem (which encodes seconds_since_epoch // 1296),
|
||||
# the FULL filename gives a second-resolution timestamp:
|
||||
# total_seconds = stem_val * 1296 + ab_val
|
||||
# timestamp = EPOCH + timedelta(seconds=total_seconds)
|
||||
#
|
||||
# Verified against three S353L4H0 events (all three match to the second):
|
||||
# S353L4H0.3M0W Full Waveform 2025-06-23 13:57:22 AB=3M=130 ✓
|
||||
# S353L4H0.8S0H Full Histogram 2025-06-23 14:00:28 AB=8S=316 ✓
|
||||
# S353L4H0.9X0W Full Waveform 2025-06-23 14:01:09 AB=9X=357 ✓
|
||||
#
|
||||
# OLD FIRMWARE (S338, 3-char extensions ending in '0') — UNKNOWN:
|
||||
# Observed (old firmware / manual downloads): .440, .470, .7M0, .9T0, .EI0, 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() 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.
|
||||
# Refuted by continuous-mode event producing .EI0 instead of .9T0.
|
||||
|
||||
|
||||
def _make_stem(ts_local: datetime.datetime) -> str:
|
||||
"""
|
||||
Encode a local timestamp as a 4-character uppercase base-36 stem.
|
||||
|
||||
Algorithm (confirmed 2026-04-21 from 6 known file/timestamp pairs):
|
||||
stem_int = floor((ts_local - Jan_1_1985_midnight_local) / 1296_seconds)
|
||||
stem = 4-char uppercase base-36 encoding of stem_int
|
||||
|
||||
Unit = 36² = 1296 seconds ≈ 21.6 minutes. Events within the same 1296-second
|
||||
window receive the same stem; their extension distinguishes them.
|
||||
"""
|
||||
delta_sec = int((ts_local - _INSTANTEL_EPOCH).total_seconds())
|
||||
n = delta_sec // _STEM_UNIT_SEC
|
||||
s = ""
|
||||
for _ in range(4):
|
||||
s = _STEM_CHARS[n % 36] + s
|
||||
n //= 36
|
||||
return s
|
||||
|
||||
|
||||
def blastware_filename(event: Event, serial: str, ach: bool = False) -> str:
|
||||
"""
|
||||
Return the correct Blastware filename for an event.
|
||||
|
||||
CONFIRMED 2026-04-22 — verified against 3,248 files from a 10-year archive.
|
||||
|
||||
Filename format: <prefix_letter><serial3><stem><AB>0[T]
|
||||
where:
|
||||
|
||||
prefix_letter = chr(ord('B') + floor(serial_numeric / 1000))
|
||||
— encodes the production generation (batch of 1000 units)
|
||||
— e.g. BE6907→H, BE11529→M, BE14036→P, BE18003→T
|
||||
|
||||
serial3 = f"{serial_numeric % 1000:03d}"
|
||||
— last 3 digits of numeric serial, zero-padded
|
||||
|
||||
stem = 4-char base-36 of floor(total_seconds / 1296)
|
||||
— encodes which 21.6-minute window the event fell in
|
||||
|
||||
AB = 2-char base-36 of (total_seconds % 1296)
|
||||
— encodes seconds within the window (0–1295)
|
||||
|
||||
0 = always literal digit zero
|
||||
|
||||
T = 'W' or 'H' — ONLY appended for call-home (ACH) downloads (ach=True).
|
||||
Manual / direct downloads produce a 3-char extension (AB0) with no type char.
|
||||
Call-home downloads produce a 4-char extension (AB0W or AB0H).
|
||||
|
||||
total_seconds = (event_local_time − 1985-01-01T00:00:00_local) in seconds
|
||||
|
||||
The 10-year production archive contains only call-home files (all end in W or H).
|
||||
Manual Blastware downloads produce 3-char extensions — the same AB0 prefix but
|
||||
without the trailing type character.
|
||||
|
||||
Micromate Series 4 uses a completely different naming scheme (literal datetime
|
||||
in filename); this function does not apply to Micromate units.
|
||||
|
||||
Args:
|
||||
event: Event object with timestamp set.
|
||||
serial: Device serial number string (e.g. "BE11529").
|
||||
ach: If True, append W/H type character (call-home style).
|
||||
If False (default), omit type character (direct download style).
|
||||
|
||||
Returns:
|
||||
Filename string, e.g. "M529LIY6.CE0" (direct) or "M529LIY6.CE0H" (ACH).
|
||||
"""
|
||||
# ── Serial prefix ──────────────────────────────────────────────────────────
|
||||
serial_digits = "".join(c for c in serial if c.isdigit())
|
||||
if len(serial_digits) >= 1:
|
||||
serial_numeric = int(serial_digits)
|
||||
generation = serial_numeric // 1000
|
||||
prefix_letter = chr(ord('B') + generation)
|
||||
serial3 = f"{serial_numeric % 1000:03d}"
|
||||
else:
|
||||
prefix_letter = "M" # fallback
|
||||
serial3 = "000"
|
||||
prefix = prefix_letter + serial3
|
||||
|
||||
# ── Stem + AB extension from timestamp ────────────────────────────────────
|
||||
if event.timestamp is not None:
|
||||
try:
|
||||
ts_local = datetime.datetime(
|
||||
event.timestamp.year, event.timestamp.month, event.timestamp.day,
|
||||
event.timestamp.hour, event.timestamp.minute, event.timestamp.second,
|
||||
)
|
||||
delta_sec = int((ts_local - _INSTANTEL_EPOCH).total_seconds())
|
||||
stem = _make_stem(ts_local)
|
||||
ab_val = delta_sec % _STEM_UNIT_SEC
|
||||
ab_str = _STEM_CHARS[ab_val // 36] + _STEM_CHARS[ab_val % 36]
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
stem = "0000"
|
||||
ab_str = "00"
|
||||
else:
|
||||
stem = "0000"
|
||||
ab_str = "00"
|
||||
|
||||
# ── Type character (ACH only) ─────────────────────────────────────────────
|
||||
if ach:
|
||||
if getattr(event, 'recording_mode', None) in (3, 4): # Histogram / Hist+Cont
|
||||
type_char = 'H'
|
||||
else:
|
||||
type_char = 'W'
|
||||
ext = f".{ab_str}0{type_char}"
|
||||
else:
|
||||
ext = f".{ab_str}0"
|
||||
|
||||
return prefix + stem + ext
|
||||
|
||||
|
||||
# ── A5 frame classifier ───────────────────────────────────────────────────────────
|
||||
|
||||
# ASCII markers that identify a compliance-config / metadata frame.
|
||||
# These strings appear in the A5 bulk stream as part of the device's
|
||||
# compliance setup payload. They should NEVER appear in raw ADC waveform
|
||||
# frames (which are binary-heavy, < 20 % printable ASCII).
|
||||
_METADATA_FRAME_MARKERS = (
|
||||
b"Project:",
|
||||
b"Client:",
|
||||
b"Standard Recording Setup",
|
||||
b"Extended Notes",
|
||||
b"User Name:",
|
||||
b"Seis Loc:",
|
||||
)
|
||||
|
||||
|
||||
def classify_frame(frame: S3Frame) -> str:
|
||||
"""
|
||||
Classify an A5 bulk waveform stream frame by its content.
|
||||
|
||||
Returns one of:
|
||||
"terminator" — page_key == 0x0000
|
||||
"probe_or_strt" — data contains b"STRT\xff\xfe" (the initial probe response)
|
||||
"metadata" — data contains ASCII compliance-config markers
|
||||
"waveform" — predominantly binary (< 20 % printable ASCII)
|
||||
"unknown" — none of the above criteria matched
|
||||
|
||||
Used by write_blastware_file() to filter non-waveform frames out of
|
||||
the reconstructed body so that metadata blocks (Project:, Client:, …)
|
||||
and spurious STRT records do not corrupt the output file.
|
||||
"""
|
||||
if frame.page_key == 0x0000:
|
||||
return "terminator"
|
||||
data = bytes(frame.data)
|
||||
if b"STRT\xff\xfe" in data:
|
||||
return "probe_or_strt"
|
||||
if any(m in data for m in _METADATA_FRAME_MARKERS):
|
||||
return "metadata"
|
||||
if len(data) > 0:
|
||||
printable = sum(1 for b in data if 32 <= b < 127)
|
||||
if printable / len(data) < 0.20:
|
||||
return "waveform"
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ── Waveform file writer ───────────────────────────────────────────────────────────
|
||||
|
||||
def write_blastware_file(
|
||||
event: Event,
|
||||
a5_frames: list[S3Frame],
|
||||
path: Union[str, Path],
|
||||
) -> None:
|
||||
"""
|
||||
Write a Blastware waveform file from a downloaded event.
|
||||
|
||||
Args:
|
||||
event: Event object (populated by get_events() or download_waveform()).
|
||||
Used for the STRT record (key, rectime) and footer timestamps.
|
||||
a5_frames: Complete A5 frame list INCLUDING the terminator frame
|
||||
(page_key=0x0000). Pass include_terminator=True to
|
||||
read_bulk_waveform_stream() when collecting frames.
|
||||
Must have at least 2 frames (probe + terminator).
|
||||
path: Destination file path. Parent directory must exist.
|
||||
Extension should be set via blastware_filename().
|
||||
|
||||
File layout:
|
||||
[22B header] [21B STRT] [body bytes] [26B footer]
|
||||
|
||||
Raises:
|
||||
ValueError: if a5_frames is empty or has no terminator (page_key=0).
|
||||
OSError: if the file cannot be written.
|
||||
|
||||
Confirmed correct waveform body reconstruction against M529LIY6 (example event) (2026-04-21).
|
||||
"""
|
||||
if not a5_frames:
|
||||
raise ValueError("a5_frames must not be empty")
|
||||
|
||||
path = Path(path)
|
||||
|
||||
# ── 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 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 files):
|
||||
# [0:4] b'STRT'
|
||||
# [4:6] 0xff 0xfe (fixed)
|
||||
# [6:10] key4 (event key)
|
||||
# [10:14] device-specific field (NOT a key4 repeat)
|
||||
# [14:20] device-specific fields (NOT zeros)
|
||||
# [20] rectime uint8 seconds
|
||||
# Extract STRT from the DLE-stripped probe frame.
|
||||
#
|
||||
# frame.data[7:] is the raw wire representation; it may contain DLE+{02,03,04}
|
||||
# inner-frame pairs that S3FrameParser preserves as two literal bytes. The
|
||||
# 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_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.
|
||||
#
|
||||
# probe_skip must be computed in the RAW frame.data domain (it is used as the
|
||||
# `skip` argument to _frame_body_bytes which operates on raw frame.data).
|
||||
# We walk the raw bytes counting stripped bytes until we have passed
|
||||
# strt_pos + 21 stripped bytes, giving the raw offset of the first body byte.
|
||||
w0_raw = bytes(a5_frames[0].data[7:])
|
||||
w0_stripped = _strip_inner_frame_dles(w0_raw)
|
||||
strt_pos_stripped = w0_stripped.find(b"STRT")
|
||||
|
||||
if strt_pos_stripped >= 0:
|
||||
strt = bytes(w0_stripped[strt_pos_stripped : strt_pos_stripped + 21])
|
||||
|
||||
# Walk raw bytes to find the raw-domain end of the STRT (= body start).
|
||||
target_stripped = strt_pos_stripped + 21
|
||||
stripped_so_far = 0
|
||||
raw_i = 0
|
||||
while stripped_so_far < target_stripped and raw_i < len(w0_raw):
|
||||
if (w0_raw[raw_i] == 0x10
|
||||
and raw_i + 1 < len(w0_raw)
|
||||
and w0_raw[raw_i + 1] in {0x02, 0x03, 0x04}):
|
||||
raw_i += 2 # DLE pair → 1 stripped byte, 2 raw bytes
|
||||
else:
|
||||
raw_i += 1 # normal byte → 1 stripped byte, 1 raw byte
|
||||
stripped_so_far += 1
|
||||
probe_skip = 7 + raw_i # raw bytes to skip: 7 header + raw STRT length
|
||||
else:
|
||||
# Fallback: construct a minimal STRT if probe frame lacks it
|
||||
key4 = event._waveform_key if hasattr(event, '_waveform_key') and event._waveform_key else bytes(4)
|
||||
rectime = event.rectime_seconds if event.rectime_seconds is not None else 0
|
||||
strt = b"STRT" + b"\xff\xfe" + key4 + bytes(14) + bytes([rectime & 0xFF])
|
||||
probe_skip = 7 + 21
|
||||
|
||||
log.warning(
|
||||
"write_blastware_file: strt_pos_stripped=%d probe_skip=%d "
|
||||
"probe_data_len=%d strt_hex=%s",
|
||||
strt_pos_stripped if strt_pos_stripped >= 0 else -1,
|
||||
probe_skip,
|
||||
len(a5_frames[0].data),
|
||||
strt.hex() if len(strt) >= 4 else "(short)",
|
||||
)
|
||||
|
||||
if len(strt) != 21:
|
||||
raise ValueError(f"STRT record must be 21 bytes, got {len(strt)}")
|
||||
|
||||
# ── 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 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 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 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.
|
||||
# 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
|
||||
|
||||
log.warning(
|
||||
"write_blastware_file: %d body_frames term_idx=%s",
|
||||
len(body_frames),
|
||||
str(term_idx) if term_idx is not None else "None",
|
||||
)
|
||||
|
||||
all_bytes = bytearray()
|
||||
|
||||
for fi, frame in enumerate(body_frames):
|
||||
# All body frames contribute to the waveform body — no frames are skipped.
|
||||
#
|
||||
# Over TCP via cellular modem, _recv_5a_batch() correctly collects all
|
||||
# A5 frames per chunk request (the device's ~1100-byte RS-232 response
|
||||
# is forwarded as ~2 TCP segments of ~550 bytes each, each parsed as a
|
||||
# separate S3 frame). ALL of these frames contain ADC body data and
|
||||
# must be included in the file — confirmed from 4-27-26 TCP capture
|
||||
# analysis: contributions from all 14 frames → 6821 bytes → file 6864 bytes.
|
||||
#
|
||||
# Skip amounts (offsets into frame.data):
|
||||
# fi=0 (probe): probe_skip — skips the type_tag header + STRT record
|
||||
# fi=1: 13 — 7-byte frame.data prefix + 6 inner header bytes
|
||||
# fi>=2: 12 — 7-byte frame.data prefix + 5 inner header bytes
|
||||
if fi == 0:
|
||||
skip = probe_skip
|
||||
elif fi == 1:
|
||||
skip = 13
|
||||
else:
|
||||
skip = 12
|
||||
|
||||
contribution = _frame_body_bytes(frame, skip)
|
||||
log.warning("write_blastware_file: fi=%d skip=%d raw_data=%d contribution=%d",
|
||||
fi, skip, len(frame.data), len(contribution))
|
||||
all_bytes.extend(contribution)
|
||||
|
||||
# Terminator contributes its content, which ends with the 26-byte footer.
|
||||
# skip=11 (not 12) because the terminator's inner frame header is 4 bytes,
|
||||
# one shorter than chunk frames' 5-byte inner header. Confirmed 2026-04-21.
|
||||
if term_frame is not None:
|
||||
term_contribution = _frame_body_bytes(term_frame, 11)
|
||||
log.warning(
|
||||
"write_blastware_file: term_frame data_len=%d skip=11 "
|
||||
"contribution_len=%d first8=%s",
|
||||
len(term_frame.data),
|
||||
len(term_contribution),
|
||||
term_contribution[:8].hex() if len(term_contribution) >= 8 else term_contribution.hex(),
|
||||
)
|
||||
all_bytes.extend(term_contribution)
|
||||
|
||||
log.warning(
|
||||
"write_blastware_file: all_bytes total=%d last28=%s",
|
||||
len(all_bytes),
|
||||
bytes(all_bytes[-28:]).hex() if len(all_bytes) >= 28 else bytes(all_bytes).hex(),
|
||||
)
|
||||
|
||||
if len(all_bytes) >= 26:
|
||||
body = bytes(all_bytes[:-26])
|
||||
footer = bytes(all_bytes[-26:])
|
||||
else:
|
||||
# Fallback: no terminator or very short stream → build footer from event metadata
|
||||
body = bytes(all_bytes)
|
||||
start_dt = _ts_from_model(event.timestamp)
|
||||
stop_dt: Optional[datetime.datetime] = None
|
||||
if start_dt is not None and event.rectime_seconds:
|
||||
stop_dt = start_dt + datetime.timedelta(seconds=event.rectime_seconds)
|
||||
footer = (
|
||||
b"\x0e\x08"
|
||||
+ _encode_ts_be(start_dt)
|
||||
+ _encode_ts_be(stop_dt)
|
||||
+ b"\x00\x01\x00\x02\x00\x00"
|
||||
+ b"\x00\x00" # CRC placeholder
|
||||
)
|
||||
|
||||
# ── Write file ───────────────────────────────────────────────────────────
|
||||
with open(path, "wb") as f:
|
||||
f.write(header)
|
||||
f.write(strt)
|
||||
f.write(body)
|
||||
f.write(footer)
|
||||
|
||||
|
||||
def read_blastware_file(path: Union[str, Path]) -> Event:
|
||||
"""
|
||||
Parse a Blastware waveform file into an Event object.
|
||||
|
||||
NOT YET IMPLEMENTED.
|
||||
|
||||
Args:
|
||||
path: Path to the waveform file.
|
||||
|
||||
Returns:
|
||||
Event object with waveform data populated.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: always (pending implementation).
|
||||
"""
|
||||
raise NotImplementedError("read_blastware_file() is not yet implemented")
|
||||
|
||||
|
||||
# ── MLG file writer ───────────────────────────────────────────────────────────
|
||||
|
||||
def _build_mlg_header(serial: str) -> bytes:
|
||||
"""
|
||||
Build the 308-byte MLG file header.
|
||||
|
||||
Header structure (confirmed from BE11529.MLG binary inspection):
|
||||
Offset 0x00: 10 00 01 80 00 00 Instantel\x00 07 2c 22 01 0e a0 (22B)
|
||||
Offset 0x16: ... (16B unknown — observed as zeros in BE11529.MLG)
|
||||
Offset 0x2A: serial number (8 bytes, null-padded ASCII)
|
||||
... rest zero-padded to 308 bytes
|
||||
|
||||
The serial string "BE11529" appears at offset 0x2A (42 decimal).
|
||||
"""
|
||||
buf = bytearray(_MLG_HEADER_SIZE)
|
||||
|
||||
# Common prefix + MLG type tag
|
||||
prefix = _FILE_HEADER_PREFIX + _MLG_TYPE_TAG # 22 bytes
|
||||
buf[0:len(prefix)] = prefix
|
||||
|
||||
# Serial number at offset 0x2A
|
||||
serial_bytes = serial.encode("ascii", errors="replace")[:8]
|
||||
serial_padded = serial_bytes.ljust(8, b"\x00")
|
||||
buf[0x2A : 0x2A + 8] = serial_padded
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def _build_mlg_record(
|
||||
entry: MonitorLogEntry,
|
||||
serial: str,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build one 292-byte MLG record from a MonitorLogEntry.
|
||||
|
||||
Record layout (confirmed from BE11529.MLG binary inspection):
|
||||
[0:2] CRC — 2-byte CRC (algorithm unknown; written as 0x0000)
|
||||
[2:6] marker — 22 01 0e 80
|
||||
[6:14] ts1 — 8B big-endian start timestamp
|
||||
[14:22] ts2 — 8B big-endian stop timestamp
|
||||
[22:26] flags — 4B record flags (see MLG_FLAGS_* constants)
|
||||
[26:36] serial — 10B null-padded serial number
|
||||
[36:] text — for triggered events: [0x08][8B ts1_copy]["Geo: X.XXX in/s"]
|
||||
for monitoring intervals: b"" or minimal separator
|
||||
[... zero-padded to 292 bytes]
|
||||
|
||||
Flags based on entry type:
|
||||
- MonitorLogEntry with start_time only (no stop_time): MLG_FLAGS_START_ONLY
|
||||
- MonitorLogEntry with both times and geo_threshold_ips set: MLG_FLAGS_TRIGGER
|
||||
- MonitorLogEntry with both times (monitoring interval): MLG_FLAGS_INTERVAL
|
||||
|
||||
The triggered-event text block (flags = MLG_FLAGS_TRIGGER):
|
||||
[0x08] [ts1: 8B] [ASCII "Geo: X.XXX in/s\x00"]
|
||||
Confirmed from BE11529.MLG records at offset 0x0134 and 0x0258.
|
||||
"""
|
||||
buf = bytearray(_MLG_RECORD_SIZE)
|
||||
|
||||
start_dt = (
|
||||
datetime.datetime(
|
||||
entry.start_time.year, entry.start_time.month, entry.start_time.day,
|
||||
entry.start_time.hour, entry.start_time.minute, entry.start_time.second,
|
||||
)
|
||||
if entry.start_time else None
|
||||
)
|
||||
stop_dt = (
|
||||
datetime.datetime(
|
||||
entry.stop_time.year, entry.stop_time.month, entry.stop_time.day,
|
||||
entry.stop_time.hour, entry.stop_time.minute, entry.stop_time.second,
|
||||
)
|
||||
if entry.stop_time else None
|
||||
)
|
||||
|
||||
# [0:2] CRC placeholder
|
||||
buf[0:2] = b"\x00\x00"
|
||||
|
||||
# [2:6] Record marker
|
||||
buf[2:6] = _MLG_RECORD_MARKER
|
||||
|
||||
# [6:14] ts1
|
||||
buf[6:14] = _encode_ts_be(start_dt)
|
||||
|
||||
# [14:22] ts2
|
||||
buf[14:22] = _encode_ts_be(stop_dt)
|
||||
|
||||
# [22:26] flags
|
||||
if stop_dt is None:
|
||||
flags = MLG_FLAGS_START_ONLY
|
||||
elif entry.geo_threshold_ips is not None:
|
||||
flags = MLG_FLAGS_TRIGGER
|
||||
else:
|
||||
flags = MLG_FLAGS_INTERVAL
|
||||
buf[22:26] = flags
|
||||
|
||||
# [26:36] serial (10B null-padded)
|
||||
serial_bytes = serial.encode("ascii", errors="replace")[:10]
|
||||
buf[26 : 26 + len(serial_bytes)] = serial_bytes
|
||||
|
||||
# [36:] text content
|
||||
pos = 36
|
||||
if flags == MLG_FLAGS_TRIGGER:
|
||||
# Extra ts1 copy: [0x08][ts1: 8B]
|
||||
buf[pos] = 0x08
|
||||
pos += 1
|
||||
buf[pos : pos + 8] = _encode_ts_be(start_dt)
|
||||
pos += 8
|
||||
|
||||
if entry.geo_threshold_ips is not None:
|
||||
geo_text = f"Geo: {entry.geo_threshold_ips:.3f} in/s\x00".encode("ascii")
|
||||
buf[pos : pos + len(geo_text)] = geo_text
|
||||
pos += len(geo_text)
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def write_mlg(
|
||||
entries: list[MonitorLogEntry],
|
||||
serial: str,
|
||||
path: Union[str, Path],
|
||||
) -> None:
|
||||
"""
|
||||
Write a Blastware .MLG monitor log file.
|
||||
|
||||
Args:
|
||||
entries: List of MonitorLogEntry objects (from get_monitor_log_entries()).
|
||||
Each entry produces one 292-byte record in the file.
|
||||
serial: Device serial number string (e.g. "BE11529").
|
||||
Written to the file header and each record.
|
||||
path: Destination file path. Extension is not enforced — use ".MLG".
|
||||
|
||||
File layout:
|
||||
[308B header] [N × 292B records]
|
||||
|
||||
Note: The 2-byte CRC at the start of each record is written as 0x0000.
|
||||
The CRC algorithm is unknown (see module docstring).
|
||||
|
||||
Raises:
|
||||
OSError: if the file cannot be written.
|
||||
"""
|
||||
path = Path(path)
|
||||
header = _build_mlg_header(serial)
|
||||
|
||||
with open(path, "wb") as f:
|
||||
f.write(header)
|
||||
for entry in entries:
|
||||
record = _build_mlg_record(entry, serial)
|
||||
f.write(record)
|
||||
|
||||
|
||||
def read_mlg(path: Union[str, Path]) -> list[MonitorLogEntry]:
|
||||
"""
|
||||
Parse a Blastware .MLG file into a list of MonitorLogEntry objects.
|
||||
|
||||
NOT YET IMPLEMENTED.
|
||||
|
||||
Args:
|
||||
path: Path to the .MLG file.
|
||||
|
||||
Returns:
|
||||
List of MonitorLogEntry objects.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: always (pending implementation).
|
||||
"""
|
||||
raise NotImplementedError("read_mlg() is not yet implemented")
|
||||
+506
-67
@@ -35,6 +35,7 @@ from typing import Optional
|
||||
|
||||
from .framing import S3Frame
|
||||
from .models import (
|
||||
CallHomeConfig,
|
||||
ComplianceConfig,
|
||||
DeviceInfo,
|
||||
Event,
|
||||
@@ -448,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.
|
||||
@@ -603,10 +604,12 @@ class MiniMateClient:
|
||||
"get_events: 5A full waveform download for key=%s", cur_key.hex()
|
||||
)
|
||||
a5_frames = proto.read_bulk_waveform_stream(
|
||||
cur_key, stop_after_metadata=False, max_chunks=128
|
||||
cur_key, stop_after_metadata=False, max_chunks=128,
|
||||
include_terminator=True,
|
||||
)
|
||||
if a5_frames:
|
||||
a5_ok = True
|
||||
ev._a5_frames = a5_frames # store for write_blastware_file
|
||||
_decode_a5_metadata_into(a5_frames, ev)
|
||||
_decode_a5_waveform(a5_frames, ev)
|
||||
log.info(
|
||||
@@ -618,10 +621,14 @@ class MiniMateClient:
|
||||
"get_events: 5A metadata-only download for key=%s", cur_key.hex()
|
||||
)
|
||||
a5_frames = proto.read_bulk_waveform_stream(
|
||||
cur_key, stop_after_metadata=True
|
||||
cur_key, stop_after_metadata=True,
|
||||
include_terminator=True,
|
||||
extra_chunks_after_metadata=extra_chunks_after_metadata,
|
||||
max_chunks=128,
|
||||
)
|
||||
if a5_frames:
|
||||
a5_ok = True
|
||||
ev._a5_frames = a5_frames # store for write_blastware_file
|
||||
_decode_a5_metadata_into(a5_frames, ev)
|
||||
log.debug(
|
||||
"get_events: 5A metadata client=%r operator=%r",
|
||||
@@ -775,6 +782,39 @@ class MiniMateClient:
|
||||
else:
|
||||
log.warning("download_waveform: waveform decode produced no samples")
|
||||
|
||||
return a5_frames
|
||||
|
||||
def save_blastware_file(self, event: "Event", path: "Union[str, Path]", serial: str) -> None:
|
||||
"""
|
||||
Download the full waveform for *event* and save it as a Blastware-
|
||||
compatible Blastware waveform file at *path*.
|
||||
|
||||
This is a convenience wrapper that calls download_waveform() (which
|
||||
performs the complete SUB 5A BULK_WAVEFORM_STREAM download) and then
|
||||
calls write_blastware_file() from blastware_file.py to encode the result.
|
||||
|
||||
Args:
|
||||
event: Event object with waveform key populated (from get_events()).
|
||||
path: Destination file path. Caller should use blastware_filename()
|
||||
to pick the correct extension via blastware_filename().
|
||||
serial: Device serial number (e.g. "BE11529") — passed to
|
||||
blastware_filename() for reference, but the caller supplies
|
||||
the final path.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
from .blastware_file import write_blastware_file as _write_blastware_file
|
||||
|
||||
a5_frames = self.download_waveform(event)
|
||||
if not a5_frames:
|
||||
raise RuntimeError(
|
||||
f"save_blastware_file: no A5 frames received for event#{event.index}"
|
||||
)
|
||||
_write_blastware_file(event, a5_frames, path)
|
||||
log.info(
|
||||
"save_blastware_file: wrote %s (%d A5 frames)",
|
||||
path, len(a5_frames),
|
||||
)
|
||||
|
||||
# ── Write commands ────────────────────────────────────────────────────────
|
||||
|
||||
def push_config_raw(
|
||||
@@ -847,12 +887,14 @@ class MiniMateClient:
|
||||
self,
|
||||
*,
|
||||
# Recording parameters
|
||||
sample_rate: Optional[int] = None,
|
||||
record_time: Optional[float] = None,
|
||||
recording_mode: Optional[int] = None,
|
||||
sample_rate: Optional[int] = None,
|
||||
record_time: Optional[float] = None,
|
||||
histogram_interval_sec: Optional[int] = None,
|
||||
# Threshold parameters (geo channels, in/s)
|
||||
trigger_level_geo: Optional[float] = None,
|
||||
alarm_level_geo: Optional[float] = None,
|
||||
max_range_geo: Optional[float] = None,
|
||||
geo_range: Optional[int] = None, # 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
|
||||
# Project / operator strings
|
||||
project: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
@@ -870,14 +912,15 @@ class MiniMateClient:
|
||||
Configurable fields
|
||||
-------------------
|
||||
Recording parameters:
|
||||
recording_mode : int — 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
|
||||
sample_rate : int — samples/sec; valid values: 1024, 2048, 4096
|
||||
record_time : float — record duration in seconds (e.g. 2.0, 3.0)
|
||||
|
||||
Trigger/alarm thresholds (geo channels, in/s):
|
||||
trigger_level_geo : float — trigger threshold (e.g. 0.5)
|
||||
alarm_level_geo : float — alarm threshold (e.g. 1.0)
|
||||
max_range_geo : float — full-scale calibration constant (e.g. 6.206)
|
||||
rarely changed — only set if you know what you're doing
|
||||
Trigger/alarm thresholds and range (geo channels):
|
||||
trigger_level_geo : float — trigger threshold in/s (e.g. 0.5)
|
||||
alarm_level_geo : float — alarm threshold in/s (e.g. 1.0)
|
||||
geo_range : int — 0x00=Normal 10.000 in/s, 0x01=Sensitive 1.250 in/s
|
||||
(written to Tran/Vert/Long channel blocks)
|
||||
|
||||
Project / operator strings (max 41 ASCII characters each):
|
||||
project : str
|
||||
@@ -891,14 +934,14 @@ class MiniMateClient:
|
||||
|
||||
Write payloads:
|
||||
event_index_data : 88 bytes — read live via SUB 08
|
||||
compliance_data : 2128 bytes — read live via SUB 1A (2126 bytes) + \\x00\\x00 footer
|
||||
compliance_data : ~2128 bytes — read live via SUB 1A (~2126 bytes, varies ±1-2) + \\x00\\x00 footer
|
||||
trigger_data : 29 bytes — hardcoded from 3-11-26 capture
|
||||
waveform_data : 204 bytes — read live via SUB 09
|
||||
|
||||
Raises:
|
||||
RuntimeError: if not connected.
|
||||
ProtocolError: if any read or write step fails.
|
||||
ValueError: if compliance buffer is not the expected 2126 bytes.
|
||||
ValueError: if compliance buffer is shorter than the 2082-byte write minimum.
|
||||
"""
|
||||
proto = self._require_proto()
|
||||
|
||||
@@ -907,7 +950,7 @@ class MiniMateClient:
|
||||
event_index_data = proto.read_event_index()
|
||||
|
||||
log.info("apply_config: reading compliance config (SUB 1A)")
|
||||
compliance_raw = proto.read_compliance_config() # 2126 bytes
|
||||
compliance_raw = proto.read_compliance_config() # ~2126 bytes (varies ±1-2 by DLE jitter)
|
||||
|
||||
log.info("apply_config: reading waveform data (SUB 09)")
|
||||
waveform_data = proto.read_waveform_data_raw() # 204 bytes
|
||||
@@ -917,11 +960,13 @@ class MiniMateClient:
|
||||
# 2. Patch the compliance buffer and build the 2128-byte write payload
|
||||
compliance_data = _encode_compliance_config(
|
||||
compliance_raw,
|
||||
recording_mode=recording_mode,
|
||||
sample_rate=sample_rate,
|
||||
record_time=record_time,
|
||||
histogram_interval_sec=histogram_interval_sec,
|
||||
trigger_level_geo=trigger_level_geo,
|
||||
alarm_level_geo=alarm_level_geo,
|
||||
max_range_geo=max_range_geo,
|
||||
geo_range=geo_range,
|
||||
project=project,
|
||||
client_name=client_name,
|
||||
operator=operator,
|
||||
@@ -951,6 +996,93 @@ class MiniMateClient:
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
# ── Call home config ──────────────────────────────────────────────────────
|
||||
|
||||
def get_call_home_config(self) -> CallHomeConfig:
|
||||
"""
|
||||
Read the auto call home (ACH) configuration from the device.
|
||||
|
||||
Sends SUB 0x2C (two-step read) and decodes the raw 125-byte payload
|
||||
into a CallHomeConfig object.
|
||||
|
||||
Returns:
|
||||
CallHomeConfig with all confirmed fields populated.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if not connected.
|
||||
ProtocolError: on timeout or wrong response SUB.
|
||||
"""
|
||||
proto = self._require_proto()
|
||||
raw = proto.read_call_home_config()
|
||||
return _decode_call_home_config(raw)
|
||||
|
||||
def set_call_home_config(
|
||||
self,
|
||||
*,
|
||||
auto_call_home_enabled: Optional[bool] = None,
|
||||
after_event_recorded: Optional[bool] = None,
|
||||
at_specified_times: Optional[bool] = None,
|
||||
time1_enabled: Optional[bool] = None,
|
||||
time1_hour: Optional[int] = None,
|
||||
time1_min: Optional[int] = None,
|
||||
time2_enabled: Optional[bool] = None,
|
||||
time2_hour: Optional[int] = None,
|
||||
time2_min: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Read the current call home config, apply any supplied changes, and
|
||||
write the updated config back to the device.
|
||||
|
||||
Only non-None arguments are modified. All other bytes are round-tripped
|
||||
verbatim from the device.
|
||||
|
||||
Configurable fields
|
||||
-------------------
|
||||
auto_call_home_enabled : bool — master enable for ACH
|
||||
after_event_recorded : bool — call home after each triggered event
|
||||
at_specified_times : bool — call home at scheduled times
|
||||
time1_enabled : bool — enable time slot 1
|
||||
time1_hour : int — hour for time slot 1 (0-23)
|
||||
time1_min : int — minute for time slot 1 (0-59)
|
||||
time2_enabled : bool — enable time slot 2
|
||||
time2_hour : int — hour for time slot 2 (0-23)
|
||||
time2_min : int — minute for time slot 2 (0-59)
|
||||
|
||||
Write sequence (confirmed from 4-20-26 call home settings captures):
|
||||
SUB 0x2C (read, 2-step) → 125-byte raw payload
|
||||
patch fields in-place
|
||||
SUB 0x7E (write, 127-byte payload) → ack 0x81
|
||||
SUB 0x7F (confirm) → ack 0x80
|
||||
|
||||
Raises:
|
||||
RuntimeError: if not connected.
|
||||
ProtocolError: if any read or write step fails.
|
||||
"""
|
||||
proto = self._require_proto()
|
||||
|
||||
# 1. Read current config
|
||||
log.info("set_call_home_config: reading current config (SUB 0x2C)")
|
||||
raw = proto.read_call_home_config()
|
||||
|
||||
# 2. Patch fields and build write payload
|
||||
write_data = _encode_call_home_config(
|
||||
raw,
|
||||
auto_call_home_enabled=auto_call_home_enabled,
|
||||
after_event_recorded=after_event_recorded,
|
||||
at_specified_times=at_specified_times,
|
||||
time1_enabled=time1_enabled,
|
||||
time1_hour=time1_hour,
|
||||
time1_min=time1_min,
|
||||
time2_enabled=time2_enabled,
|
||||
time2_hour=time2_hour,
|
||||
time2_min=time2_min,
|
||||
)
|
||||
|
||||
# 3. Write back
|
||||
log.info("set_call_home_config: writing updated config (SUB 0x7E + 0x7F)")
|
||||
proto.write_call_home_config(write_data)
|
||||
log.info("set_call_home_config: complete")
|
||||
|
||||
def poll(self) -> None:
|
||||
"""
|
||||
Perform just the POLL startup handshake — no config reads.
|
||||
@@ -1231,7 +1363,7 @@ def _decode_waveform_record_into(data: bytes, event: Event) -> None:
|
||||
log.warning("waveform record project strings decode failed: %s", exc)
|
||||
|
||||
|
||||
def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None:
|
||||
def _decode_a5_metadata_into(frames_data: list[S3Frame], event: Event) -> None:
|
||||
"""
|
||||
Search A5 (BULK_WAVEFORM_STREAM) frame data for event-time metadata strings
|
||||
and populate event.project_info.
|
||||
@@ -1259,7 +1391,7 @@ def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None:
|
||||
|
||||
Modifies event in-place.
|
||||
"""
|
||||
combined = b"".join(frames_data)
|
||||
combined = b"".join(f.data for f in frames_data)
|
||||
|
||||
def _find_string_after(needle: bytes, max_len: int = 64) -> Optional[str]:
|
||||
pos = combined.find(needle)
|
||||
@@ -1283,7 +1415,7 @@ def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None:
|
||||
notes = _find_string_after(b"Extended Notes")
|
||||
|
||||
if not any([project, client, operator, location, notes]):
|
||||
log.debug("a5 metadata: no project strings found in %d frames", len(frames_data))
|
||||
log.debug("a5 metadata: no project strings found in %d frames (%d bytes)", len(frames_data), len(combined))
|
||||
return
|
||||
|
||||
if event.project_info is None:
|
||||
@@ -1309,7 +1441,7 @@ def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None:
|
||||
|
||||
|
||||
def _decode_a5_waveform(
|
||||
frames_data: list[bytes],
|
||||
frames_data: list[S3Frame],
|
||||
event: Event,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -1370,7 +1502,7 @@ def _decode_a5_waveform(
|
||||
return
|
||||
|
||||
# ── Parse STRT record from A5[0] ────────────────────────────────────────
|
||||
w0 = frames_data[0][7:] # db[7:] for A5[0]
|
||||
w0 = frames_data[0].data[7:] # frame.data[7:] for A5[0]
|
||||
strt_pos = w0.find(b"STRT")
|
||||
if strt_pos < 0:
|
||||
log.warning("_decode_a5_waveform: STRT record not found in A5[0]")
|
||||
@@ -1406,7 +1538,7 @@ def _decode_a5_waveform(
|
||||
global_offset = 0
|
||||
|
||||
for fi, db in enumerate(frames_data):
|
||||
w = db[7:]
|
||||
w = db.data[7:] # frame.data[7:]
|
||||
|
||||
# A5[0]: waveform begins after the 21-byte STRT record and 6-byte preamble.
|
||||
# Layout: STRT(21B) + null-pad(2B) + 0xFF sentinel(4B) = 27 bytes total.
|
||||
@@ -1654,16 +1786,18 @@ def _extract_project_strings(data: bytes) -> Optional[ProjectInfo]:
|
||||
def _encode_compliance_config(
|
||||
raw: bytes,
|
||||
*,
|
||||
recording_mode: Optional[int] = None,
|
||||
sample_rate: Optional[int] = None,
|
||||
record_time: Optional[float] = None,
|
||||
trigger_level_geo: Optional[float] = None,
|
||||
alarm_level_geo: Optional[float] = None,
|
||||
max_range_geo: Optional[float] = None,
|
||||
project: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
operator: Optional[str] = None,
|
||||
seis_loc: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
alarm_level_geo: Optional[float] = None,
|
||||
geo_range: Optional[int] = None, # 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
|
||||
histogram_interval_sec: Optional[int] = None,
|
||||
project: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
operator: Optional[str] = None,
|
||||
seis_loc: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Patch a live 2126-byte compliance buffer (read from the device) with any
|
||||
@@ -1675,13 +1809,31 @@ def _encode_compliance_config(
|
||||
DLE-jitter shifts):
|
||||
|
||||
Anchor: b'\\xbe\\x80\\x00\\x00\\x00\\x00' (confirmed stable, both BE11529 and BE18189)
|
||||
sample_rate → uint16 BE at anchor_pos - 6
|
||||
record_time → float32 BE at anchor_pos + 6
|
||||
recording_mode → uint8 at anchor_pos - 8 (BOTH read and write)
|
||||
Values: 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
|
||||
NOTE: The byte at anchor_pos - 7 is always 0x10 (a DLE marker regenerated by
|
||||
device firmware in every E5 response). It must NOT be overwritten during
|
||||
write — doing so causes anchor drift (+1 per write cycle).
|
||||
CORRECTION 2026-04-21: previous doc stated anchor-7 for write; empirically
|
||||
confirmed wrong — writing to anchor-7 shifts the anchor by 1 on every cycle.
|
||||
sample_rate → uint16 BE at anchor_pos - 6
|
||||
histogram_interval_sec → uint16 BE at anchor_pos - 4 (seconds; mode-gated to Histogram/Histogram+Continuous)
|
||||
Valid values: 2, 5, 15, 60, 300, 900 (= 2s, 5s, 15s, 1m, 5m, 15m)
|
||||
record_time → float32 BE at anchor_pos + 6
|
||||
|
||||
Channel block (anchored on b"Tran" with unit-string guard):
|
||||
max_range_geo → float32 BE at tran_pos + 28
|
||||
geo_range → uint8 at tran_pos + 33 (confirmed 2026-04-20)
|
||||
0x00 = Normal 10.000 in/s, 0x01 = Sensitive 1.250 in/s
|
||||
Written to Tran, Vert, Long channel blocks (all three).
|
||||
adc_scale_factor → float32 BE at tran_pos + 28 (= 6.206053; do NOT write)
|
||||
trigger_level_geo → float32 BE at tran_pos + 34
|
||||
"in.\\x00" → unit string at tran_pos + 38 (layout guard)
|
||||
alarm_level_geo → float32 BE at tran_pos + 42
|
||||
"/s\\x00\\x00" → unit string at tran_pos + 46 (layout guard)
|
||||
NOTE: tran_pos+28 (float32 = 6.206053) is the ADC-to-velocity scale factor
|
||||
(= 1/sensitivity, (in/s)/V — Interface Handbook §4.5: 1.61133 V × 6.206053 = 10.000 in/s).
|
||||
This is a hardware/firmware constant common to all MiniMate Plus S3 units.
|
||||
It must NOT be written — do not add it back as a parameter.
|
||||
|
||||
String field locations (64-byte slots, label+22 format):
|
||||
b"Project:" → value at label_pos + 22, max 41 chars + null
|
||||
@@ -1696,17 +1848,68 @@ def _encode_compliance_config(
|
||||
by the device in POC test 2026-04-07.)
|
||||
|
||||
Raises:
|
||||
ValueError: if raw is not exactly 2126 bytes.
|
||||
ValueError: if raw is shorter than the minimum needed for the 3-chunk write.
|
||||
"""
|
||||
if len(raw) != 2126:
|
||||
raise ValueError(f"_encode_compliance_config: expected 2126 bytes, got {len(raw)}")
|
||||
# Total size is nominally ~2126 bytes but varies by ±1-2 bytes depending on
|
||||
# DLE jitter in the E5 read response (0x10 bytes in the config data cause
|
||||
# 1-byte expansions per occurrence during DLE stuffing/unstuffing). The
|
||||
# anchor-based field access and the chunk splitter (fixed chunk1=1027,
|
||||
# chunk2=1055, chunk3=remainder) both handle variable length correctly.
|
||||
# Only enforce a minimum — must have at least chunk1+chunk2 bytes of content.
|
||||
_MIN_COMPLIANCE_LEN = 1027 + 1055 # = 2082
|
||||
if len(raw) < _MIN_COMPLIANCE_LEN:
|
||||
raise ValueError(
|
||||
f"_encode_compliance_config: compliance buffer too short "
|
||||
f"({len(raw)} bytes, need at least {_MIN_COMPLIANCE_LEN})"
|
||||
)
|
||||
if len(raw) not in range(2124, 2132):
|
||||
log.warning(
|
||||
"_encode_compliance_config: unusual compliance buffer length %d "
|
||||
"(expected ~2126); proceeding with anchor-based access",
|
||||
len(raw),
|
||||
)
|
||||
|
||||
buf = bytearray(raw)
|
||||
|
||||
# ── Numeric: sample_rate + record_time (anchor-relative) ─────────────────
|
||||
# ── Numeric: recording_mode + sample_rate + record_time (anchor-relative) ──
|
||||
_ANC = b'\xbe\x80\x00\x00\x00\x00'
|
||||
_anc = buf.find(_ANC, 0, 150)
|
||||
|
||||
# Log anchor position every time so we can detect unexpected shifts due to
|
||||
# DLE jitter or firmware differences. Expected position is ~15.
|
||||
if _anc < 0:
|
||||
log.warning(
|
||||
"_encode_compliance_config: anchor NOT FOUND in cfg[0:150] "
|
||||
"(buf len=%d) — all anchor-relative writes will be skipped",
|
||||
len(buf),
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
"_encode_compliance_config: anchor at cfg[%d] buf_len=%d "
|
||||
"(recording_mode@%d DLE_marker@%d sample_rate@%d:%d "
|
||||
"histogram_interval@%d:%d record_time@%d:%d)",
|
||||
_anc, len(buf),
|
||||
_anc - 8,
|
||||
_anc - 7,
|
||||
_anc - 6, _anc - 4,
|
||||
_anc - 4, _anc - 2,
|
||||
_anc + 6, _anc + 10,
|
||||
)
|
||||
|
||||
if recording_mode is not None:
|
||||
if _anc < 8:
|
||||
log.warning("_encode_compliance_config: anchor not found — cannot write recording_mode")
|
||||
else:
|
||||
# Write to anchor-8, same physical position as the E5 read format.
|
||||
# The byte at anchor-7 is a DLE marker (0x10) that the device firmware
|
||||
# regenerates in every E5 response — it must NOT be overwritten.
|
||||
# Writing to anchor-7 causes the device to add an extra byte on the
|
||||
# next read-back, drifting the anchor by +1 on every write cycle.
|
||||
# (CLAUDE.md "anchor-7 write" was incorrect — confirmed 2026-04-21)
|
||||
buf[_anc - 8] = recording_mode & 0xFF
|
||||
log.debug("_encode_compliance_config: recording_mode=0x%02X -> offset %d",
|
||||
recording_mode, _anc - 8)
|
||||
|
||||
if sample_rate is not None:
|
||||
if _anc < 6:
|
||||
log.warning("_encode_compliance_config: anchor not found — cannot write sample_rate")
|
||||
@@ -1714,6 +1917,14 @@ def _encode_compliance_config(
|
||||
struct.pack_into(">H", buf, _anc - 6, sample_rate)
|
||||
log.debug("_encode_compliance_config: sample_rate=%d -> offset %d", sample_rate, _anc - 6)
|
||||
|
||||
if histogram_interval_sec is not None:
|
||||
if _anc < 4:
|
||||
log.warning("_encode_compliance_config: anchor not found — cannot write histogram_interval")
|
||||
else:
|
||||
struct.pack_into(">H", buf, _anc - 4, histogram_interval_sec)
|
||||
log.debug("_encode_compliance_config: histogram_interval=%ds -> offset %d",
|
||||
histogram_interval_sec, _anc - 4)
|
||||
|
||||
if record_time is not None:
|
||||
if _anc < 0 or _anc + 10 > len(buf):
|
||||
log.warning("_encode_compliance_config: anchor not found — cannot write record_time")
|
||||
@@ -1722,9 +1933,11 @@ def _encode_compliance_config(
|
||||
log.debug("_encode_compliance_config: record_time=%.3f -> offset %d", record_time, _anc + 6)
|
||||
|
||||
# ── Numeric: channel block (Tran label + unit-string guard) ───────────────
|
||||
_needs_channel = any(
|
||||
v is not None for v in (trigger_level_geo, alarm_level_geo, max_range_geo)
|
||||
)
|
||||
# NOTE: tran_pos+24 (write format) or tran_pos+28 (E5 read format) is the
|
||||
# ADC-to-velocity scale factor (6.206053, hardware constant — never written).
|
||||
# geo_range is written to ALL THREE geo channel blocks (Tran, Vert, Long),
|
||||
# confirmed from 4-20-26 captures showing the byte at label+29 in each block.
|
||||
_needs_channel = any(v is not None for v in (trigger_level_geo, alarm_level_geo, geo_range))
|
||||
if _needs_channel:
|
||||
_tran = buf.find(b"Tran", 44)
|
||||
_valid = (
|
||||
@@ -1737,18 +1950,28 @@ def _encode_compliance_config(
|
||||
if not _valid:
|
||||
log.warning(
|
||||
"_encode_compliance_config: 'Tran' channel block not found or unit "
|
||||
"guard failed — trigger/alarm/max_range will not be written"
|
||||
"guard failed — trigger/alarm/geo_range will not be written"
|
||||
)
|
||||
else:
|
||||
if max_range_geo is not None:
|
||||
struct.pack_into(">f", buf, _tran + 28, max_range_geo)
|
||||
log.debug("_encode_compliance_config: max_range_geo=%.4f -> offset %d", max_range_geo, _tran + 28)
|
||||
if trigger_level_geo is not None:
|
||||
struct.pack_into(">f", buf, _tran + 34, trigger_level_geo)
|
||||
log.debug("_encode_compliance_config: trigger_level_geo=%.4f -> offset %d", trigger_level_geo, _tran + 34)
|
||||
if alarm_level_geo is not None:
|
||||
struct.pack_into(">f", buf, _tran + 42, alarm_level_geo)
|
||||
log.debug("_encode_compliance_config: alarm_level_geo=%.4f -> offset %d", alarm_level_geo, _tran + 42)
|
||||
if geo_range is not None:
|
||||
# Write geo_range to all three geo channel blocks (Tran, Vert, Long).
|
||||
# Field at label+33 in the E5-format compliance bytes (same in read and write
|
||||
# since the 2126-byte payload is round-tripped verbatim).
|
||||
# 0x00 = Normal 10.000 in/s, 0x01 = Sensitive 1.250 in/s.
|
||||
for _ch_label in (b"Tran", b"Vert", b"Long"):
|
||||
_ch = buf.find(_ch_label, 44)
|
||||
if _ch >= 0 and buf[_ch + 4 : _ch + 5] != b"2" and _ch + 34 <= len(buf):
|
||||
buf[_ch + 33] = geo_range & 0xFF
|
||||
log.debug(
|
||||
"_encode_compliance_config: geo_range=0x%02X -> %s+33 offset %d",
|
||||
geo_range, _ch_label.decode(), _ch + 33,
|
||||
)
|
||||
|
||||
# ── ASCII strings (64-byte slot, value at label_pos+22) ───────────────────
|
||||
def _set_string(label: bytes, value: Optional[str]) -> None:
|
||||
@@ -1800,7 +2023,7 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
Channel block layout (✅ confirmed 2026-04-02 from 3-11-26 E5 frame 78
|
||||
and 1-2-26 A5 frame 77):
|
||||
"Tran" label at tran_pos
|
||||
tran_pos + 28 = max_range float32_BE (e.g. 6.206053 in/s)
|
||||
tran_pos + 28 = scale_factor float32_BE (= 1/sensitivity = 6.206053 (in/s)/V — ADC scale; NOT a UI setting)
|
||||
tran_pos + 34 = trigger_level float32_BE (e.g. 0.600000 in/s)
|
||||
tran_pos + 38 = "in.\\x00" (unit string anchor)
|
||||
tran_pos + 42 = alarm_level float32_BE (e.g. 1.250000 in/s)
|
||||
@@ -1827,21 +2050,55 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
except Exception as exc:
|
||||
log.warning("compliance_config: setup_name extraction failed: %s", exc)
|
||||
|
||||
# ── Record time + sample rate — anchor-relative ───────────────────────────
|
||||
# The 10-byte anchor sits between sample_rate and record_time in the cfg.
|
||||
# Absolute offsets are NOT reliable because sample_rate = 4096 (0x1000) is
|
||||
# DLE-escaped in the raw S3 frame (10 10 00 → 10 00 after unstuffing),
|
||||
# making frame C 1 byte shorter than for 1024/2048 and shifting everything.
|
||||
# sample_rate: uint16_BE at anchor - 2
|
||||
# record_time: float32_BE at anchor + 10
|
||||
# 6-byte suffix anchor — confirmed stable across BE11529 and bench unit (BE18189).
|
||||
# The preceding 4 bytes (old anchor prefix 01 2c / 00 3c) vary by unit config;
|
||||
# only be 80 00 00 00 00 is constant.
|
||||
# sample_rate : uint16 BE at anchor_pos - 6
|
||||
# record_time : float32 BE at anchor_pos + 6
|
||||
# ── recording_mode / sample_rate / histogram_interval / record_time ─────────
|
||||
# 6-byte stable anchor: b'\xbe\x80\x00\x00\x00\x00' — confirmed across BE11529
|
||||
# and BE18189. The 4 bytes immediately before the anchor are NOT constant:
|
||||
# bytes -4:-2 are the histogram_interval (uint16 BE, seconds), and bytes -2:0
|
||||
# are zero padding. The old "10-byte anchor" (\x01\x2c\x00\x00 prefix) was
|
||||
# only constant when the histogram interval happened to be 5 min (0x012C = 300).
|
||||
#
|
||||
# E5 read format layout relative to 6-byte anchor:
|
||||
# _anchor - 8 : recording_mode (uint8)
|
||||
# _anchor - 7 : 0x10 (extra byte E5 read only; absent in SUB 71 write)
|
||||
# _anchor - 6 : sample_rate_hi (uint16 BE, MSB)
|
||||
# _anchor - 5 : sample_rate_lo (uint16 BE, LSB)
|
||||
# _anchor - 4 : histogram_interval_hi (uint16 BE, seconds, MSB)
|
||||
# _anchor - 3 : histogram_interval_lo (uint16 BE, seconds, LSB)
|
||||
# _anchor - 2 : 0x00 (padding)
|
||||
# _anchor - 1 : 0x00 (padding)
|
||||
# _anchor : \xbe\x80\x00\x00\x00\x00 (6-byte anchor)
|
||||
# _anchor + 6 : record_time (float32 BE)
|
||||
_ANCHOR = b'\xbe\x80\x00\x00\x00\x00'
|
||||
_anchor = data.find(_ANCHOR, 0, 150)
|
||||
if _anchor >= 6 and _anchor + 10 <= len(data):
|
||||
|
||||
# Log anchor position on every decode so we can compare read vs write and
|
||||
# catch unexpected shifts from DLE jitter or firmware differences.
|
||||
# Expected position is ~15 for the E5 read payload (anchor - 8 = recording_mode).
|
||||
if _anchor < 0:
|
||||
log.warning(
|
||||
"_decode_compliance_config_into: anchor NOT FOUND in data[0:150] (len=%d)",
|
||||
len(data),
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
"_decode_compliance_config_into: anchor at data[%d] data_len=%d "
|
||||
"(expected ~15; recording_mode@%d sample_rate@%d:%d "
|
||||
"histogram_interval@%d:%d record_time@%d:%d)",
|
||||
_anchor, len(data),
|
||||
_anchor - 8,
|
||||
_anchor - 6, _anchor - 4,
|
||||
_anchor - 4, _anchor - 2,
|
||||
_anchor + 6, _anchor + 10,
|
||||
)
|
||||
|
||||
if _anchor >= 8 and _anchor + 10 <= len(data):
|
||||
try:
|
||||
config.recording_mode = data[_anchor - 8]
|
||||
log.debug(
|
||||
"compliance_config: recording_mode = 0x%02X (anchor@%d)", config.recording_mode, _anchor
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("compliance_config: recording_mode extraction failed: %s", exc)
|
||||
try:
|
||||
config.sample_rate = struct.unpack_from(">H", data, _anchor - 6)[0]
|
||||
log.debug(
|
||||
@@ -1849,6 +2106,14 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("compliance_config: sample_rate extraction failed: %s", exc)
|
||||
try:
|
||||
config.histogram_interval_sec = struct.unpack_from(">H", data, _anchor - 4)[0]
|
||||
log.debug(
|
||||
"compliance_config: histogram_interval = %d s (anchor@%d)",
|
||||
config.histogram_interval_sec, _anchor
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("compliance_config: histogram_interval extraction failed: %s", exc)
|
||||
try:
|
||||
config.record_time = struct.unpack_from(">f", data, _anchor + 6)[0]
|
||||
log.debug(
|
||||
@@ -1856,10 +2121,21 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("compliance_config: record_time extraction failed: %s", exc)
|
||||
elif _anchor >= 6 and _anchor + 10 <= len(data):
|
||||
# Fallback: anchor found but not enough bytes before it for recording_mode
|
||||
log.warning("compliance_config: anchor too close to start (anchor@%d) — skipping recording_mode", _anchor)
|
||||
try:
|
||||
config.sample_rate = struct.unpack_from(">H", data, _anchor - 6)[0]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
config.record_time = struct.unpack_from(">f", data, _anchor + 6)[0]
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
log.warning(
|
||||
"compliance_config: anchor %s not found in cfg[0:150] (len=%d) "
|
||||
"— sample_rate and record_time will be None",
|
||||
"— sample_rate, record_time and recording_mode will be None",
|
||||
_ANCHOR.hex(), len(data),
|
||||
)
|
||||
|
||||
@@ -1893,18 +2169,22 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
except Exception as exc:
|
||||
log.warning("compliance_config: project string extraction failed: %s", exc)
|
||||
|
||||
# ── Channel block: trigger_level_geo, alarm_level_geo, max_range_geo ─────
|
||||
# ── Channel block: trigger_level_geo, alarm_level_geo, geo_range, geo_adc_scale ──
|
||||
# The channel block is only present in the full cfg (frame D delivered,
|
||||
# ~2126 bytes). Layout confirmed 2026-04-02 from both E5 frame 78 of the
|
||||
# 3-11-26 compliance-config capture and A5 frame 77 of the 1-2-26 event
|
||||
# download capture:
|
||||
# download capture. Cross-checked 2026-04-17 across both BE11529 and BE18189.
|
||||
#
|
||||
# "Tran" label at tran_pos (+0 to +3)
|
||||
# max_range float32_BE at tran_pos + 28 (e.g. 6.206053 in/s)
|
||||
# trigger float32_BE at tran_pos + 34 (e.g. 0.600000 in/s)
|
||||
# "in.\x00" unit string at tran_pos + 38 ✅ confirmed
|
||||
# alarm float32_BE at tran_pos + 42 (e.g. 1.250000 in/s)
|
||||
# "/s\x00\x00" unit string at tran_pos + 46 ✅ confirmed
|
||||
# adc_scale float32_BE at tran_pos + 28 (= 1/sensitivity = 6.206053 (in/s)/V; hardware constant — do NOT write)
|
||||
# geo_range uint8 at tran_pos + 33 CONFIRMED 2026-04-20
|
||||
# 0x00 = Normal 10.000 in/s, 0x01 = Sensitive 1.250 in/s
|
||||
# Same offset in E5 read and SUB 71 write (bytes are round-tripped verbatim).
|
||||
# NOTE: tran_pos+20 reads 0x01 on ALL captures — constant flag, NOT range field.
|
||||
# trigger float32_BE at tran_pos + 34 (e.g. 0.600000 in/s) ✅
|
||||
# "in.\x00" unit string at tran_pos + 38 ✅ confirmed (layout guard)
|
||||
# alarm float32_BE at tran_pos + 42 (e.g. 1.250000 in/s) ✅
|
||||
# "/s\x00\x00" unit string at tran_pos + 46 ✅ confirmed (layout guard)
|
||||
#
|
||||
# Unit strings serve as layout anchors — if they match, the float offsets
|
||||
# are reliable. Skip "Tran2" (a later repeated label) via the +4 check.
|
||||
@@ -1917,12 +2197,14 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
|
||||
and data[tran_pos + 38 : tran_pos + 42] == b"in.\x00"
|
||||
and data[tran_pos + 46 : tran_pos + 50] == b"/s\x00\x00"
|
||||
):
|
||||
config.max_range_geo = struct.unpack_from(">f", data, tran_pos + 28)[0]
|
||||
config.geo_range = data[tran_pos + 33] # CONFIRMED 2026-04-20: 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
|
||||
config.geo_adc_scale = struct.unpack_from(">f", data, tran_pos + 28)[0] # hw scale factor (in/s)/V — do NOT write
|
||||
config.trigger_level_geo = struct.unpack_from(">f", data, tran_pos + 34)[0]
|
||||
config.alarm_level_geo = struct.unpack_from(">f", data, tran_pos + 42)[0]
|
||||
log.debug(
|
||||
"compliance_config: trigger=%.4f alarm=%.4f max_range=%.4f in/s",
|
||||
config.trigger_level_geo, config.alarm_level_geo, config.max_range_geo,
|
||||
"compliance_config: trigger=%.4f alarm=%.4f geo_range=0x%02X geo_adc_scale=%.6f",
|
||||
config.trigger_level_geo, config.alarm_level_geo,
|
||||
config.geo_range, config.geo_adc_scale,
|
||||
)
|
||||
elif tran_pos >= 0:
|
||||
log.warning(
|
||||
@@ -2144,3 +2426,160 @@ def _decode_monitor_status(data: bytes) -> MonitorStatus:
|
||||
memory_total=memory_total,
|
||||
memory_free=memory_free,
|
||||
)
|
||||
|
||||
|
||||
def _decode_call_home_config(raw: bytes) -> CallHomeConfig:
|
||||
"""
|
||||
Decode the raw 125-byte call home config payload into a CallHomeConfig.
|
||||
|
||||
*raw* is data[11:] from the SUB 0xD3 data response frame.
|
||||
|
||||
Field offsets (confirmed from 4-20-26 captures, all 11 BW+S3 pairs):
|
||||
[5] auto_call_home_enabled (0x00=off, 0x01=on)
|
||||
[6:46] dial_string 40-byte null-padded ASCII
|
||||
[87] after_event_recorded (0x01=on, 0x00=off)
|
||||
[91] at_specified_times (0x01=on, 0x00=off)
|
||||
[93] time1_enabled (0x01=on, 0x00=off)
|
||||
[95] time2_enabled (0x01=on, 0x00=off)
|
||||
[101] time1_hour uint8 decimal 0-23
|
||||
[102] time1_min uint8 decimal 0-59
|
||||
[105] time2_hour uint8 decimal 0-23
|
||||
[106] time2_min uint8 decimal 0-59
|
||||
[117:119] 10 03 = DLE-escaped num_retries=3 (logical value = 0x03)
|
||||
[120] time_between_retries_sec (shifted +1 from logical by DLE prefix)
|
||||
[122] wait_for_connection_sec
|
||||
[124] warm_up_time_sec
|
||||
|
||||
The DLE-escaped ETX at raw[117:119] = b'\\x10\\x03' means the logical value
|
||||
0x03 (3 retries) is stored there. The S3FrameParser keeps both bytes verbatim.
|
||||
Subsequent fields are at logical_offset + 1 in the raw byte array.
|
||||
"""
|
||||
cfg = CallHomeConfig(raw=raw)
|
||||
|
||||
if len(raw) < 10:
|
||||
return cfg
|
||||
|
||||
# Simple boolean and string fields — direct reads, no DLE complications
|
||||
if len(raw) > 5:
|
||||
cfg.auto_call_home_enabled = bool(raw[5])
|
||||
if len(raw) >= 46:
|
||||
ds = raw[6:46]
|
||||
cfg.dial_string = ds.split(b"\x00", 1)[0].decode("ascii", errors="replace") or None
|
||||
if len(raw) > 87:
|
||||
cfg.after_event_recorded = bool(raw[87])
|
||||
if len(raw) > 91:
|
||||
cfg.at_specified_times = bool(raw[91])
|
||||
if len(raw) > 93:
|
||||
cfg.time1_enabled = bool(raw[93])
|
||||
if len(raw) > 95:
|
||||
cfg.time2_enabled = bool(raw[95])
|
||||
if len(raw) > 102:
|
||||
cfg.time1_hour = raw[101]
|
||||
cfg.time1_min = raw[102]
|
||||
if len(raw) > 106:
|
||||
cfg.time2_hour = raw[105]
|
||||
cfg.time2_min = raw[106]
|
||||
|
||||
# num_retries: raw[117]=0x10 (DLE prefix), raw[118]=0x03 (value)
|
||||
# Subsequent fields shift by +1 from logical positions.
|
||||
if len(raw) > 118 and raw[117] == 0x10:
|
||||
cfg.num_retries = raw[118] # 0x03 = 3
|
||||
if len(raw) > 120:
|
||||
cfg.time_between_retries_sec = raw[120] # logical 119, shifted to 120
|
||||
if len(raw) > 122:
|
||||
cfg.wait_for_connection_sec = raw[122] # logical 121, shifted to 122
|
||||
if len(raw) > 124:
|
||||
cfg.warm_up_time_sec = raw[124] # logical 123, shifted to 124
|
||||
elif len(raw) > 117:
|
||||
# Fallback: no DLE prefix (num_retries is not 0x03)
|
||||
cfg.num_retries = raw[117]
|
||||
if len(raw) > 119:
|
||||
cfg.time_between_retries_sec = raw[119]
|
||||
if len(raw) > 121:
|
||||
cfg.wait_for_connection_sec = raw[121]
|
||||
if len(raw) > 123:
|
||||
cfg.warm_up_time_sec = raw[123]
|
||||
|
||||
log.debug(
|
||||
"_decode_call_home_config: enabled=%s dial=%r after_event=%s at_times=%s "
|
||||
"t1=%s %02d:%02d t2=%s %02d:%02d retries=%s gap=%s wait=%s warmup=%s",
|
||||
cfg.auto_call_home_enabled, cfg.dial_string,
|
||||
cfg.after_event_recorded, cfg.at_specified_times,
|
||||
cfg.time1_enabled, cfg.time1_hour or 0, cfg.time1_min or 0,
|
||||
cfg.time2_enabled, cfg.time2_hour or 0, cfg.time2_min or 0,
|
||||
cfg.num_retries, cfg.time_between_retries_sec,
|
||||
cfg.wait_for_connection_sec, cfg.warm_up_time_sec,
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _encode_call_home_config(
|
||||
raw: bytes,
|
||||
*,
|
||||
auto_call_home_enabled: Optional[bool] = None,
|
||||
after_event_recorded: Optional[bool] = None,
|
||||
at_specified_times: Optional[bool] = None,
|
||||
time1_enabled: Optional[bool] = None,
|
||||
time1_hour: Optional[int] = None,
|
||||
time1_min: Optional[int] = None,
|
||||
time2_enabled: Optional[bool] = None,
|
||||
time2_hour: Optional[int] = None,
|
||||
time2_min: Optional[int] = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Patch specific fields in the 125-byte raw call home payload and return
|
||||
the 127-byte write payload (raw + b'\\x00\\x00' footer).
|
||||
|
||||
Only non-None arguments are modified. All other bytes including the
|
||||
DLE-escaped \\x10\\x03 at [117:119] are preserved verbatim for round-trip.
|
||||
|
||||
The write payload footer (2 trailing zeros) matches Blastware's confirmed
|
||||
write frame format from the 4-20-26 captures.
|
||||
|
||||
CAUTION: hour and minute values must not equal 0x03 (3) — such values would
|
||||
require DLE-escaping that this encoder does not implement. Values 0x03 in
|
||||
hour/minute slots are rejected with ValueError.
|
||||
"""
|
||||
if len(raw) < 107:
|
||||
raise ValueError(
|
||||
f"call home raw payload too short: {len(raw)} bytes (need ≥107)"
|
||||
)
|
||||
buf = bytearray(raw) # 125 bytes
|
||||
|
||||
def _set_bool(offset: int, value: Optional[bool]) -> None:
|
||||
if value is not None:
|
||||
buf[offset] = 0x01 if value else 0x00
|
||||
|
||||
def _set_uint8(offset: int, value: Optional[int], name: str) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if value == 0x03:
|
||||
raise ValueError(
|
||||
f"{name}={value} (0x03) requires DLE escaping — "
|
||||
"not supported by this encoder; avoid using 3 for hour/minute fields"
|
||||
)
|
||||
buf[offset] = value & 0xFF
|
||||
|
||||
_set_bool(5, auto_call_home_enabled)
|
||||
_set_bool(87, after_event_recorded)
|
||||
_set_bool(91, at_specified_times)
|
||||
_set_bool(93, time1_enabled)
|
||||
_set_bool(95, time2_enabled)
|
||||
_set_uint8(101, time1_hour, "time1_hour")
|
||||
_set_uint8(102, time1_min, "time1_min")
|
||||
_set_uint8(105, time2_hour, "time2_hour")
|
||||
_set_uint8(106, time2_min, "time2_min")
|
||||
# num_retries, time_between_retries_sec, wait_for_connection_sec, warm_up_time_sec
|
||||
# are not writable via this encoder — they're preserved verbatim including the
|
||||
# DLE-escaped 0x03 at [117:119].
|
||||
|
||||
log.debug(
|
||||
"_encode_call_home_config: patched fields: "
|
||||
"enabled=%s after_event=%s at_times=%s "
|
||||
"t1=%s %s:%s t2=%s %s:%s",
|
||||
auto_call_home_enabled, after_event_recorded, at_specified_times,
|
||||
time1_enabled, time1_hour, time1_min,
|
||||
time2_enabled, time2_hour, time2_min,
|
||||
)
|
||||
|
||||
return bytes(buf) + b"\x00\x00" # append 2-byte footer (confirmed BW pattern)
|
||||
|
||||
+10
-4
@@ -457,6 +457,11 @@ class S3Frame:
|
||||
page_lo: int # PAGE_LO from header
|
||||
data: bytes # payload data section (payload[5:], checksum already stripped)
|
||||
checksum_valid: bool
|
||||
chk_byte: int = 0 # actual checksum byte received from wire (body[-1])
|
||||
# needed for waveform file reconstruction: when the last data byte
|
||||
# is 0x10 and chk_byte ∈ {0x02, 0x03, 0x04}, the DLE+chk pair
|
||||
# must be included in the DLE-strip operation to correctly
|
||||
# reconstruct the Blastware binary body.
|
||||
|
||||
@property
|
||||
def page_key(self) -> int:
|
||||
@@ -592,9 +597,10 @@ class S3FrameParser:
|
||||
return None
|
||||
|
||||
return S3Frame(
|
||||
sub = raw_payload[2],
|
||||
page_hi = raw_payload[3],
|
||||
page_lo = raw_payload[4],
|
||||
data = raw_payload[5:],
|
||||
sub = raw_payload[2],
|
||||
page_hi = raw_payload[3],
|
||||
page_lo = raw_payload[4],
|
||||
data = raw_payload[5:],
|
||||
checksum_valid = (chk_received == chk_computed),
|
||||
chk_byte = chk_received,
|
||||
)
|
||||
|
||||
+101
-6
@@ -269,7 +269,7 @@ class ChannelConfig:
|
||||
label: str # e.g. "Tran", "Vert", "Long", "MicL" ✅
|
||||
trigger_level: float # in/s (geo) or psi (MicL) ✅
|
||||
alarm_level: float # in/s (geo) or psi (MicL) ✅
|
||||
max_range: float # full-scale calibration constant (e.g. 6.206) 🔶
|
||||
max_range: float # hardware/firmware sensitivity constant (e.g. 6.206053) ✅ confirmed same on all units
|
||||
unit_label: str # e.g. "in./s" or "psi" ✅
|
||||
|
||||
|
||||
@@ -338,15 +338,34 @@ class ComplianceConfig:
|
||||
raw: Optional[bytes] = None # full 2090-byte payload (for debugging)
|
||||
|
||||
# Recording parameters (✅ CONFIRMED from §7.6)
|
||||
record_time: Optional[float] = None # seconds (7.0, 10.0, 13.0, etc.)
|
||||
sample_rate: Optional[int] = None # sps (1024, 2048, 4096, etc.) — NOT YET FOUND ❓
|
||||
recording_mode: Optional[int] = None # uint8: 0x00=Single Shot, 0x01=Continuous,
|
||||
# 0x03=Histogram, 0x04=Histogram+Continuous ✅ confirmed 2026-04-20
|
||||
# Read (E5): data[anchor_pos - 8] (6-byte anchor)
|
||||
# Write (SUB 71): data[anchor_pos - 7]
|
||||
sample_rate: Optional[int] = None # sps (1024, 2048, 4096)
|
||||
histogram_interval_sec: Optional[int] = None # uint16 BE, seconds ✅ confirmed 2026-04-20
|
||||
# anchor_pos - 4 (same offset in read & write)
|
||||
# Valid values: 2, 5, 15, 60, 300, 900
|
||||
# Mode-gated: only active in Histogram/Histogram+Continuous
|
||||
record_time: Optional[float] = None # seconds (e.g. 3.0, 5.0, 8.0, 10.0)
|
||||
|
||||
# Trigger/alarm levels (✅ CONFIRMED per-channel at §7.6)
|
||||
# For now we store the first geo channel (Transverse) as representatives;
|
||||
# full per-channel data would require structured Channel objects.
|
||||
trigger_level_geo: Optional[float] = None # in/s (first geo channel)
|
||||
alarm_level_geo: Optional[float] = None # in/s (first geo channel)
|
||||
max_range_geo: Optional[float] = None # in/s full-scale range
|
||||
trigger_level_geo: Optional[float] = None # in/s (first geo channel) ✅
|
||||
alarm_level_geo: Optional[float] = None # in/s (first geo channel) ✅
|
||||
geo_adc_scale: Optional[float] = None # ADC-to-velocity scale factor (float32 at Tran+28) ✅
|
||||
# = inverse sensitivity = 1/sensitivity (in/s per V)
|
||||
# Formula (Interface Handbook §4.5): Range = 1.61133 V × scale_factor
|
||||
# → 1.61133 × 6.206053 = 10.000 in/s (Normal range) ✅
|
||||
# Firmware uses: PPV (in/s) = ADC_voltage (V) × 6.206053
|
||||
# Identical on BE11529 and BE18189 — same Instantel geophone hardware.
|
||||
# NOT a user-configurable setting. Must NOT be written.
|
||||
geo_range: Optional[int] = None # range/sensitivity selector — CONFIRMED 2026-04-20
|
||||
# 0x00 = Normal 10.000 in/s (standard gain)
|
||||
# 0x01 = Sensitive 1.250 in/s (high gain)
|
||||
# Offset: Tran+33 in both E5 read and SUB 71 write payloads
|
||||
# (same 2126-byte buffer is round-tripped; applied to Tran/Vert/Long)
|
||||
|
||||
# Project/setup strings (sourced from E5 / SUB 71 write payload)
|
||||
# These are the FULL project metadata from compliance config,
|
||||
@@ -359,6 +378,78 @@ class ComplianceConfig:
|
||||
notes: Optional[str] = None # extended notes / additional info
|
||||
|
||||
|
||||
# ── Call Home Config ──────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class CallHomeConfig:
|
||||
"""
|
||||
Auto Call Home (ACH) configuration from SUB 0x2C (response 0xD3).
|
||||
|
||||
Read with a standard two-step protocol (probe offset=0x00, data offset=0x7C).
|
||||
Written via SUB 0x7E (write, 127-byte payload) + SUB 0x7F (confirm).
|
||||
|
||||
Confirmed from 4-20-26 call home settings captures (11 BW + S3 capture pairs).
|
||||
|
||||
Raw payload layout (data[11:] from S3 response, 125 bytes):
|
||||
[0] 0x00 header byte
|
||||
[1] 0x7C = 124 inner length (= offset for SUB 0x7E write - 2)
|
||||
[2] 0xDC constant
|
||||
[3:5] 0x00 0x00 padding
|
||||
[5] auto_call_home_enabled (0x00=off, 0x01=on) ✅
|
||||
[6:46] dial_string 40-byte null-padded ASCII ✅
|
||||
[46:87] auto_answer_raw AT command strings (not decoded) ✅ present
|
||||
[87] after_event_recorded (0x01=on, 0x00=off) ✅
|
||||
[91] at_specified_times (0x01=on, 0x00=off) ✅
|
||||
[93] time1_enabled (0x01=on, 0x00=off) ✅
|
||||
[95] time2_enabled (0x01=on, 0x00=off) ✅
|
||||
[101] time1_hour uint8 decimal 0-23 ✅
|
||||
[102] time1_min uint8 decimal 0-59 ✅
|
||||
[105] time2_hour uint8 decimal 0-23 ✅
|
||||
[106] time2_min uint8 decimal 0-59 ✅
|
||||
[117] DLE prefix (0x10) ┐ DLE-escaped num_retries=3 (0x03)
|
||||
[118] 0x03 ┘ device stores/returns 0x03 DLE-escaped ✅
|
||||
[120] time_between_retries_sec uint8 (= 0x0F = 15 s default) ✅
|
||||
[122] wait_for_connection_sec uint8 (= 0x3C = 60 s default) ✅
|
||||
[124] warm_up_time_sec uint8 (= 0x3C = 60 s default) ✅
|
||||
|
||||
Write payload = raw 125 bytes + b'\\x00\\x00' (2 trailing zeros) = 127 bytes.
|
||||
Offset for SUB 0x7E: data[1] + 2 = 0x7C + 2 = 0x7E = 126.
|
||||
|
||||
Note on DLE-escaped 0x03: The device's S3 response DLE-escapes ETX (0x03)
|
||||
bytes as \\x10\\x03. The S3FrameParser preserves both bytes in frame.data.
|
||||
Subsequent fields after offset 117 are therefore at raw_offset = logical+1.
|
||||
The raw payload must be round-tripped verbatim in write; do NOT reapply DLE
|
||||
destuffing or stripping.
|
||||
"""
|
||||
raw: Optional[bytes] = None # raw 125-byte read payload (for round-trip write)
|
||||
|
||||
# ── Main enable ──────────────────────────────────────────────────────────
|
||||
auto_call_home_enabled: Optional[bool] = None # raw[5] ✅
|
||||
|
||||
# ── Dial string ──────────────────────────────────────────────────────────
|
||||
dial_string: Optional[str] = None # raw[6:46] 40-byte null-padded ASCII ✅
|
||||
|
||||
# ── When to call ─────────────────────────────────────────────────────────
|
||||
after_event_recorded: Optional[bool] = None # raw[87] ✅
|
||||
at_specified_times: Optional[bool] = None # raw[91] ✅
|
||||
|
||||
# ── Time slot 1 ──────────────────────────────────────────────────────────
|
||||
time1_enabled: Optional[bool] = None # raw[93] ✅
|
||||
time1_hour: Optional[int] = None # raw[101] 0-23 ✅
|
||||
time1_min: Optional[int] = None # raw[102] 0-59 ✅
|
||||
|
||||
# ── Time slot 2 ──────────────────────────────────────────────────────────
|
||||
time2_enabled: Optional[bool] = None # raw[95] ✅
|
||||
time2_hour: Optional[int] = None # raw[105] 0-23 ✅
|
||||
time2_min: Optional[int] = None # raw[106] 0-59 ✅
|
||||
|
||||
# ── Retry / timeout settings (read-only; not writable via set_call_home_config) ──
|
||||
num_retries: Optional[int] = None # raw[117:119]=10 03 → value 3 ✅
|
||||
time_between_retries_sec: Optional[int] = None # raw[120] (shifted +1 by DLE) ✅
|
||||
wait_for_connection_sec: Optional[int] = None # raw[122] ✅
|
||||
warm_up_time_sec: Optional[int] = None # raw[124] ✅
|
||||
|
||||
|
||||
# ── Event ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
@@ -402,6 +493,10 @@ class Event:
|
||||
# Set by get_events(); required by download_waveform().
|
||||
_waveform_key: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
# Raw A5 frames from the full bulk waveform download (full_waveform=True).
|
||||
# Populated by get_events() when full_waveform=True; used by write_blastware_file().
|
||||
_a5_frames: Optional[list] = field(default=None, repr=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
ts = str(self.timestamp) if self.timestamp else "no timestamp"
|
||||
ppv = ""
|
||||
|
||||
+263
-34
@@ -65,6 +65,7 @@ SUB_WAVEFORM_HEADER = 0x0A
|
||||
SUB_WAVEFORM_RECORD = 0x0C
|
||||
SUB_BULK_WAVEFORM = 0x5A
|
||||
SUB_COMPLIANCE = 0x1A
|
||||
SUB_CALL_HOME = 0x2C # Call home config read → response 0xD3 ✅
|
||||
SUB_UNKNOWN_2E = 0x2E
|
||||
|
||||
# Write command SUBs (= Read SUB + 0x60, confirmed from BW captures 3-11-26)
|
||||
@@ -78,6 +79,10 @@ SUB_WRITE_CONFIRM_C = 0x74 # Confirm C — sent after 69 ✅
|
||||
SUB_TRIGGER_CONFIG_WRITE = 0x82 # Write trigger config (0x22 + 0x60) ✅
|
||||
SUB_TRIGGER_CONFIRM = 0x83 # Confirm trigger write ✅
|
||||
|
||||
# Call home write SUBs (confirmed from 4-20-26 call home settings captures)
|
||||
SUB_CALL_HOME_WRITE = 0x7E # Write call home config → response 0x81 ✅
|
||||
SUB_CALL_HOME_CONFIRM = 0x7F # Confirm call home write → response 0x80 ✅
|
||||
|
||||
# Monitoring control SUBs (confirmed from 4-8-26/2ndtry BW TX capture)
|
||||
SUB_START_MONITORING = 0x96 # Start monitoring → response 0x69 ✅
|
||||
SUB_STOP_MONITORING = 0x97 # Stop monitoring → response 0x68 ✅
|
||||
@@ -109,6 +114,7 @@ DATA_LENGTHS: dict[int, int] = {
|
||||
# SUB_WAVEFORM_HEADER (0x0A) is VARIABLE — length read from probe response
|
||||
# data[4]. Do NOT add it here; use read_waveform_header() instead. ✅
|
||||
SUB_WAVEFORM_RECORD: 0xD2, # 210-byte waveform/histogram record ✅
|
||||
SUB_CALL_HOME: 0x7C, # 124-byte call home config ✅ (confirmed 4-20-26)
|
||||
SUB_UNKNOWN_2E: 0x1A, # 26 bytes, purpose TBD 🔶
|
||||
0x09: 0xCA, # 202 bytes, purpose TBD 🔶
|
||||
# SUB_COMPLIANCE (0x1A) uses a multi-step sequence with a 2090-byte total;
|
||||
@@ -120,10 +126,12 @@ DATA_LENGTHS: dict[int, int] = {
|
||||
_BULK_CHUNK_OFFSET = 0x1004 # offset field for probe + all regular chunk requests ✅
|
||||
_BULK_TERM_OFFSET = 0x005A # offset field for termination request ✅
|
||||
_BULK_COUNTER_STEP = 0x0400 # chunk counter increment per chunk ✅
|
||||
# Chunk counter formula: chunk_num * 0x0400 for ALL chunks including chunk 1.
|
||||
# Earlier captures showed 0x1004 for chunk 1 — that was a Blastware artifact, not a
|
||||
# protocol requirement. Confirmed 2026-04-06: 0x0400 for chunk 1 works; 0x1004
|
||||
# causes a 120-second device timeout. Formula n * 0x0400 is used for all chunks.
|
||||
# Chunk counter formula: key4[2:4] + (chunk_num - 1) * 0x0400
|
||||
# where key4[2:4] is the event's circular-buffer base offset ((key4[2]<<8)|key4[3]).
|
||||
# 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
|
||||
# "n * 0x0400" formula sends counters from the wrong buffer region and the device
|
||||
# returns data from a different event. Confirmed correct 2026-04-24.
|
||||
|
||||
# Default timeout values (seconds).
|
||||
# MiniMate Plus is a slow device — keep these generous.
|
||||
@@ -520,7 +528,9 @@ class MiniMateProtocol:
|
||||
*,
|
||||
stop_after_metadata: bool = True,
|
||||
max_chunks: int = 32,
|
||||
) -> list[bytes]:
|
||||
include_terminator: bool = False,
|
||||
extra_chunks_after_metadata: int = 1,
|
||||
) -> list[S3Frame]:
|
||||
"""
|
||||
Download the SUB 5A (BULK_WAVEFORM_STREAM) A5 frames for one event.
|
||||
|
||||
@@ -536,7 +546,9 @@ class MiniMateProtocol:
|
||||
4. Termination (offset=_BULK_TERM_OFFSET, counter=last+_BULK_COUNTER_STEP)
|
||||
Device responds with a final A5 frame (page_key=0x0000).
|
||||
|
||||
The termination frame (page_key=0x0000) is NOT included in the returned list.
|
||||
By default the termination frame (page_key=0x0000) is NOT included in the
|
||||
returned list. Pass include_terminator=True to append it; the blastware_file
|
||||
writer needs the terminator frame's body to reconstruct the waveform file footer.
|
||||
|
||||
Args:
|
||||
key4: 4-byte waveform key from EVENT_HEADER (1E).
|
||||
@@ -546,11 +558,16 @@ class MiniMateProtocol:
|
||||
hundred KB). Set False to download everything.
|
||||
max_chunks: Safety cap on the number of chunk requests sent
|
||||
(default 32; a typical event uses 9 large frames).
|
||||
include_terminator: If True, append the terminator A5 frame
|
||||
(page_key=0x0000) to the returned list. The
|
||||
terminator carries the waveform file footer bytes.
|
||||
Default False preserves existing caller behaviour.
|
||||
|
||||
Returns:
|
||||
List of raw data bytes from each A5 response frame (not including
|
||||
the terminator frame). Frame indices match the request sequence:
|
||||
index 0 = probe response, index 1 = first chunk, etc.
|
||||
List of S3Frame objects from each A5 response frame. Frame indices
|
||||
match the request sequence: index 0 = probe response, index 1 = first
|
||||
chunk, etc. If include_terminator=True, the last element is the
|
||||
terminator frame (page_key=0x0000).
|
||||
|
||||
Raises:
|
||||
ProtocolError: on timeout, bad checksum, or unexpected SUB.
|
||||
@@ -565,16 +582,24 @@ class MiniMateProtocol:
|
||||
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
|
||||
|
||||
rsp_sub = _expected_rsp_sub(SUB_BULK_WAVEFORM) # 0xFF - 0x5A = 0xA5
|
||||
frames_data: list[bytes] = []
|
||||
frames_data: list[S3Frame] = []
|
||||
counter = 0
|
||||
|
||||
# BW counter formula (confirmed from 4-3-26 capture for key 0111245a,
|
||||
# and empirical live-device test 2026-04-06 for key 01110000):
|
||||
# counter for chunk n = max(key4[2:4], 0x0400) + (n - 1) * 0x0400
|
||||
# key4[2:4] is the event's circular-buffer base offset. The max() guard
|
||||
# ensures chunk 1 never uses counter=0x0000 (which equals the probe address
|
||||
# and causes the device to re-return STRT record data for the first chunk).
|
||||
_key4_offset = (key4[2] << 8) | key4[3]
|
||||
|
||||
# ── Step 1: probe ────────────────────────────────────────────────────
|
||||
log.debug("5A probe key=%s", key4.hex())
|
||||
log.debug("5A probe key=%s key4_offset=0x%04X", key4.hex(), _key4_offset)
|
||||
params = bulk_waveform_params(key4, 0, is_probe=True)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
self._parser.reset() # reset bytes_fed counter before probe recv
|
||||
try:
|
||||
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False)
|
||||
probe_batch = self._recv_5a_batch(rsp_sub)
|
||||
except TimeoutError:
|
||||
log.warning(
|
||||
"5A probe TIMED OUT for key=%s — "
|
||||
@@ -582,23 +607,54 @@ class MiniMateProtocol:
|
||||
key4.hex(), self._parser.bytes_fed,
|
||||
)
|
||||
raise
|
||||
frames_data.append(rsp.data)
|
||||
log.debug("5A A5[0] page_key=0x%04X %d bytes", rsp.page_key, len(rsp.data))
|
||||
frames_data.extend(probe_batch)
|
||||
log.debug(
|
||||
"5A probe: %d frame(s) page_keys=%s",
|
||||
len(probe_batch),
|
||||
[f"0x{f.page_key:04X}" for f in probe_batch],
|
||||
)
|
||||
|
||||
# Log probe frame size for diagnostics.
|
||||
# The device always needs extra_chunks_after_metadata chunks after the
|
||||
# metadata frame before termination to prime the valid waveform footer.
|
||||
# This holds regardless of TCP frame size (1-frame vs 2-frame mode).
|
||||
_effective_extra_chunks = extra_chunks_after_metadata
|
||||
log.warning(
|
||||
"5A probe data_len=%d effective_extra_chunks=%d",
|
||||
len(probe_batch[0].data),
|
||||
_effective_extra_chunks,
|
||||
)
|
||||
|
||||
# ── Step 2: chunk loop ───────────────────────────────────────────────
|
||||
# Chunk counters are monotonic: chunk_num * 0x0400 for all chunks.
|
||||
# The 4-2-26 BW TX capture showed 0x1004 for chunk 1, but this is a
|
||||
# Blastware artifact — the device accepts any counter value and streams
|
||||
# data regardless. Empirically confirmed 2026-04-06: 0x0400 for chunk 1
|
||||
# works; 0x1004 causes the device to ignore the frame (timeout).
|
||||
# Counter formula: _chunk_base + (chunk_num - 1) * 0x0400
|
||||
# where _chunk_base = max(key4[2:4], 0x0400).
|
||||
#
|
||||
# For events with key4[2:4] != 0 (e.g. key 0111245a, offset 0x245a):
|
||||
# _chunk_base = 0x245a → chunk 1=0x245a, chunk 2=0x285a, ...
|
||||
# Confirmed from 4-3-26 capture.
|
||||
#
|
||||
# For events with key4[2:4] == 0 (e.g. key 01110000):
|
||||
# _chunk_base = max(0, 0x0400) = 0x0400
|
||||
# → chunk 1=0x0400, chunk 2=0x0800, ... (= old chunk_num*0x0400)
|
||||
# CRITICAL: counter=0x0000 (same as the probe) causes the device to
|
||||
# re-return the STRT record data for chunk 1, making frame 1 look like
|
||||
# a second probe response (confirmed from server log: frame 1 len=1097,
|
||||
# contains STRT\xff\xfe, contributes zero body bytes after DLE-strip).
|
||||
# counter=0x0400 for chunk 1 confirmed working (empirical test 2026-04-06).
|
||||
_chunk_base = max(_key4_offset, _BULK_COUNTER_STEP)
|
||||
for chunk_num in range(1, max_chunks + 1):
|
||||
counter = chunk_num * _BULK_COUNTER_STEP
|
||||
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
|
||||
params = bulk_waveform_params(key4, counter)
|
||||
log.debug("5A chunk %d counter=0x%04X", chunk_num, counter)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
self._parser.reset() # reset bytes_fed for accurate per-chunk count
|
||||
try:
|
||||
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False, timeout=10.0)
|
||||
# Collect ALL frames from this chunk response.
|
||||
# Over TCP via modem, a single large A5 device response (~1100 bytes
|
||||
# RS-232) is split across ~2 TCP segments, each parsed as its own
|
||||
# complete S3 frame. _recv_5a_batch gathers all of them so that
|
||||
# every subsequent chunk request is paired with the correct response.
|
||||
batch = self._recv_5a_batch(rsp_sub, first_timeout=10.0)
|
||||
except TimeoutError:
|
||||
raw = self._parser.bytes_fed
|
||||
log.warning(
|
||||
@@ -617,20 +673,51 @@ class MiniMateProtocol:
|
||||
break
|
||||
raise
|
||||
|
||||
log.warning(
|
||||
"5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s",
|
||||
chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data,
|
||||
)
|
||||
# Process all frames from this batch.
|
||||
metadata_found = False
|
||||
for rsp in batch:
|
||||
log.warning(
|
||||
"5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s",
|
||||
chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data,
|
||||
)
|
||||
if rsp.page_key == 0x0000:
|
||||
# Device unexpectedly terminated mid-stream.
|
||||
log.debug("5A page_key=0x0000 — device terminated early")
|
||||
if include_terminator:
|
||||
frames_data.append(rsp)
|
||||
return frames_data
|
||||
frames_data.append(rsp)
|
||||
if stop_after_metadata and b"Project:" in rsp.data:
|
||||
metadata_found = True
|
||||
|
||||
if rsp.page_key == 0x0000:
|
||||
# Device unexpectedly terminated mid-stream (no termination needed).
|
||||
log.debug("5A A5[%d] page_key=0x0000 — device terminated early", chunk_num)
|
||||
return frames_data
|
||||
|
||||
frames_data.append(rsp.data)
|
||||
|
||||
if stop_after_metadata and b"Project:" in rsp.data:
|
||||
log.debug("5A A5[%d] metadata found — stopping early", chunk_num)
|
||||
if metadata_found:
|
||||
# Download extra_chunks_after_metadata more chunks after metadata.
|
||||
# This primes the device to return the valid waveform footer in the
|
||||
# termination response — without it the terminator carries too few bytes
|
||||
# (confirmed 2026-04-23). The extra chunk data also belongs in the
|
||||
# file body (confirmed from TCP capture analysis 2026-04-27).
|
||||
log.debug("5A metadata found — fetching %d more chunk(s)",
|
||||
_effective_extra_chunks)
|
||||
for _extra_n in range(_effective_extra_chunks):
|
||||
chunk_num += 1
|
||||
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
|
||||
params = bulk_waveform_params(key4, counter)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
try:
|
||||
extra_batch = self._recv_5a_batch(rsp_sub, first_timeout=10.0)
|
||||
for ef in extra_batch:
|
||||
log.debug(
|
||||
"5A extra chunk page_key=0x%04X data_len=%d",
|
||||
ef.page_key, len(ef.data),
|
||||
)
|
||||
if ef.page_key == 0x0000:
|
||||
if include_terminator:
|
||||
frames_data.append(ef)
|
||||
return frames_data
|
||||
frames_data.append(ef)
|
||||
except TimeoutError:
|
||||
log.debug("5A extra chunk %d timed out — end of stream", _extra_n + 1)
|
||||
break
|
||||
break
|
||||
else:
|
||||
log.warning(
|
||||
@@ -652,6 +739,8 @@ class MiniMateProtocol:
|
||||
"5A termination response page_key=0x%04X %d bytes",
|
||||
term_rsp.page_key, len(term_rsp.data),
|
||||
)
|
||||
if include_terminator:
|
||||
frames_data.append(term_rsp)
|
||||
except TimeoutError:
|
||||
log.debug("5A no termination response — device may have already closed")
|
||||
|
||||
@@ -1087,6 +1176,89 @@ class MiniMateProtocol:
|
||||
self._send(frame)
|
||||
return self.recv_write_ack(expected_sub=rsp_sub)
|
||||
|
||||
# ── Call home config (SUBs 0x2C / 0x7E / 0x7F) ──────────────────────────
|
||||
|
||||
def read_call_home_config(self) -> bytes:
|
||||
"""
|
||||
Read the auto call home configuration (SUB 0x2C → response 0xD3).
|
||||
|
||||
Standard two-step read: probe (offset=0x00) then data (offset=0x7C=124).
|
||||
Returns the raw 125-byte payload (data[11:] of the data response).
|
||||
|
||||
Confirmed from 4-20-26 call home settings capture:
|
||||
- Probe response: data[4]=0x7C (confirms data length = 124)
|
||||
- Data response: 136 bytes total (11-byte echo header + 125 bytes payload)
|
||||
- Payload[0:3] = 0x00 0x7C 0xDC (header: zero, inner-length, constant)
|
||||
- Payload[5] = auto_call_home_enabled
|
||||
- Payload[6:46] = dial_string (40-byte null-padded ASCII "RADIO RING")
|
||||
|
||||
Returns:
|
||||
Raw 125-byte call home config payload (data[11:]).
|
||||
Suitable for round-trip write (append \\x00\\x00 → 127-byte write payload).
|
||||
|
||||
Raises:
|
||||
ProtocolError: on timeout or wrong response SUB.
|
||||
"""
|
||||
rsp_sub = _expected_rsp_sub(SUB_CALL_HOME) # 0xFF - 0x2C = 0xD3
|
||||
length = DATA_LENGTHS[SUB_CALL_HOME] # 0x7C = 124
|
||||
|
||||
log.debug("read_call_home_config: 0x2C probe")
|
||||
self._send(build_bw_frame(SUB_CALL_HOME, 0))
|
||||
self._recv_one(expected_sub=rsp_sub)
|
||||
|
||||
log.debug("read_call_home_config: 0x2C data request offset=0x%02X", length)
|
||||
self._send(build_bw_frame(SUB_CALL_HOME, length))
|
||||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||||
|
||||
payload = data_rsp.data[11:]
|
||||
log.debug("read_call_home_config: received %d payload bytes", len(payload))
|
||||
return payload
|
||||
|
||||
def write_call_home_config(self, data: bytes) -> None:
|
||||
"""
|
||||
Write the auto call home configuration (SUB 0x7E → 0x7F confirm).
|
||||
|
||||
Write sequence (confirmed from 4-20-26 call home settings captures):
|
||||
SUB 0x7E write 127-byte payload → device acks SUB 0x81
|
||||
SUB 0x7F confirm (no data) → device acks SUB 0x80
|
||||
|
||||
The 127-byte write payload = 125-byte read payload + b'\\x00\\x00'.
|
||||
The offset field = data[1] + 2 = 0x7C + 2 = 0x7E = 126.
|
||||
|
||||
Write frame format: build_bw_write_frame (minimal DLE stuffing — only
|
||||
BW_CMD is doubled; all other bytes are RAW). The \\x10\\x03 sequence
|
||||
within the payload is preserved as-is (device interprets DLE+ETX as the
|
||||
literal value 0x03 per the inner-frame terminator convention).
|
||||
|
||||
Args:
|
||||
data: 127-byte write payload (read payload + \\x00\\x00 footer).
|
||||
Must start with [0x00][0x7C][...] (standard header).
|
||||
|
||||
Raises:
|
||||
ValueError: if data is not exactly 127 bytes or lacks expected header.
|
||||
ProtocolError: on timeout or wrong response SUB.
|
||||
"""
|
||||
if len(data) < 2:
|
||||
raise ValueError(f"call home write payload must be at least 2 bytes, got {len(data)}")
|
||||
rsp_sub_write = _expected_rsp_sub(SUB_CALL_HOME_WRITE) # 0xFF - 0x7E = 0x81
|
||||
rsp_sub_confirm = _expected_rsp_sub(SUB_CALL_HOME_CONFIRM) # 0xFF - 0x7F = 0x80
|
||||
|
||||
# Offset formula: data[1] + 2 (same pattern as other single-chunk writes)
|
||||
offset = data[1] + 2 # 0x7C + 2 = 0x7E = 126
|
||||
frame = build_bw_write_frame(SUB_CALL_HOME_WRITE, data, offset=offset)
|
||||
log.debug(
|
||||
"write_call_home_config: %d bytes data[1]=0x%02X offset=0x%04X",
|
||||
len(data), data[1], offset,
|
||||
)
|
||||
self._send(frame)
|
||||
self.recv_write_ack(expected_sub=rsp_sub_write)
|
||||
log.debug("write_call_home_config: write acked; sending confirm 0x7F")
|
||||
|
||||
confirm_frame = build_bw_write_frame(SUB_CALL_HOME_CONFIRM, b"")
|
||||
self._send(confirm_frame)
|
||||
self.recv_write_ack(expected_sub=rsp_sub_confirm)
|
||||
log.debug("write_call_home_config: confirm acked — done")
|
||||
|
||||
# ── Monitoring ────────────────────────────────────────────────────────────
|
||||
|
||||
def read_monitor_status(self) -> S3Frame:
|
||||
@@ -1231,6 +1403,63 @@ class MiniMateProtocol:
|
||||
log.debug("TX %d bytes: %s", len(frame), frame.hex())
|
||||
self._transport.write(frame)
|
||||
|
||||
def _recv_5a_batch(
|
||||
self,
|
||||
expected_sub: int,
|
||||
first_timeout: float = 10.0,
|
||||
batch_timeout: float = 0.5,
|
||||
) -> list[S3Frame]:
|
||||
"""
|
||||
Collect all S3 frames that arrive as part of one device response.
|
||||
|
||||
Over TCP via cellular modem, a single device A5 response (~1100 bytes of
|
||||
RS-232 data) is forwarded in multiple TCP segments due to the modem's
|
||||
data-forwarding timeout (~100-150 ms per segment). Each TCP segment
|
||||
contains a complete, valid S3 frame (~550 bytes). Calling _recv_one()
|
||||
once returns only the first segment's frame and misses the rest, causing
|
||||
the chunk request/response pairing to cascade out of alignment.
|
||||
|
||||
This helper collects ALL frames before returning, by trying additional
|
||||
short-timeout receives after the first frame arrives.
|
||||
|
||||
The caller must call self._parser.reset() before this method to ensure
|
||||
bytes_fed is accurate; this method always uses reset_parser=False.
|
||||
|
||||
Args:
|
||||
expected_sub: Expected SUB byte for validation.
|
||||
first_timeout: Timeout for the mandatory first frame. Should be
|
||||
generous (default 10 s) since the device may be slow.
|
||||
batch_timeout: Short timeout for subsequent frames. Default 0.5 s
|
||||
— comfortably longer than the modem forwarding gap
|
||||
(~150 ms) but short enough to avoid stalling when
|
||||
only one frame is expected (probe, terminator).
|
||||
|
||||
Returns:
|
||||
List of S3Frame objects in arrival order (at least one).
|
||||
|
||||
Raises:
|
||||
TimeoutError: If no frame arrives within first_timeout.
|
||||
UnexpectedResponse: If any frame has the wrong SUB byte.
|
||||
"""
|
||||
frames: list[S3Frame] = []
|
||||
first = self._recv_one(
|
||||
expected_sub=expected_sub,
|
||||
reset_parser=False,
|
||||
timeout=first_timeout,
|
||||
)
|
||||
frames.append(first)
|
||||
while True:
|
||||
try:
|
||||
extra = self._recv_one(
|
||||
expected_sub=expected_sub,
|
||||
reset_parser=False,
|
||||
timeout=batch_timeout,
|
||||
)
|
||||
frames.append(extra)
|
||||
except TimeoutError:
|
||||
break
|
||||
return frames
|
||||
|
||||
def _recv_one(
|
||||
self,
|
||||
expected_sub: Optional[int] = None,
|
||||
|
||||
+28
-10
@@ -33,7 +33,7 @@ STX = 0x02
|
||||
ETX = 0x03
|
||||
ACK = 0x41
|
||||
|
||||
__version__ = "0.2.2"
|
||||
__version__ = "0.2.3"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -227,17 +227,32 @@ def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]:
|
||||
trailer_end = trailer_start + trailer_len
|
||||
trailer = blob[trailer_start:trailer_end]
|
||||
|
||||
# For S3 mode we don't assume checksum type here yet.
|
||||
chk_valid = None
|
||||
chk_type = None
|
||||
chk_hex = None
|
||||
payload = bytes(body)
|
||||
|
||||
if len(body) >= 1:
|
||||
received_chk = body[-1]
|
||||
computed_chk = checksum8_sum(bytes(body[:-1]))
|
||||
if computed_chk == received_chk:
|
||||
chk_valid = True
|
||||
chk_type = "SUM8"
|
||||
chk_hex = f"{received_chk:02x}"
|
||||
payload = bytes(body[:-1])
|
||||
else:
|
||||
chk_valid = False
|
||||
|
||||
frames.append(Frame(
|
||||
index=idx,
|
||||
start_offset=start_offset,
|
||||
end_offset=end_offset,
|
||||
payload_raw=bytes(body),
|
||||
payload=bytes(body),
|
||||
payload=payload,
|
||||
trailer=trailer,
|
||||
checksum_valid=None,
|
||||
checksum_type=None,
|
||||
checksum_hex=None
|
||||
checksum_valid=chk_valid,
|
||||
checksum_type=chk_type,
|
||||
checksum_hex=chk_hex
|
||||
))
|
||||
|
||||
idx += 1
|
||||
@@ -298,10 +313,13 @@ def parse_bw(blob: bytes, trailer_len: int, validate_checksum: bool) -> List[Fra
|
||||
|
||||
if b == ETX:
|
||||
# Candidate end-of-frame.
|
||||
# Accept ETX if the next bytes look like a real next-frame start (ACK+STX),
|
||||
# or we're at EOF. This prevents chopping on in-payload 0x03.
|
||||
next_is_start = (i + 2 < n and blob[i + 1] == ACK and blob[i + 2] == STX)
|
||||
at_eof = (i == n - 1)
|
||||
# Skip any SESSION_RESET (41 03) sequences — sent before POLL to wake
|
||||
# monitoring units — to find the real next frame start (ACK+STX).
|
||||
j = i + 1
|
||||
while j + 1 < n and blob[j] == ACK and blob[j + 1] == ETX:
|
||||
j += 2
|
||||
next_is_start = (j + 1 < n and blob[j] == ACK and blob[j + 1] == STX)
|
||||
at_eof = (i == n - 1) or (j >= n)
|
||||
|
||||
if not (next_is_start or at_eof):
|
||||
# Not a real boundary -> payload byte
|
||||
|
||||
+189
-34
@@ -97,16 +97,24 @@ class AnalyzerState:
|
||||
class BridgePanel(tk.Frame):
|
||||
"""
|
||||
All bridge controls and live log output.
|
||||
Calls on_bridge_started(raw_bw_path, raw_s3_path) when the bridge starts
|
||||
so the parent can wire up the Analyzer.
|
||||
Calls on_bridge_started(struct_bin_path) when the bridge starts.
|
||||
Calls on_capture_started(bw_path, s3_path, label) when a capture begins.
|
||||
Calls on_capture_complete(bw_path, s3_path, label) when a capture ends.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, **kw):
|
||||
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped,
|
||||
on_capture_started=None, on_capture_complete=None, **kw):
|
||||
super().__init__(parent, bg=BG2, **kw)
|
||||
self._on_started = on_bridge_started # signature: (raw_bw, raw_s3, struct_bin)
|
||||
self._on_stopped = on_bridge_stopped
|
||||
self._on_started = on_bridge_started # signature: (struct_bin)
|
||||
self._on_stopped = on_bridge_stopped
|
||||
self._on_cap_started = on_capture_started # (bw, s3, label)
|
||||
self._on_cap_complete = on_capture_complete # (bw, s3, label)
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self._stdout_q: queue.Queue[str] = queue.Queue()
|
||||
# Capture state
|
||||
self._capturing = False
|
||||
self._cap_label: Optional[str] = None
|
||||
self._cap_history: list[dict] = [] # {label, status, bw, s3}
|
||||
self._build()
|
||||
self._poll_stdout()
|
||||
|
||||
@@ -146,17 +154,7 @@ class BridgePanel(tk.Frame):
|
||||
tk.Button(cfg, text="Browse", bg=BG3, fg=FG, relief="flat", cursor="hand2",
|
||||
font=MONO, command=self._choose_dir).grid(row=1, column=5, **pad)
|
||||
|
||||
# Row 2: raw taps (always enabled — timestamped names generated at start)
|
||||
self._raw_bw_on = tk.BooleanVar(value=True)
|
||||
self._raw_s3_on = tk.BooleanVar(value=True)
|
||||
tk.Checkbutton(cfg, text="Capture BW->S3 raw", variable=self._raw_bw_on,
|
||||
bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2,
|
||||
font=MONO).grid(row=2, column=0, columnspan=2, sticky="w", **pad)
|
||||
tk.Checkbutton(cfg, text="Capture S3->BW raw", variable=self._raw_s3_on,
|
||||
bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2,
|
||||
font=MONO).grid(row=2, column=2, columnspan=2, sticky="w", **pad)
|
||||
|
||||
# Row 3: buttons + status
|
||||
# Row 2: buttons + status
|
||||
btn_row = tk.Frame(self, bg=BG2)
|
||||
btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2)
|
||||
|
||||
@@ -170,6 +168,18 @@ class BridgePanel(tk.Frame):
|
||||
command=self.stop_bridge, state="disabled")
|
||||
self.stop_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,
|
||||
relief="flat", padx=10, cursor="hand2", font=MONO,
|
||||
command=self.add_mark, state="disabled")
|
||||
@@ -179,9 +189,34 @@ class BridgePanel(tk.Frame):
|
||||
tk.Label(btn_row, textvariable=self.status_var,
|
||||
bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=10)
|
||||
|
||||
# Capture history panel
|
||||
hist_outer = tk.Frame(self, bg=BG2)
|
||||
hist_outer.pack(side=tk.TOP, fill=tk.X, padx=4, pady=(2, 0))
|
||||
|
||||
tk.Label(hist_outer, text="Captures:", bg=BG2, fg=FG_DIM,
|
||||
font=MONO_SM, anchor="w").pack(side=tk.LEFT, padx=(4, 6))
|
||||
|
||||
hist_inner = tk.Frame(hist_outer, bg=BG2)
|
||||
hist_inner.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
|
||||
self._hist_lb = tk.Listbox(
|
||||
hist_inner, bg=BG3, fg=FG, font=MONO_SM,
|
||||
height=3, relief="flat", selectbackground=BG,
|
||||
selectforeground=ACCENT, activestyle="none",
|
||||
highlightthickness=0,
|
||||
)
|
||||
hist_vsb = ttk.Scrollbar(hist_inner, orient="vertical", command=self._hist_lb.yview)
|
||||
self._hist_lb.configure(yscrollcommand=hist_vsb.set)
|
||||
hist_vsb.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self._hist_lb.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
self._hist_lb.bind("<Double-Button-1>", self._on_hist_dblclick)
|
||||
|
||||
tk.Label(hist_outer, text="dbl-click to reload", bg=BG2, fg=FG_DIM,
|
||||
font=MONO_SM, anchor="e").pack(side=tk.RIGHT, padx=6)
|
||||
|
||||
# Log output
|
||||
self.log_view = scrolledtext.ScrolledText(
|
||||
self, height=18, font=MONO_SM,
|
||||
self, height=14, font=MONO_SM,
|
||||
bg=BG, fg=FG, insertbackground=FG,
|
||||
relief="flat", state="disabled",
|
||||
)
|
||||
@@ -221,14 +256,8 @@ class BridgePanel(tk.Frame):
|
||||
|
||||
args = [sys.executable, str(BRIDGE_PATH),
|
||||
"--bw", bw, "--s3", s3, "--baud", baud, "--logdir", logdir]
|
||||
|
||||
raw_bw_path = raw_s3_path = None
|
||||
if self._raw_bw_on.get():
|
||||
raw_bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin")
|
||||
args += ["--raw-bw", raw_bw_path]
|
||||
if self._raw_s3_on.get():
|
||||
raw_s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin")
|
||||
args += ["--raw-s3", raw_s3_path]
|
||||
# Raw BW/S3 taps are NOT opened at bridge start.
|
||||
# Use "New Capture" to start a labeled tap on demand.
|
||||
|
||||
# Structured bin path — written by bridge automatically, named by ts
|
||||
struct_bin_path = os.path.join(logdir, f"s3_session_{ts}.bin")
|
||||
@@ -250,11 +279,12 @@ class BridgePanel(tk.Frame):
|
||||
self.status_var.set(f"Running — {bw} <-> {s3}")
|
||||
self.start_btn.configure(state="disabled")
|
||||
self.stop_btn.configure(state="normal", bg=RED)
|
||||
self.mark_btn.configure(state="normal")
|
||||
self.cap_btn.configure(state="normal")
|
||||
self._append_log(f"== Bridge started [{ts}] ==\n")
|
||||
self._append_log(" Click 'New Capture' when ready to record a setting change.\n")
|
||||
|
||||
# Notify parent so Analyzer can wire up live mode
|
||||
self._on_started(raw_bw_path, raw_s3_path, struct_bin_path)
|
||||
# Notify parent — no raw files yet, just the structured bin path
|
||||
self._on_started(struct_bin_path)
|
||||
|
||||
def stop_bridge(self) -> None:
|
||||
if self.process and self.process.poll() is None:
|
||||
@@ -270,7 +300,11 @@ class BridgePanel(tk.Frame):
|
||||
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:
|
||||
@@ -288,12 +322,120 @@ class BridgePanel(tk.Frame):
|
||||
self._bridge_ended()
|
||||
self._on_stopped()
|
||||
break
|
||||
|
||||
stripped = line.strip()
|
||||
|
||||
# Handle capture lifecycle events from bridge
|
||||
if stripped.startswith("[CAP_START] ") and "\t" in stripped:
|
||||
parts = stripped[12:].split("\t", 1)
|
||||
if len(parts) == 2:
|
||||
bw_path, s3_path = parts[0].strip(), parts[1].strip()
|
||||
self._on_cap_started_msg(bw_path, s3_path)
|
||||
|
||||
elif stripped.startswith("[CAP_STOP] ") and "\t" in stripped:
|
||||
parts = stripped[11:].split("\t", 1)
|
||||
if len(parts) == 2:
|
||||
bw_path, s3_path = parts[0].strip(), parts[1].strip()
|
||||
self._on_cap_stopped_msg(bw_path, s3_path)
|
||||
|
||||
self._append_log(line)
|
||||
except queue.Empty:
|
||||
pass
|
||||
finally:
|
||||
self.after(100, self._poll_stdout)
|
||||
|
||||
# ── capture control ───────────────────────────────────────────────────
|
||||
|
||||
def _start_capture(self) -> None:
|
||||
"""Ask for a label and tell the bridge to start writing raw tap files."""
|
||||
if not self.process or self.process.poll() is not None:
|
||||
return
|
||||
label = simpledialog.askstring(
|
||||
"New Capture",
|
||||
"Label for this capture\n(e.g. 'recording_mode_continuous').\nLeave blank for timestamp only:",
|
||||
parent=self,
|
||||
)
|
||||
if label is None:
|
||||
return # user hit Cancel
|
||||
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
|
||||
@@ -1884,6 +2026,8 @@ class SeismoLab(tk.Tk):
|
||||
nb,
|
||||
on_bridge_started=self._on_bridge_started,
|
||||
on_bridge_stopped=self._on_bridge_stopped,
|
||||
on_capture_started=self._on_capture_started,
|
||||
on_capture_complete=self._on_capture_complete,
|
||||
)
|
||||
nb.add(self._bridge_panel, text=" Bridge ")
|
||||
|
||||
@@ -1905,16 +2049,27 @@ class SeismoLab(tk.Tk):
|
||||
self._nb = nb
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
def _on_bridge_started(self, raw_bw: Optional[str], raw_s3: Optional[str],
|
||||
struct_bin: Optional[str] = None) -> None:
|
||||
"""Bridge started — inject paths into analyzer and start live mode."""
|
||||
self._analyzer_panel.set_live_files(raw_bw, raw_s3, struct_bin)
|
||||
# Switch to Analyzer tab so the user can watch it update
|
||||
self._nb.select(1)
|
||||
def _on_bridge_started(self, struct_bin: Optional[str] = None) -> None:
|
||||
"""Bridge started — stash the structured bin path; stay on Bridge tab."""
|
||||
if struct_bin:
|
||||
self._analyzer_panel.bin_var.set(struct_bin)
|
||||
|
||||
def _on_bridge_stopped(self) -> None:
|
||||
self._analyzer_panel.stop_live()
|
||||
|
||||
def _on_capture_started(self, bw_path: str, s3_path: str, label: str) -> None:
|
||||
"""A capture began — wire up live mode in the Analyzer and switch tabs."""
|
||||
self._analyzer_panel.set_live_files(bw_path, s3_path)
|
||||
self._nb.select(1)
|
||||
|
||||
def _on_capture_complete(self, bw_path: str, s3_path: str, label: str) -> None:
|
||||
"""A capture stopped — stop live mode, run full analysis, switch to Analyzer."""
|
||||
self._analyzer_panel.stop_live()
|
||||
self._analyzer_panel.s3_var.set(s3_path)
|
||||
self._analyzer_panel.bw_var.set(bw_path)
|
||||
self._analyzer_panel._run_analyze()
|
||||
self._nb.select(1)
|
||||
|
||||
def _on_console_send_to_analyzer(self, raw_s3_path: str) -> None:
|
||||
"""Console captured bytes → inject into Analyzer S3 field and switch tab."""
|
||||
self._analyzer_panel.s3_var.set(raw_s3_path)
|
||||
|
||||
+286
-16
@@ -59,8 +59,9 @@ except ImportError:
|
||||
|
||||
from minimateplus import MiniMateClient
|
||||
from minimateplus.protocol import ProtocolError
|
||||
from minimateplus.models import ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
|
||||
from minimateplus.models import CallHomeConfig, ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
|
||||
from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT
|
||||
from minimateplus.blastware_file import write_blastware_file, blastware_filename
|
||||
from sfm.cache import SFMCache, get_cache
|
||||
from sfm.database import SeismoDb
|
||||
|
||||
@@ -285,11 +286,14 @@ def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[d
|
||||
if cc is None:
|
||||
return None
|
||||
return {
|
||||
"record_time": cc.record_time,
|
||||
"sample_rate": cc.sample_rate,
|
||||
"recording_mode": cc.recording_mode, # 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
|
||||
"sample_rate": cc.sample_rate,
|
||||
"histogram_interval_sec": cc.histogram_interval_sec, # seconds; None if not Histogram mode
|
||||
"record_time": cc.record_time,
|
||||
"trigger_level_geo": cc.trigger_level_geo,
|
||||
"alarm_level_geo": cc.alarm_level_geo,
|
||||
"max_range_geo": cc.max_range_geo,
|
||||
"geo_adc_scale": cc.geo_adc_scale, # hw scale factor (in/s)/V — informational only, do not write
|
||||
"geo_range": cc.geo_range, # CONFIRMED 2026-04-20: 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
|
||||
"setup_name": cc.setup_name,
|
||||
"project": cc.project,
|
||||
"client": cc.client,
|
||||
@@ -299,6 +303,27 @@ def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[d
|
||||
}
|
||||
|
||||
|
||||
def _serialise_call_home_config(ch: Optional["CallHomeConfig"]) -> Optional[dict]:
|
||||
if ch is None:
|
||||
return None
|
||||
return {
|
||||
"auto_call_home_enabled": ch.auto_call_home_enabled,
|
||||
"dial_string": ch.dial_string,
|
||||
"after_event_recorded": ch.after_event_recorded,
|
||||
"at_specified_times": ch.at_specified_times,
|
||||
"time1_enabled": ch.time1_enabled,
|
||||
"time1_hour": ch.time1_hour,
|
||||
"time1_min": ch.time1_min,
|
||||
"time2_enabled": ch.time2_enabled,
|
||||
"time2_hour": ch.time2_hour,
|
||||
"time2_min": ch.time2_min,
|
||||
"num_retries": ch.num_retries,
|
||||
"time_between_retries_sec": ch.time_between_retries_sec,
|
||||
"wait_for_connection_sec": ch.wait_for_connection_sec,
|
||||
"warm_up_time_sec": ch.warm_up_time_sec,
|
||||
}
|
||||
|
||||
|
||||
def _serialise_device_info(info: DeviceInfo) -> dict:
|
||||
return {
|
||||
"serial": info.serial,
|
||||
@@ -824,6 +849,109 @@ def device_event_waveform(
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/device/event/{index}/blastware_file")
|
||||
def device_event_blastware_file(
|
||||
index: int,
|
||||
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
) -> FileResponse:
|
||||
"""
|
||||
Download the waveform for a single event (0-based index) and return it
|
||||
as a Blastware-compatible binary file with a correct Blastware filename.
|
||||
|
||||
Supply either *port* (serial) or *host* (TCP/modem).
|
||||
|
||||
The file is written to /tmp and streamed back as a binary download.
|
||||
Blastware can open it directly — filename encodes serial + timestamp.
|
||||
|
||||
Filename format: <prefix><serial3><stem><AB>0<W|H>
|
||||
- prefix letter = chr(ord('B') + floor(serial_numeric / 1000))
|
||||
- stem + AB = second-resolution timestamp since 1985-01-01 local
|
||||
- W / H = Full Waveform / Full Histogram (defaults to W for
|
||||
triggered events; histogram requires recording_mode
|
||||
to be populated from compliance config)
|
||||
|
||||
Performs: POLL startup → get_events(full_waveform=False, extra_chunks=1,
|
||||
stop_after_index=index) → write_blastware_file() → FileResponse.
|
||||
"""
|
||||
log.info(
|
||||
"GET /device/event/%d/blastware_file port=%s host=%s",
|
||||
index, port, host,
|
||||
)
|
||||
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
|
||||
info = client.connect()
|
||||
# Use stop_after_metadata=True (full_waveform=False) with 1 extra
|
||||
# chunk after "Project:". The extra chunk primes the device so that
|
||||
# the termination response carries the full waveform footer bytes.
|
||||
# Without it the terminator returns only ~90 bytes (no useful footer).
|
||||
#
|
||||
# The extra chunk's ADC data IS part of the Blastware file body —
|
||||
# confirmed from 4-27-26 TCP capture: all 14 A5 frames (including the
|
||||
# extra chunk's 2 TCP sub-frames) contribute to the correct 6864-byte
|
||||
# output. write_blastware_file() includes all frames unconditionally.
|
||||
#
|
||||
# full_waveform=True (natural end-of-stream) downloads ALL chunks
|
||||
# including post-event silence (35+ chunks for a 9-sec event at
|
||||
# 1024 sps) — this produces 24KB+ files that Blastware rejects.
|
||||
events = client.get_events(
|
||||
full_waveform=False,
|
||||
stop_after_index=index,
|
||||
extra_chunks_after_metadata=1,
|
||||
)
|
||||
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))
|
||||
except HTTPException:
|
||||
raise
|
||||
except ProtocolError as exc:
|
||||
log.error("blastware_file: protocol error: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
|
||||
except OSError as exc:
|
||||
log.error("blastware_file: connection error: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
|
||||
except Exception as exc:
|
||||
log.error("blastware_file: unexpected error: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
if ev is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Event index {index} not found on device",
|
||||
)
|
||||
|
||||
a5_frames = getattr(ev, "_a5_frames", None)
|
||||
if not a5_frames:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"No waveform data received for event index {index} — 5A download failed",
|
||||
)
|
||||
|
||||
# Determine serial number from device info
|
||||
serial = getattr(info, "serial", None) or "UNKNOWN"
|
||||
|
||||
# Build filename using the same algorithm Blastware uses
|
||||
filename = blastware_filename(ev, serial)
|
||||
|
||||
# Write to /tmp so FastAPI can stream it back
|
||||
out_path = Path("/tmp") / filename
|
||||
write_blastware_file(ev, a5_frames, out_path)
|
||||
log.info(
|
||||
"blastware_file: wrote %s (%d A5 frames, serial=%s)",
|
||||
out_path, len(a5_frames), serial,
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=str(out_path),
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
# ── Write endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
class DeviceConfigBody(BaseModel):
|
||||
@@ -835,16 +963,15 @@ class DeviceConfigBody(BaseModel):
|
||||
|
||||
Recording parameters
|
||||
--------------------
|
||||
sample_rate : Samples per second. Valid values: 1024, 2048, 4096.
|
||||
record_time : Record duration in seconds (e.g. 1.0, 2.0, 3.0).
|
||||
recording_mode : Recording mode enum. Values: 0=Single Shot, 1=Continuous, 3=Histogram, 4=Histogram+Continuous.
|
||||
sample_rate : Samples per second. Valid values: 1024, 2048, 4096.
|
||||
record_time : Record duration in seconds (e.g. 1.0, 2.0, 3.0).
|
||||
|
||||
Trigger / alarm thresholds (geo channels, in/s)
|
||||
------------------------------------------------
|
||||
Trigger / alarm thresholds and range (geo channels)
|
||||
----------------------------------------------------
|
||||
trigger_level_geo : Trigger threshold in in/s (e.g. 0.5).
|
||||
alarm_level_geo : Alarm threshold in in/s (e.g. 1.0).
|
||||
max_range_geo : Full-scale calibration constant (e.g. 6.206).
|
||||
Rarely changed — only set if you know what you're doing.
|
||||
|
||||
geo_range : Geophone range/sensitivity. 0=Normal 10.000 in/s, 1=Sensitive 1.250 in/s.
|
||||
Project / operator strings (max 41 ASCII characters each)
|
||||
----------------------------
|
||||
project : Project description.
|
||||
@@ -854,12 +981,14 @@ class DeviceConfigBody(BaseModel):
|
||||
notes : Extended notes.
|
||||
"""
|
||||
# Recording parameters
|
||||
sample_rate: Optional[int] = None
|
||||
record_time: Optional[float] = None
|
||||
# Threshold parameters
|
||||
recording_mode: Optional[int] = None
|
||||
sample_rate: Optional[int] = None
|
||||
record_time: Optional[float] = None
|
||||
histogram_interval_sec: Optional[int] = None # seconds: 2, 5, 15, 60, 300, 900 (mode-gated)
|
||||
# Threshold parameters / geo range
|
||||
trigger_level_geo: Optional[float] = None
|
||||
alarm_level_geo: Optional[float] = None
|
||||
max_range_geo: Optional[float] = None
|
||||
geo_range: Optional[int] = None # 0=Normal 10.000 in/s, 1=Sensitive 1.250 in/s
|
||||
# Project / operator strings
|
||||
project: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
@@ -887,6 +1016,7 @@ def device_config(
|
||||
|
||||
Example body (all fields optional — include only what you want to change):
|
||||
{
|
||||
"recording_mode": 1,
|
||||
"sample_rate": 1024,
|
||||
"record_time": 3.0,
|
||||
"trigger_level_geo": 0.5,
|
||||
@@ -914,11 +1044,13 @@ def device_config(
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
client.connect()
|
||||
client.apply_config(
|
||||
recording_mode=body.recording_mode,
|
||||
sample_rate=body.sample_rate,
|
||||
record_time=body.record_time,
|
||||
histogram_interval_sec=body.histogram_interval_sec,
|
||||
trigger_level_geo=body.trigger_level_geo,
|
||||
alarm_level_geo=body.alarm_level_geo,
|
||||
max_range_geo=body.max_range_geo,
|
||||
geo_range=body.geo_range,
|
||||
project=body.project,
|
||||
client_name=body.client_name,
|
||||
operator=body.operator,
|
||||
@@ -1068,6 +1200,144 @@ def device_monitor_stop(
|
||||
return {"status": "stopped"}
|
||||
|
||||
|
||||
# ── Call home config endpoints ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/device/call_home")
|
||||
def device_call_home_get(
|
||||
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
) -> dict:
|
||||
"""
|
||||
Read the Auto Call Home (ACH) configuration from the device.
|
||||
|
||||
Sends SUB 0x2C (two-step read) and returns the decoded call home config.
|
||||
|
||||
Confirmed from 4-20-26 call home settings captures (BE11529).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"auto_call_home_enabled": true/false,
|
||||
"dial_string": "RADIO RING",
|
||||
"after_event_recorded": true/false,
|
||||
"at_specified_times": true/false,
|
||||
"time1_enabled": true/false, "time1_hour": 19, "time1_min": 55,
|
||||
"time2_enabled": false, "time2_hour": 0, "time2_min": 0,
|
||||
"num_retries": 3,
|
||||
"time_between_retries_sec": 15,
|
||||
"wait_for_connection_sec": 60,
|
||||
"warm_up_time_sec": 60
|
||||
}
|
||||
"""
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
client.poll()
|
||||
return client.get_call_home_config()
|
||||
ch_config = _run_with_retry(_do, is_tcp=_is_tcp(host))
|
||||
except HTTPException:
|
||||
raise
|
||||
except ProtocolError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
return _serialise_call_home_config(ch_config) or {}
|
||||
|
||||
|
||||
class CallHomeConfigBody(BaseModel):
|
||||
"""
|
||||
Request body for POST /device/call_home.
|
||||
|
||||
All fields are optional — only supplied (non-null) fields are modified.
|
||||
All other call home config bytes are round-tripped verbatim from the device.
|
||||
|
||||
Confirmed writable fields (4-20-26 captures):
|
||||
auto_call_home_enabled : bool — master enable for auto call home
|
||||
after_event_recorded : bool — call home after each triggered event
|
||||
at_specified_times : bool — enable time-based scheduled calls
|
||||
time1_enabled : bool — enable time slot 1
|
||||
time1_hour : int — hour for slot 1 (0-23; avoid 3 — DLE escape limitation)
|
||||
time1_min : int — minute for slot 1 (0-59; avoid 3)
|
||||
time2_enabled : bool — enable time slot 2
|
||||
time2_hour : int — hour for slot 2 (0-23; avoid 3)
|
||||
time2_min : int — minute for slot 2 (0-59; avoid 3)
|
||||
|
||||
Read-only fields (not writable via this endpoint):
|
||||
dial_string, num_retries, time_between_retries_sec,
|
||||
wait_for_connection_sec, warm_up_time_sec
|
||||
"""
|
||||
auto_call_home_enabled: Optional[bool] = None
|
||||
after_event_recorded: Optional[bool] = None
|
||||
at_specified_times: Optional[bool] = None
|
||||
time1_enabled: Optional[bool] = None
|
||||
time1_hour: Optional[int] = None
|
||||
time1_min: Optional[int] = None
|
||||
time2_enabled: Optional[bool] = None
|
||||
time2_hour: Optional[int] = None
|
||||
time2_min: Optional[int] = None
|
||||
|
||||
|
||||
@app.post("/device/call_home")
|
||||
def device_call_home_set(
|
||||
body: CallHomeConfigBody,
|
||||
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
|
||||
baud: int = Query(38400, description="Serial baud rate"),
|
||||
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
|
||||
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
|
||||
) -> dict:
|
||||
"""
|
||||
Read the current call home config, apply supplied changes, and write back.
|
||||
|
||||
Only non-null fields are modified. All other bytes round-trip verbatim.
|
||||
|
||||
Write sequence (confirmed from 4-20-26 call home settings captures):
|
||||
SUB 0x2C (read 2-step) → 125-byte raw payload
|
||||
patch fields
|
||||
SUB 0x7E (write 127-byte payload) → ack 0x81
|
||||
SUB 0x7F (confirm) → ack 0x80
|
||||
|
||||
Example body:
|
||||
{ "auto_call_home_enabled": true, "after_event_recorded": true,
|
||||
"time1_enabled": true, "time1_hour": 20, "time1_min": 0 }
|
||||
"""
|
||||
changed = body.model_dump(exclude_none=True)
|
||||
log.info("POST /device/call_home port=%s host=%s fields=%s", port, host, list(changed.keys()))
|
||||
|
||||
try:
|
||||
def _do():
|
||||
with _build_client(port, baud, host, tcp_port) as client:
|
||||
client.poll()
|
||||
client.set_call_home_config(
|
||||
auto_call_home_enabled=body.auto_call_home_enabled,
|
||||
after_event_recorded=body.after_event_recorded,
|
||||
at_specified_times=body.at_specified_times,
|
||||
time1_enabled=body.time1_enabled,
|
||||
time1_hour=body.time1_hour,
|
||||
time1_min=body.time1_min,
|
||||
time2_enabled=body.time2_enabled,
|
||||
time2_hour=body.time2_hour,
|
||||
time2_min=body.time2_min,
|
||||
)
|
||||
_run_with_retry(_do, is_tcp=_is_tcp(host))
|
||||
except HTTPException:
|
||||
raise
|
||||
except ProtocolError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
|
||||
|
||||
return {"status": "ok", "updated_fields": changed}
|
||||
|
||||
|
||||
# ── Cache management endpoints ────────────────────────────────────────────────
|
||||
|
||||
@app.get("/cache/stats")
|
||||
|
||||
+308
-19
@@ -736,9 +736,10 @@
|
||||
|
||||
<!-- ── Live tab bar ───────────────────────────────────────────────── -->
|
||||
<div class="tab-bar" id="live-tab-bar">
|
||||
<button class="tab-btn active" data-tab="device" onclick="switchTab('device')">Device</button>
|
||||
<button class="tab-btn" data-tab="events" onclick="switchTab('events')">Events</button>
|
||||
<button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</button>
|
||||
<button class="tab-btn active" data-tab="device" onclick="switchTab('device')">Device</button>
|
||||
<button class="tab-btn" data-tab="events" onclick="switchTab('events')">Events</button>
|
||||
<button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</button>
|
||||
<button class="tab-btn" data-tab="call-home" onclick="switchTab('call-home')">Call Home</button>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
@@ -803,6 +804,17 @@
|
||||
<div class="cfg-section">
|
||||
<div class="cfg-section-title">Recording</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Recording Mode</label>
|
||||
<select id="cfg-recording-mode">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="0">Single Shot</option>
|
||||
<option value="1">Continuous</option>
|
||||
<option value="3">Histogram</option>
|
||||
<option value="4">Histogram + Continuous</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Sample Rate</label>
|
||||
<select id="cfg-sample-rate">
|
||||
@@ -813,6 +825,20 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Histogram Interval</label>
|
||||
<select id="cfg-histogram-interval">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="2">2 seconds</option>
|
||||
<option value="5">5 seconds</option>
|
||||
<option value="15">15 seconds</option>
|
||||
<option value="60">1 minute</option>
|
||||
<option value="300">5 minutes</option>
|
||||
<option value="900">15 minutes</option>
|
||||
</select>
|
||||
<div class="hint">Only active in Histogram / Histogram + Continuous mode</div>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Record Time (seconds)</label>
|
||||
<input type="number" id="cfg-record-time" step="0.5" min="0.5" max="60" placeholder="e.g. 3.0" />
|
||||
@@ -832,10 +858,15 @@
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Max Range — Geo (in/s)</label>
|
||||
<input type="number" id="cfg-max-range" step="0.001" min="0.001" placeholder="e.g. 6.206" />
|
||||
<div class="hint">Full-scale calibration constant — only change if you have a cal cert</div>
|
||||
<label>Maximum Range — Geo</label>
|
||||
<select id="cfg-geo-range">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="0">Normal — 10.000 in/s</option>
|
||||
<option value="1">Sensitive — 1.250 in/s</option>
|
||||
</select>
|
||||
<div class="hint">Geophone sensitivity (applies to Tran / Vert / Long channels)</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Project / operator strings -->
|
||||
@@ -879,6 +910,123 @@
|
||||
|
||||
</div><!-- end #tab-config -->
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
TAB: Call Home
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<div id="tab-call-home" class="tab-pane">
|
||||
|
||||
<div class="cfg-grid">
|
||||
|
||||
<!-- Enable / dial -->
|
||||
<div class="cfg-section">
|
||||
<div class="cfg-section-title">Auto Call Home</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Enable Auto Call Home</label>
|
||||
<select id="ch-enabled">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Dial String</label>
|
||||
<input type="text" id="ch-dial-string" disabled placeholder="Read-only (e.g. RADIO RING)" />
|
||||
<div class="hint">Read from device — not writable via this interface</div>
|
||||
</div>
|
||||
|
||||
<div class="cfg-section-title" style="margin-top:16px">When to Call</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>After Event Recorded</label>
|
||||
<select id="ch-after-event">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>At Specified Times</label>
|
||||
<select id="ch-at-times">
|
||||
<option value="">— unchanged —</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Scheduled call times -->
|
||||
<div class="cfg-section">
|
||||
<div class="cfg-section-title">Scheduled Call Times</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Time Slot 1</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select id="ch-t1-enabled" style="width:120px">
|
||||
<option value="">— enable —</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
<input type="number" id="ch-t1-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
|
||||
<span>:</span>
|
||||
<input type="number" id="ch-t1-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
|
||||
</div>
|
||||
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Time Slot 2</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select id="ch-t2-enabled" style="width:120px">
|
||||
<option value="">— enable —</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
<input type="number" id="ch-t2-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
|
||||
<span>:</span>
|
||||
<input type="number" id="ch-t2-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
|
||||
</div>
|
||||
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
|
||||
</div>
|
||||
|
||||
<div class="cfg-section-title" style="margin-top:16px">Retry Settings (read-only)</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Number of Retries</label>
|
||||
<input type="text" id="ch-num-retries" disabled placeholder="—" />
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Time Between Retries (s)</label>
|
||||
<input type="text" id="ch-retry-gap" disabled placeholder="—" />
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Wait for Connection (s)</label>
|
||||
<input type="text" id="ch-wait-conn" disabled placeholder="—" />
|
||||
</div>
|
||||
|
||||
<div class="cfg-field">
|
||||
<label>Warm-up Time (s)</label>
|
||||
<input type="text" id="ch-warmup" disabled placeholder="—" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="cfg-actions">
|
||||
<button class="btn btn-ghost" id="ch-read-btn" onclick="readCallHome()" disabled>Read from Device</button>
|
||||
<button class="btn btn-success" id="ch-write-btn" onclick="writeCallHome()" disabled>Write to Device</button>
|
||||
<button class="btn btn-ghost" onclick="clearCallHomeForm()">Clear Form</button>
|
||||
<span id="ch-status"></span>
|
||||
</div>
|
||||
|
||||
</div><!-- end #tab-call-home -->
|
||||
|
||||
</div><!-- end #section-live -->
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
@@ -1037,7 +1185,7 @@ let unitInfo = null;
|
||||
let eventList = [];
|
||||
let currentEvent = 0;
|
||||
let charts = {};
|
||||
let geoRange = 6.206;
|
||||
let geoAdcScale = 6.206;
|
||||
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
|
||||
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
|
||||
|
||||
@@ -1162,6 +1310,8 @@ async function connectUnit() {
|
||||
document.getElementById('next-btn').disabled = eventList.length <= 1;
|
||||
document.getElementById('cfg-read-btn').disabled = false;
|
||||
document.getElementById('cfg-write-btn').disabled = false;
|
||||
document.getElementById('ch-read-btn').disabled = false;
|
||||
document.getElementById('ch-write-btn').disabled = false;
|
||||
|
||||
btn.disabled = false; btn.textContent = 'Reconnect';
|
||||
|
||||
@@ -1189,7 +1339,7 @@ function populateDeviceBar() {
|
||||
qs('di-project').textContent = cc.project || '—';
|
||||
qs('di-client').textContent = cc.client || '—';
|
||||
qs('di-operator').textContent = cc.operator || '—';
|
||||
geoRange = cc.max_range_geo ?? 6.206;
|
||||
geoAdcScale = cc.geo_adc_scale ?? 6.206;
|
||||
}
|
||||
|
||||
// ── Monitoring ─────────────────────────────────────────────────────────────────
|
||||
@@ -1326,12 +1476,16 @@ function populateDeviceTab() {
|
||||
|
||||
// Compliance table
|
||||
const cc = unitInfo.compliance_config || {};
|
||||
const RECORDING_MODE_LABELS = {0: 'Single Shot', 1: 'Continuous', 3: 'Histogram', 4: 'Histogram + Continuous'};
|
||||
const complianceRows = [
|
||||
['Recording Mode', cc.recording_mode != null ? (RECORDING_MODE_LABELS[cc.recording_mode] || `0x${cc.recording_mode.toString(16).padStart(2,'0')}`) : '—'],
|
||||
['Sample Rate', cc.sample_rate != null ? `${cc.sample_rate} sps` : '—'],
|
||||
['Histogram Interval', cc.histogram_interval_sec != null ? (() => { const s = cc.histogram_interval_sec; return s < 60 ? `${s}s` : `${s/60}m`; })() : '—'],
|
||||
['Record Time', cc.record_time != null ? `${cc.record_time.toFixed(2)} s` : '—'],
|
||||
['Trigger Level (geo)', cc.trigger_level_geo != null ? `${cc.trigger_level_geo.toFixed(4)} in/s` : '—'],
|
||||
['Alarm Level (geo)', cc.alarm_level_geo != null ? `${cc.alarm_level_geo.toFixed(4)} in/s` : '—'],
|
||||
['Max Range (geo)', cc.max_range_geo != null ? `${cc.max_range_geo.toFixed(4)} in/s` : '—'],
|
||||
['Max Range (geo)', cc.geo_range != null ? (cc.geo_range === 0 ? 'Normal — 10.000 in/s' : cc.geo_range === 1 ? 'Sensitive — 1.250 in/s' : `0x${cc.geo_range.toString(16).padStart(2,'0')}`) : '—'],
|
||||
['ADC Scale Factor (geo)', cc.geo_adc_scale != null ? `${cc.geo_adc_scale.toFixed(4)} in/s` : '—'],
|
||||
['Setup Name', cc.setup_name || '—'],
|
||||
];
|
||||
renderTable('compliance-table', complianceRows);
|
||||
@@ -1362,11 +1516,13 @@ function renderTable(id, rows) {
|
||||
function populateConfigFromDeviceInfo() {
|
||||
if (!unitInfo) return;
|
||||
const cc = unitInfo.compliance_config || {};
|
||||
if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
|
||||
if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1));
|
||||
if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4));
|
||||
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4));
|
||||
if (cc.max_range_geo != null) qs('cfg-max-range',cc.max_range_geo.toFixed(4));
|
||||
if (cc.recording_mode != null) qs('cfg-recording-mode', String(cc.recording_mode));
|
||||
if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
|
||||
if (cc.histogram_interval_sec != null) qs('cfg-histogram-interval', String(cc.histogram_interval_sec));
|
||||
if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1));
|
||||
if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4));
|
||||
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4));
|
||||
if (cc.geo_range != null) qs('cfg-geo-range', String(cc.geo_range));
|
||||
if (cc.project) qs('cfg-project', cc.project);
|
||||
if (cc.client) qs('cfg-client', cc.client);
|
||||
if (cc.operator) qs('cfg-operator', cc.operator);
|
||||
@@ -1375,8 +1531,9 @@ function populateConfigFromDeviceInfo() {
|
||||
}
|
||||
|
||||
function clearConfigForm() {
|
||||
['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm','cfg-max-range',
|
||||
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes']
|
||||
['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm',
|
||||
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes',
|
||||
'cfg-recording-mode','cfg-histogram-interval','cfg-geo-range']
|
||||
.forEach(id => { const el = qs(id); el.tagName === 'SELECT' ? el.selectedIndex = 0 : el.value = ''; });
|
||||
setCfgStatus('');
|
||||
}
|
||||
@@ -1405,16 +1562,20 @@ async function writeConfig() {
|
||||
|
||||
// Build body — only include fields that have values
|
||||
const body = {};
|
||||
const rm = qs('cfg-recording-mode').value;
|
||||
if (rm !== '') body.recording_mode = parseInt(rm, 10);
|
||||
const sr = qs('cfg-sample-rate').value;
|
||||
if (sr) body.sample_rate = parseInt(sr, 10);
|
||||
const hi = qs('cfg-histogram-interval').value;
|
||||
if (hi !== '') body.histogram_interval_sec = parseInt(hi, 10);
|
||||
const rt = qs('cfg-record-time').value;
|
||||
if (rt) body.record_time = parseFloat(rt);
|
||||
const trig = qs('cfg-trigger').value;
|
||||
if (trig) body.trigger_level_geo = parseFloat(trig);
|
||||
const alarm = qs('cfg-alarm').value;
|
||||
if (alarm) body.alarm_level_geo = parseFloat(alarm);
|
||||
const mr = qs('cfg-max-range').value;
|
||||
if (mr) body.max_range_geo = parseFloat(mr);
|
||||
const gr = qs('cfg-geo-range').value;
|
||||
if (gr !== '') body.geo_range = parseInt(gr, 10);
|
||||
const proj = qs('cfg-project').value.trim();
|
||||
if (proj) body.project = proj;
|
||||
const cli = qs('cfg-client').value.trim();
|
||||
@@ -1453,6 +1614,134 @@ async function writeConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Call Home form ─────────────────────────────────────────────────────────────
|
||||
function setChStatus(msg, type) {
|
||||
const el = document.getElementById('ch-status');
|
||||
el.textContent = msg;
|
||||
el.style.color = type === 'ok' ? '#4caf50' : type === 'error' ? '#f44336' : '#aaa';
|
||||
}
|
||||
|
||||
function populateCallHomeForm(ch) {
|
||||
if (!ch) return;
|
||||
const qs2 = id => document.getElementById(id);
|
||||
|
||||
// Read-only display fields
|
||||
if (ch.dial_string != null) qs2('ch-dial-string').value = ch.dial_string || '';
|
||||
if (ch.num_retries != null) qs2('ch-num-retries').value = ch.num_retries;
|
||||
if (ch.time_between_retries_sec != null) qs2('ch-retry-gap').value = ch.time_between_retries_sec;
|
||||
if (ch.wait_for_connection_sec != null) qs2('ch-wait-conn').value = ch.wait_for_connection_sec;
|
||||
if (ch.warm_up_time_sec != null) qs2('ch-warmup').value = ch.warm_up_time_sec;
|
||||
|
||||
// Editable select/input fields (use "" for "unchanged" state when value is null)
|
||||
function setBool(id, val) {
|
||||
if (val != null) document.getElementById(id).value = val ? 'true' : 'false';
|
||||
}
|
||||
setBool('ch-enabled', ch.auto_call_home_enabled);
|
||||
setBool('ch-after-event', ch.after_event_recorded);
|
||||
setBool('ch-at-times', ch.at_specified_times);
|
||||
setBool('ch-t1-enabled', ch.time1_enabled);
|
||||
setBool('ch-t2-enabled', ch.time2_enabled);
|
||||
if (ch.time1_hour != null) qs2('ch-t1-hour').value = ch.time1_hour;
|
||||
if (ch.time1_min != null) qs2('ch-t1-min').value = ch.time1_min;
|
||||
if (ch.time2_hour != null) qs2('ch-t2-hour').value = ch.time2_hour;
|
||||
if (ch.time2_min != null) qs2('ch-t2-min').value = ch.time2_min;
|
||||
}
|
||||
|
||||
function clearCallHomeForm() {
|
||||
['ch-enabled','ch-after-event','ch-at-times','ch-t1-enabled','ch-t2-enabled']
|
||||
.forEach(id => { document.getElementById(id).selectedIndex = 0; });
|
||||
['ch-t1-hour','ch-t1-min','ch-t2-hour','ch-t2-min']
|
||||
.forEach(id => { document.getElementById(id).value = ''; });
|
||||
// Keep read-only display fields but clear them too
|
||||
['ch-dial-string','ch-num-retries','ch-retry-gap','ch-wait-conn','ch-warmup']
|
||||
.forEach(id => { document.getElementById(id).value = ''; });
|
||||
setChStatus('');
|
||||
}
|
||||
|
||||
async function readCallHome() {
|
||||
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
|
||||
setChStatus('Reading call home config from device…');
|
||||
document.getElementById('ch-read-btn').disabled = true;
|
||||
try {
|
||||
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`);
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
const ch = await r.json();
|
||||
populateCallHomeForm(ch);
|
||||
setChStatus('Call home config loaded from device.', 'ok');
|
||||
} catch(e) {
|
||||
setChStatus(`Read failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
document.getElementById('ch-read-btn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCallHome() {
|
||||
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
|
||||
|
||||
// Build body — only include fields that have values
|
||||
const body = {};
|
||||
|
||||
function getBool(id) {
|
||||
const v = document.getElementById(id).value;
|
||||
return v === '' ? null : v === 'true';
|
||||
}
|
||||
function getIntField(id) {
|
||||
const v = document.getElementById(id).value.trim();
|
||||
return v === '' ? null : parseInt(v, 10);
|
||||
}
|
||||
|
||||
const en = getBool('ch-enabled');
|
||||
if (en !== null) body.auto_call_home_enabled = en;
|
||||
const ae = getBool('ch-after-event');
|
||||
if (ae !== null) body.after_event_recorded = ae;
|
||||
const at = getBool('ch-at-times');
|
||||
if (at !== null) body.at_specified_times = at;
|
||||
const t1e = getBool('ch-t1-enabled');
|
||||
if (t1e !== null) body.time1_enabled = t1e;
|
||||
const t1h = getIntField('ch-t1-hour');
|
||||
if (t1h !== null) body.time1_hour = t1h;
|
||||
const t1m = getIntField('ch-t1-min');
|
||||
if (t1m !== null) body.time1_min = t1m;
|
||||
const t2e = getBool('ch-t2-enabled');
|
||||
if (t2e !== null) body.time2_enabled = t2e;
|
||||
const t2h = getIntField('ch-t2-hour');
|
||||
if (t2h !== null) body.time2_hour = t2h;
|
||||
const t2m = getIntField('ch-t2-min');
|
||||
if (t2m !== null) body.time2_min = t2m;
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
setChStatus('No fields to write — change at least one field.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn about value 3 in hour/min fields
|
||||
const hourMinFields = [body.time1_hour, body.time1_min, body.time2_hour, body.time2_min];
|
||||
if (hourMinFields.some(v => v === 3)) {
|
||||
setChStatus('Error: value 3 in hour/minute fields is not supported (DLE protocol limitation).', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldsStr = Object.keys(body).join(', ');
|
||||
setChStatus(`Writing ${Object.keys(body).length} field(s)…`);
|
||||
document.getElementById('ch-write-btn').disabled = true;
|
||||
|
||||
try {
|
||||
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
setChStatus(`Written: ${fieldsStr}`, 'ok');
|
||||
// Re-read to confirm changes
|
||||
await readCallHome();
|
||||
} catch(e) {
|
||||
setChStatus(`Write failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
document.getElementById('ch-write-btn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────────────────
|
||||
function populateEventChips() {
|
||||
const el = document.getElementById('event-chips');
|
||||
@@ -1565,7 +1854,7 @@ function renderWaveform(data) {
|
||||
let plotData, peakLabel, yUnit, ttFmt, tickFmt;
|
||||
|
||||
if (isGeo) {
|
||||
const scale = geoRange / 32767;
|
||||
const scale = geoAdcScale / 32767;
|
||||
plotData = samples.map(s => s * scale);
|
||||
// Use the device-recorded peak from the 0C waveform record — authoritative
|
||||
// and matches Blastware. Computing from raw samples can catch rogue
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
let charts = {};
|
||||
let lastData = null;
|
||||
let unitInfo = null;
|
||||
let geoRange = 10.0; // in/s full-scale for geo channels; updated on connect
|
||||
let geoAdcScale = 10.0; // in/s full-scale for geo channels; updated on connect
|
||||
let eventList = []; // populated from /device/events after connect
|
||||
let currentEventIndex = 0;
|
||||
|
||||
@@ -278,7 +278,7 @@
|
||||
throw new Error(err.detail || resp.statusText);
|
||||
}
|
||||
unitInfo = await resp.json();
|
||||
geoRange = unitInfo.compliance_config?.max_range_geo ?? 10.0;
|
||||
geoAdcScale = unitInfo.compliance_config?.geo_adc_scale ?? 10.0;
|
||||
} catch (e) {
|
||||
setStatus(`Error: ${e.message}`, 'error');
|
||||
btn.disabled = false;
|
||||
@@ -457,7 +457,7 @@
|
||||
|
||||
if (isGeo) {
|
||||
// Geo channels: counts × (range / 32767) → in/s
|
||||
const scale = geoRange / 32767;
|
||||
const scale = geoAdcScale / 32767;
|
||||
plotSamples = samples.map(c => c * scale);
|
||||
const peakIns = Math.max(...plotSamples.map(Math.abs));
|
||||
peakLabel = `${peakIns.toFixed(5)} in/s`;
|
||||
|
||||
Reference in New Issue
Block a user