feat: updates to 0.8.0 - initial write functions

This commit is contained in:
2026-04-07 02:09:29 -04:00
parent c2ab94f20c
commit bcc044655a
6 changed files with 1083 additions and 21 deletions
+144 -6
View File
@@ -2,7 +2,7 @@
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
(Sierra Wireless RV50 / RV55). Current version: **v0.7.0**.
(Sierra Wireless RV50 / RV55). Current version: **v0.8.0**.
---
@@ -25,9 +25,9 @@ CHANGELOG.md ← version history
---
## Current implementation state (v0.7.0)
## Current implementation state (v0.8.0)
Full read pipeline working end-to-end over TCP/cellular:
Full read pipeline + write pipeline working end-to-end over TCP/cellular:
| Step | SUB | Status |
|---|---|---|
@@ -39,12 +39,14 @@ Full read pipeline working end-to-end over TCP/cellular:
| Event header / first key | 1E | ✅ |
| Waveform header | 0A | ✅ |
| Waveform record (peaks, timestamp, project) | 0C | ✅ |
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ **new v0.6.0** |
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ new v0.6.0 |
| Event advance / next key | 1F | ✅ |
| Write commands (push config to device) | 6883 | ❌ not yet implemented |
| **Write commands (push config to device)** | **6883** | ✅ **new v0.8.0** |
`get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F`
`push_config_raw()` write sequence: `68→73 | 71×3→72 | 82→83 | 69→74→72`
---
## Protocol fundamentals
@@ -432,8 +434,144 @@ Server retries once on `ProtocolError` for TCP connections (handles cold-boot ti
---
## Write commands (SUBs 6883) — confirmed 2026-04-07
All confirmed from 3-11-26 BW TX capture (`raw_bw_20260311_170151.bin`, frames 102112).
### Write frame format — CRITICAL: minimal DLE stuffing
Write frames do NOT use the same DLE stuffing as read frames. **Only the BW_CMD byte
(0x10 at payload position [0]) is doubled on the wire. All other bytes — flags, sub,
offset, params, data, and checksum — are written RAW without stuffing.**
Confirmed from all 11 write frames in the 3-11-26/170151 BW capture. ✅ 2026-04-07
Do NOT use `dle_stuff()` or `build_bw_frame()` for write commands. Use `build_bw_write_frame()`.
```
Actual wire layout:
[41] ACK
[02] STX
[10 10] BW_CMD doubled (ONLY DLE stuffing applied)
[00] flags
[sub] write command byte (0x680x83)
[00] always zero
[hi][lo] offset uint16 BE — RAW (not stuffed even if hi=0x10)
[params] 10 bytes — RAW
[data] variable-length write payload — RAW (0x10 bytes not stuffed)
[chk] checksum — RAW (not stuffed even if 0x10)
[03] ETX
Total wire length = 2 (ACK+STX) + 2 (doubled BW_CMD) + 15 (raw header) + len(data) + 1 (chk) + 1 (ETX)
= 21 + len(data)
```
De-stuffed payload (logical; used for checksum computation only):
```
[0] BW_CMD 0x10
[1] flags 0x00
[2] SUB write command byte (0x680x83)
[3] 0x00 always zero
[4] offset_hi
[5] offset_lo
[6:16] params 10-byte field (see per-SUB notes below)
[16:] data write payload (variable length; absent for confirm frames)
[-1] chk large-frame DLE-aware checksum (see below)
```
Write SUBs = Read SUB + 0x60. Response SUB follows the standard 0xFF Request SUB rule.
### Write frame checksum
All write frames (data frames AND confirm frames) use the **large-frame DLE-aware checksum**:
```python
chk = (sum(b for b in payload[2:] if b != 0x10) + 0x10) & 0xFF
```
This is identical to the SUB 5A DLE-aware checksum. Confirmed against all 11 write frames in
the 3-11-26/170151 capture. ✅ 2026-04-07
Note: confirm frames contain no embedded 0x10 bytes, so both the standard SUM8 and the
DLE-aware formula produce the same result for them — but `build_bw_write_frame` always uses
the DLE-aware formula for consistency.
### Write ack responses
All device acks for write commands are **17-byte zero-data S3 frames**:
```
[DLE=0x10][STX=0x02][stuffed(header + chk)][bare ETX=0x03]
```
The data section carries zeros; RSP_SUB = 0xFF write_request_SUB.
### Write SUB constants and sequences
| Request SUB | Function | Offset | Response SUB |
|---|---|---|---|
| 0x68 | Event index write | `data[1] + 2` | 0x97 |
| 0x73 | Confirm B (follows 68) | 0 | 0x8C |
| 0x71 | Compliance write (×3 chunks) | see below | 0x8E |
| 0x72 | Confirm A (follows 71×3, 69) | 0 | 0x8D |
| 0x82 | Trigger config write | `data[1] + 2` | 0x7D |
| 0x83 | Trigger confirm (follows 82) | 0 | 0x7C |
| 0x69 | Waveform data write | `data[1] + 2` | 0x96 |
| 0x74 | Confirm C (follows 69) | 0 | 0x8B |
**Offset formula for single-chunk writes (0x68, 0x69, 0x82):** `offset = data[1] + 2`
The write payload always begins with a 2-byte header `[0x00][length]`, where `data[1]` is
an embedded length field. The offset encodes this inner length + 2 (accounting for the
header bytes). Confirmed from all three single-chunk write frames in the 3-11-26 capture:
| SUB | data[0:4] (hex) | data[1] | offset | total data len |
|---|---|---|---|---|
| 0x68 | `00 58 09 00` | 0x58=88 | 0x5A=90 | 91 |
| 0x82 | `00 1A D5 00` | 0x1A=26 | 0x1C=28 | 29 |
| 0x69 | `00 C8 08 00` | 0xC8=200 | 0xCA=202 | 204 |
Full sequence: `68→73 | 71×3→72 | 82→83 | 69→74→72`
### SUB 71 — compliance write chunk parameters
The full compliance config payload (~2128 bytes) is split into exactly 3 chunks.
Confirmed from 3-11-26 BW TX capture frames 104108:
| Chunk | Size | `offset` | `params` (10 bytes hex) |
|---|---|---|---|
| 1 (first) | 1027 bytes | 0x1004 | `00 00 00 00 00 00 00 00 00 00` |
| 2 (middle) | 1055 bytes | 0x1004 | `00 00 00 10 04 00 00 00 00 00` |
| 3 (last) | remainder | 0x002C | `00 00 08 00 00 00 00 00 00 00` |
Total: 1027 + 1055 + N = 2082 + N bytes (N ≈ 46 for a standard 2128-byte config).
After all 3 chunks are acked (SUB 0x8E each), send SUB 72 confirm → device acks 0x8D.
### `build_bw_write_frame()` — framing.py
```python
build_bw_write_frame(sub, data, *, offset=0, params=bytes(10)) -> bytes
```
Use for all write commands (SUBs 6883) including confirm frames (data=b"").
**Do NOT use `build_bw_frame` for write commands** — it uses standard SUM8, not the
large-frame DLE-aware checksum required for writes.
### `push_config_raw()` — client.py
```python
client.push_config_raw(event_index_data, compliance_data, trigger_data, waveform_data)
```
Orchestrates the full write sequence in the confirmed order. All payloads are raw bytes
(no encoding performed at this level). A higher-level encoder that builds payloads from
a `ComplianceConfig` object is a future task.
---
## What's next
- Write commands (SUBs 6883) — push compliance config, channel config, trigger settings to device
- Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
- ACH inbound server — accept call-home connections from field units
- Modem manager — push RV50/RV55 configs via Sierra Wireless API
+72
View File
@@ -45,6 +45,10 @@ from .protocol import MiniMateProtocol, ProtocolError
from .protocol import (
SUB_SERIAL_NUMBER,
SUB_FULL_CONFIG,
SUB_WRITE_CONFIRM_A,
SUB_WRITE_CONFIRM_B,
SUB_WRITE_CONFIRM_C,
SUB_TRIGGER_CONFIRM,
)
from .transport import SerialTransport, BaseTransport
@@ -527,6 +531,74 @@ class MiniMateClient:
else:
log.warning("download_waveform: waveform decode produced no samples")
# ── Write commands ────────────────────────────────────────────────────────
def push_config_raw(
self,
event_index_data: bytes,
compliance_data: bytes,
trigger_data: bytes,
waveform_data: bytes,
) -> None:
"""
Push a complete config update to the device using the confirmed write
sequence from the 3-11-26 BW TX capture.
This is the raw-bytes interface — callers supply pre-encoded payloads for
each write block. A higher-level method that encodes from ComplianceConfig
and re-reads the current payloads first can be built on top of this.
Full write sequence (confirmed from 3-11-26 BW TX capture frames 102112):
SUB 68 → event index write → ack SUB 0x97
SUB 73 → confirm B → ack SUB 0x8C
SUB 71 (×3 chunks) → compliance write → each ack SUB 0x8E
SUB 72 → confirm A → ack SUB 0x8D
SUB 82 → trigger config write → ack SUB 0x7D
SUB 83 → trigger confirm → ack SUB 0x7C
SUB 69 → waveform data write → ack SUB 0x96
SUB 74 → confirm C → ack SUB 0x8B
SUB 72 → confirm A → ack SUB 0x8D
Args:
event_index_data: Raw bytes for SUB 68 write (88-byte event index).
compliance_data: Raw bytes for SUB 71 write (≥2082 bytes, 3 chunks).
trigger_data: Raw bytes for SUB 82 write (44-byte trigger config).
waveform_data: Raw bytes for SUB 69 write.
Raises:
RuntimeError: if the client is not connected.
ProtocolError: if any write step fails (timeout, bad ack SUB).
ValueError: if compliance_data is too short for the 3-chunk split.
"""
proto = self._require_proto()
# 68 → 73
log.info("push_config_raw: write event index (SUB 68)")
proto.write_event_index(event_index_data)
log.info("push_config_raw: confirm B (SUB 73)")
proto.write_confirm(SUB_WRITE_CONFIRM_B)
# 71×3 → 72 (handled internally by write_compliance_config_raw)
log.info("push_config_raw: write compliance config (SUB 71 ×3 + confirm 72)")
proto.write_compliance_config_raw(compliance_data)
# 82 → 83
log.info("push_config_raw: write trigger config (SUB 82)")
proto.write_trigger_config(trigger_data)
log.info("push_config_raw: trigger confirm (SUB 83)")
proto.write_confirm(SUB_TRIGGER_CONFIRM)
# 69 → 74 → 72
log.info("push_config_raw: write waveform data (SUB 69)")
proto.write_waveform_data(waveform_data)
log.info("push_config_raw: confirm C (SUB 74)")
proto.write_confirm(SUB_WRITE_CONFIRM_C)
log.info("push_config_raw: confirm A (SUB 72)")
proto.write_confirm(SUB_WRITE_CONFIRM_A)
log.info("push_config_raw: complete")
# ── Internal helpers ──────────────────────────────────────────────────────
def _require_proto(self) -> MiniMateProtocol:
+103
View File
@@ -194,6 +194,109 @@ def build_bw_frame(sub: int, offset: int = 0, params: bytes = bytes(10)) -> byte
return wire
def build_bw_write_frame(
sub: int,
data: bytes,
*,
offset: int = 0,
params: bytes = bytes(10),
) -> bytes:
"""
Build a BWS3 write-command frame.
Write frames extend the standard 16-byte read header with a variable-length
data payload. They use a different checksum formula from read frames.
**CRITICAL: Write frames use minimal DLE stuffing.**
Unlike read frames (build_bw_frame), write frames do NOT apply full DLE
stuffing to the payload. Only the BW_CMD byte (0x10) at position [0] is
doubled to 0x10 0x10 on the wire. All other bytes flags, sub, offset,
params, data, and checksum are written RAW with no stuffing, even if they
contain 0x10 bytes (e.g. offset_hi=0x10 for compliance chunks, or 0x10
bytes in the write data payload).
Confirmed from 3-11-26 BW TX capture (frames 102112): all 11 write frames
match the rule "double BW_CMD only; everything else raw." 2026-04-07.
Wire layout:
[41] ACK
[02] STX
[10 10] BW_CMD doubled (the ONLY DLE stuffing applied)
[00] flags
[sub] write command byte (0x680x83)
[00] always zero
[hi][lo] offset as uint16 BE (raw; NOT stuffed even if hi=0x10)
[params] 10 bytes (raw)
[data] variable-length write payload (raw; NOT stuffed)
[chk] checksum byte (raw; NOT stuffed even if 0x10)
[03] ETX
De-stuffed payload (for checksum computation):
[0] BW_CMD 0x10
[1] flags 0x00
[2] SUB write command byte
[3] 0x00 always zero
[4] offset_hi
[5] offset_lo
[6:16] params 10 bytes
[16:] data write payload
[-1] chk
**Checksum formula (confirmed 2026-03-12 from 3-11-26 BW TX capture):**
chk = (sum(b for b in payload[2:] if b != 0x10) + 0x10) % 256
where payload = destuffed content BEFORE appending chk.
This skips all 0x10 bytes in payload[2:] (sub onwards), including any
0x10 bytes in the offset, params, data, and the checksum byte itself.
The offset field [4:6] meaning per write SUB:
- SUBs 68, 69, 82 (single-chunk writes): offset = data[1] + 2, where
data[1] is an embedded length field in the write payload.
Confirmed from capture: 680x5A (data[1]=0x58+2), 820x1C
(data[1]=0x1A+2), 690xCA (data[1]=0xC8+2).
- SUB 71 (multi-chunk compliance): 0x1004 for full chunks, 0x002C
for the final partial chunk.
- Confirm frames (72, 73, 74, 83): offset=0, no data.
Args:
sub: Write command SUB byte.
data: Write payload (variable length; empty for confirm frames).
offset: 16-bit value placed at [4:6]. See per-SUB notes above.
params: 10 bytes placed at [6:16]. All-zero for most writes; compliance
chunk writes use chunk-specific values.
Returns:
Complete frame bytes ready to write to the transport.
"""
if len(params) != 10:
raise ValueError(f"params must be exactly 10 bytes, got {len(params)}")
if offset > 0xFFFF:
raise ValueError(f"offset must fit in uint16, got {offset:#06x}")
offset_hi = (offset >> 8) & 0xFF
offset_lo = offset & 0xFF
# Destuffed payload (used only for checksum; not sent directly)
payload_no_chk = bytes([BW_CMD, 0x00, sub, 0x00, offset_hi, offset_lo]) + params + data
# Large-frame checksum: sum payload[2:] skipping all 0x10 bytes, add 0x10.
# Applied to the destuffed representation — confirms correctly against
# all 11 write frames in the 3-11-26/170151 BW TX capture. ✅
chk = (sum(b for b in payload_no_chk[2:] if b != 0x10) + 0x10) & 0xFF
# Wire construction: only BW_CMD is doubled; everything else is raw.
# Do NOT use dle_stuff() here — that would incorrectly double 0x10 bytes
# in the offset, params, and data sections.
wire = (
bytes([ACK, STX]) # Frame prefix (not part of payload)
+ bytes([BW_CMD, BW_CMD]) # BW_CMD doubled (only DLE stuffing applied)
+ payload_no_chk[1:] # flags, sub, offset, params, data — RAW
+ bytes([chk]) # checksum — RAW
+ bytes([ETX]) # Frame terminator
)
return wire
def waveform_key_params(key4: bytes) -> bytes:
"""
Build the 10-byte params block that carries a 4-byte waveform key.
+277 -4
View File
@@ -30,6 +30,7 @@ from .framing import (
S3FrameParser,
build_bw_frame,
build_5a_frame,
build_bw_write_frame,
waveform_key_params,
token_params,
bulk_waveform_params,
@@ -65,6 +66,17 @@ SUB_BULK_WAVEFORM = 0x5A
SUB_COMPLIANCE = 0x1A
SUB_UNKNOWN_2E = 0x2E
# Write command SUBs (= Read SUB + 0x60, confirmed from BW captures 3-11-26)
# Response SUB follows the standard 0xFF - Request SUB rule.
SUB_EVENT_INDEX_WRITE = 0x68 # Write event index (0x08 + 0x60) ✅
SUB_WAVEFORM_DATA_WRITE = 0x69 # Write waveform data (0x09 + 0x60) ✅
SUB_COMPLIANCE_WRITE = 0x71 # Write compliance cfg (0x11 + 0x60) ✅
SUB_WRITE_CONFIRM_A = 0x72 # Confirm A — sent after 71×3 and other writes ✅
SUB_WRITE_CONFIRM_B = 0x73 # Confirm B — sent after 68 ✅
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 ✅
# Hardcoded data lengths for the two-step read protocol.
#
# The S3 probe response page_key is always 0x0000 — it does NOT carry the
@@ -95,10 +107,11 @@ DATA_LENGTHS: dict[int, int] = {
# Confirmed from 1-2-26 BW TX capture analysis (2026-04-02).
_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 for chunks 2+
# Chunk 1 counter is 0x1004 (NOT 1 * 0x0400 = 0x0400). Confirmed from 4-2-26 BW TX
# capture. Chunks 2+ use n * 0x0400 (0x0800, 0x0C00, …). Device silently ignores
# frames with wrong counter — this was the root cause of the full-waveform timeout.
_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.
# Default timeout values (seconds).
# MiniMate Plus is a slow device — keep these generous.
@@ -749,6 +762,266 @@ class MiniMateProtocol:
return bytes(config)
# ── Write commands (SUBs 6883) ───────────────────────────────────────────
def recv_write_ack(
self,
expected_sub: int,
timeout: Optional[float] = None,
) -> S3Frame:
"""
Wait for a write-ack S3 frame.
All write ack responses are 17-byte frames (11-byte header + no data +
1 checksum byte) with SUB = 0xFF - request_SUB. The page_key and data
section carry zeros. Confirmed from 3-11-26 BW capture.
Args:
expected_sub: Expected response SUB byte (0xFF - write_request_SUB).
timeout: Seconds to wait; defaults to self._recv_timeout.
Returns:
The ack S3Frame.
Raises:
TimeoutError: if no frame arrives in time.
UnexpectedResponse: if the response SUB doesn't match.
"""
log.debug("recv_write_ack: waiting for SUB=0x%02X", expected_sub)
ack = self._recv_one(expected_sub=expected_sub, timeout=timeout)
log.debug(
"recv_write_ack: received SUB=0x%02X page=0x%04X data=%d bytes",
ack.sub, ack.page_key, len(ack.data),
)
return ack
def write_confirm(self, sub: int) -> S3Frame:
"""
Send a zero-data confirm frame and wait for the ack.
Confirm frames (SUBs 72, 73, 74, 83) carry no write data they are
16-byte header-only frames (offset=0, params=zeros, data=b"") with the
DLE-aware large-frame checksum. The device acks with the complementary
RSP_SUB.
Args:
sub: Confirm SUB byte (SUB_WRITE_CONFIRM_A/B/C or SUB_TRIGGER_CONFIRM).
Returns:
The ack S3Frame.
Raises:
ProtocolError: on timeout or wrong response SUB.
"""
rsp_sub = _expected_rsp_sub(sub)
frame = build_bw_write_frame(sub, b"")
log.debug("write_confirm: SUB=0x%02X frame=%s", sub, frame.hex())
self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub)
def write_event_index(self, data: bytes) -> S3Frame:
"""
Send a SUB 68 (EVENT_INDEX_WRITE) frame and await the confirm ack (SUB 97).
Offset formula: data[1] + 2 confirmed from 3-11-26 BW TX capture frame 102.
The write payload has a 2-byte header [0x00][length] where data[1] encodes
the length of the meaningful payload; offset = data[1] + 2.
Example from capture:
data[0:4] = 00 58 09 00 (data[1]=0x58=88 offset=0x5A=90)
data length = 91, offset = 90
Write sequence fragment:
68 (data) device acks with SUB 0x97
73 (confirm) device acks with SUB 0x8C
Callers should call write_confirm(SUB_WRITE_CONFIRM_B) after this.
Args:
data: Raw event-index payload bytes to write to the device.
Must be at least 2 bytes. data[1] must contain the length field.
Returns:
The SUB 0x97 ack frame.
Raises:
ProtocolError: on timeout or wrong response SUB.
ValueError: if data is shorter than 2 bytes.
"""
if len(data) < 2:
raise ValueError(f"event index write data must be at least 2 bytes, got {len(data)}")
rsp_sub = _expected_rsp_sub(SUB_EVENT_INDEX_WRITE) # 0xFF - 0x68 = 0x97
offset = data[1] + 2
frame = build_bw_write_frame(SUB_EVENT_INDEX_WRITE, data, offset=offset)
log.debug(
"write_event_index: %d bytes data[1]=0x%02X offset=0x%04X rsp_sub=0x%02X",
len(data), data[1], offset, rsp_sub,
)
self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub)
def write_waveform_data(self, data: bytes) -> S3Frame:
"""
Send a SUB 69 (WAVEFORM_DATA_WRITE) frame and await the confirm ack (SUB 96).
Offset formula: data[1] + 2 same pattern as write_event_index().
Confirmed from 3-11-26 BW TX capture frame 110:
data[0:4] = 00 c8 08 00 (data[1]=0xC8=200 offset=0xCA=202)
data length = 204, offset = 202
Write sequence fragment:
69 (data) device acks with SUB 0x96
74 (confirm) device acks with SUB 0x8B
72 (confirm) device acks with SUB 0x8D
Callers should call write_confirm(SUB_WRITE_CONFIRM_C) then
write_confirm(SUB_WRITE_CONFIRM_A) after this.
Args:
data: Raw waveform-data payload bytes to write.
Must be at least 2 bytes. data[1] must contain the length field.
Returns:
The SUB 0x96 ack frame.
Raises:
ProtocolError: on timeout or wrong response SUB.
ValueError: if data is shorter than 2 bytes.
"""
if len(data) < 2:
raise ValueError(f"waveform data write payload must be at least 2 bytes, got {len(data)}")
rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_DATA_WRITE) # 0xFF - 0x69 = 0x96
offset = data[1] + 2
frame = build_bw_write_frame(SUB_WAVEFORM_DATA_WRITE, data, offset=offset)
log.debug(
"write_waveform_data: %d bytes data[1]=0x%02X offset=0x%04X rsp_sub=0x%02X",
len(data), data[1], offset, rsp_sub,
)
self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub)
def write_compliance_config_raw(self, data: bytes) -> None:
"""
Send the SUB 71 (COMPLIANCE_WRITE) 3-chunk sequence and final confirm.
The full compliance config payload (~2128 bytes) is split into exactly 3
chunks with hardcoded boundaries and params confirmed from the 3-11-26 BW
TX capture (frames 104108):
Chunk 1 first 1027 bytes:
offset=0x1004 params=bytes(10)
device acks SUB 0x8E
Chunk 2 next 1055 bytes:
offset=0x1004 params=b'\\x00\\x00\\x00\\x00\\x10\\x04' + b'\\x00'*4
device acks SUB 0x8E
Chunk 3 remaining bytes:
offset=0x002C params=b'\\x00\\x00\\x08' + b'\\x00'*7
device acks SUB 0x8E
Confirm SUB 72 (zero data):
device acks SUB 0x8D
The total write payload should be at least 1027+1055=2082 bytes; chunk 3
carries everything after offset 2082 (typically ~46 bytes for a 2128-byte
config).
Args:
data: Raw compliance config bytes to write. Must be at least 2082 bytes.
Raises:
ValueError: if data is too short to fill chunks 1 and 2.
ProtocolError: on timeout or wrong response SUB from any chunk.
"""
_CHUNK1_SIZE = 1027
_CHUNK2_SIZE = 1055
_CHUNK1_OFFSET = 0x1004
_CHUNK2_OFFSET = 0x1004
_CHUNK3_OFFSET = 0x002C
_CHUNK1_PARAMS = bytes(10)
_CHUNK2_PARAMS = bytes([0x00, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])
_CHUNK3_PARAMS = bytes([0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
min_size = _CHUNK1_SIZE + _CHUNK2_SIZE
if len(data) < min_size:
raise ValueError(
f"Compliance write data too short: {len(data)} bytes, "
f"need at least {min_size} (chunk1={_CHUNK1_SIZE} + chunk2={_CHUNK2_SIZE})"
)
rsp_sub = _expected_rsp_sub(SUB_COMPLIANCE_WRITE) # 0xFF - 0x71 = 0x8E
chunk1 = data[:_CHUNK1_SIZE]
chunk2 = data[_CHUNK1_SIZE : _CHUNK1_SIZE + _CHUNK2_SIZE]
chunk3 = data[_CHUNK1_SIZE + _CHUNK2_SIZE :]
chunks = [
(1, chunk1, _CHUNK1_OFFSET, _CHUNK1_PARAMS),
(2, chunk2, _CHUNK2_OFFSET, _CHUNK2_PARAMS),
(3, chunk3, _CHUNK3_OFFSET, _CHUNK3_PARAMS),
]
for chunk_num, chunk_data, chunk_offset, chunk_params in chunks:
frame = build_bw_write_frame(
SUB_COMPLIANCE_WRITE,
chunk_data,
offset=chunk_offset,
params=chunk_params,
)
log.debug(
"write_compliance_config_raw: chunk %d %d bytes "
"offset=0x%04X params=%s",
chunk_num, len(chunk_data), chunk_offset, chunk_params.hex(),
)
self._send(frame)
self.recv_write_ack(expected_sub=rsp_sub)
log.debug("write_compliance_config_raw: chunk %d acked", chunk_num)
# Final confirm (SUB 72)
log.debug("write_compliance_config_raw: sending confirm (SUB 0x72)")
self.write_confirm(SUB_WRITE_CONFIRM_A)
log.debug("write_compliance_config_raw: done")
def write_trigger_config(self, data: bytes) -> S3Frame:
"""
Send a SUB 82 (TRIGGER_CONFIG_WRITE) frame and await the confirm ack (SUB 7D).
Offset formula: data[1] + 2 same pattern as write_event_index().
Confirmed from 3-11-26 BW TX capture frame 108:
data[0:4] = 00 1a d5 00 (data[1]=0x1A=26 offset=0x1C=28)
data length = 29, offset = 28
Write sequence fragment:
82 (data) device acks with SUB 0x7D
83 (confirm) device acks with SUB 0x7C
Callers should call write_confirm(SUB_TRIGGER_CONFIRM) after this.
Args:
data: Raw trigger-config payload bytes to write.
Must be at least 2 bytes. data[1] must contain the length field.
Returns:
The SUB 0x7D ack frame.
Raises:
ProtocolError: on timeout or wrong response SUB.
ValueError: if data is shorter than 2 bytes.
"""
if len(data) < 2:
raise ValueError(f"trigger config write payload must be at least 2 bytes, got {len(data)}")
rsp_sub = _expected_rsp_sub(SUB_TRIGGER_CONFIG_WRITE) # 0xFF - 0x82 = 0x7D
offset = data[1] + 2
frame = build_bw_write_frame(SUB_TRIGGER_CONFIG_WRITE, data, offset=offset)
log.debug(
"write_trigger_config: %d bytes data[1]=0x%02X offset=0x%04X rsp_sub=0x%02X",
len(data), data[1], offset, rsp_sub,
)
self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub)
# ── Internal helpers ──────────────────────────────────────────────────────
def _send(self, frame: bytes) -> None:
+51 -11
View File
@@ -240,6 +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 eventList = []; // populated from /device/events after connect
let currentEventIndex = 0;
@@ -277,6 +278,7 @@
throw new Error(err.detail || resp.statusText);
}
unitInfo = await resp.json();
geoRange = unitInfo.compliance_config?.max_range_geo ?? 10.0;
} catch (e) {
setStatus(`Error: ${e.message}`, 'error');
btn.disabled = false;
@@ -441,19 +443,48 @@
Object.values(charts).forEach(c => c.destroy());
charts = {};
// Mic peak PSI from 0C waveform record — used to scale raw mic counts
const micPeakPsi = data.peak_values?.micl_psi ?? null;
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch];
if (!samples || samples.length === 0) continue;
// Convert raw ADC counts to physical units
const isGeo = ch !== 'Mic';
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt;
if (isGeo) {
// Geo channels: counts × (range / 32767) → in/s
const scale = geoRange / 32767;
plotSamples = samples.map(c => c * scale);
const peakIns = Math.max(...plotSamples.map(Math.abs));
peakLabel = `${peakIns.toFixed(5)} in/s`;
yUnit = 'in/s';
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
tickFmt = v => v.toFixed(4);
} else {
// Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header
const peakCounts = Math.max(...samples.map(Math.abs));
const micScale = (micPeakPsi !== null && peakCounts > 0)
? Math.abs(micPeakPsi) / peakCounts
: 1.0;
plotSamples = samples.map(c => c * micScale);
const peakPsi = Math.max(...plotSamples.map(Math.abs));
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`;
yUnit = 'psi';
tooltipFmt = v => `${ch}: ${v.toExponential(3)} psi`;
tickFmt = v => v.toExponential(1);
}
const wrap = document.createElement('div');
wrap.className = 'chart-wrap';
const lbl = document.createElement('div');
lbl.className = `chart-label ch-${ch.toLowerCase()}`;
// Compute peak for label
const peak = Math.max(...samples.map(Math.abs));
lbl.textContent = `${ch} — peak ${peak.toLocaleString()} counts`;
lbl.textContent = `${ch} — peak ${peakLabel}`;
wrap.appendChild(lbl);
const canvasWrap = document.createElement('div');
@@ -466,11 +497,11 @@
// Downsample for rendering if very long (keep chart responsive)
const MAX_POINTS = 4000;
let renderTimes = times;
let renderData = samples;
if (samples.length > MAX_POINTS) {
const step = Math.ceil(samples.length / MAX_POINTS);
let renderData = plotSamples;
if (plotSamples.length > MAX_POINTS) {
const step = Math.ceil(plotSamples.length / MAX_POINTS);
renderTimes = times.filter((_, i) => i % step === 0);
renderData = samples.filter((_, i) => i % step === 0);
renderData = plotSamples.filter((_, i) => i % step === 0);
}
const chart = new Chart(canvas, {
@@ -496,10 +527,9 @@
intersect: false,
callbacks: {
title: items => `t = ${items[0].label} ms`,
label: item => `${ch}: ${item.raw.toLocaleString()} counts`,
label: item => tooltipFmt(item.raw),
},
},
// Trigger line annotation (drawn manually via afterDraw)
},
scales: {
x: {
@@ -513,8 +543,18 @@
grid: { color: '#21262d' },
},
y: {
ticks: { color: '#484f58', maxTicksLimit: 5 },
ticks: {
color: '#484f58',
maxTicksLimit: 5,
callback: v => tickFmt(v),
},
grid: { color: '#21262d' },
title: {
display: true,
text: yUnit,
color: '#484f58',
font: { size: 10 },
},
},
},
},
+436
View File
@@ -0,0 +1,436 @@
"""
test_write_frames.py Verify write frame construction against BW capture.
Validates that build_bw_write_frame() reproduces the exact wire bytes that
Blastware sent during the 3-11-26/170151 compliance-config write session.
Frames tested (BW TX frame indices 102112):
102 SUB 0x68 event index write
103 SUB 0x73 confirm B
104 SUB 0x71 compliance write chunk 1
105 SUB 0x71 compliance write chunk 2
106 SUB 0x71 compliance write chunk 3
107 SUB 0x72 confirm A
108 SUB 0x82 trigger config write
109 SUB 0x83 trigger confirm
110 SUB 0x69 waveform data write
111 SUB 0x74 confirm C
112 SUB 0x72 confirm A (end of sequence)
Run:
python -m pytest tests/test_write_frames.py -v
or:
python tests/test_write_frames.py
"""
from __future__ import annotations
import os
import sys
import pytest
# Allow running from the project root without installation
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from minimateplus.framing import build_bw_write_frame
# ── Capture loading ────────────────────────────────────────────────────────────
CAPTURE_PATH = os.path.join(
os.path.dirname(__file__),
"..",
"bridges",
"captures",
"3-11-26",
"raw_bw_20260311_170151.bin",
)
def _load_bw_frames(path: str) -> list[bytes]:
"""
Parse a raw BW capture file into a list of BW frames.
BW frames start with ACK=0x41 followed by STX=0x02. The frame boundary is
the position of the NEXT 0x41 0x02 sequence (the ETX=0x03 terminator is the
last byte before the next frame start).
NOTE: A naive scan for ETX=0x03 fails because 0x03 can appear inside the
DLE-stuffed payload. This parser uses consecutive 0x41 0x02 starts as
boundaries, which is safe because the ACK byte (0x41) is never DLE-stuffed.
"""
with open(path, "rb") as f:
raw = f.read()
boundaries: list[int] = []
i = 0
while i < len(raw) - 1:
if raw[i] == 0x41 and raw[i + 1] == 0x02:
boundaries.append(i)
i += 1
boundaries.append(len(raw))
frames = []
for k in range(len(boundaries) - 1):
frames.append(raw[boundaries[k] : boundaries[k + 1]])
return frames
def _destuff(data: bytes) -> bytes:
"""Undo DLE stuffing: replace every 0x10 0x10 pair with a single 0x10."""
result = bytearray()
k = 0
while k < len(data):
if data[k] == 0x10 and k + 1 < len(data) and data[k + 1] == 0x10:
result.append(0x10)
k += 2
else:
result.append(data[k])
k += 1
return bytes(result)
def _decode_bw_frame(wire: bytes) -> tuple[int, int, bytes, bytes, int]:
"""
Decode a BW wire frame into its components.
Returns:
(sub, offset, params, data, chk)
sub SUB byte (payload[2])
offset uint16 from payload[4:6]
params 10-byte params field (payload[6:16])
data write payload bytes (payload[16:-1])
chk checksum byte (payload[-1])
"""
inner = wire[2:-1] # strip ACK+STX and trailing ETX
payload = _destuff(inner)
sub = payload[2]
offset = (payload[4] << 8) | payload[5]
params = payload[6:16]
data = payload[16:-1]
chk = payload[-1]
return sub, offset, params, data, chk
# ── Test fixtures ──────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def bw_frames() -> list[bytes]:
if not os.path.exists(CAPTURE_PATH):
pytest.skip(f"Capture file not found: {CAPTURE_PATH}")
return _load_bw_frames(CAPTURE_PATH)
# ── Individual frame tests ─────────────────────────────────────────────────────
class TestWriteFrameReconstruction:
"""Verify build_bw_write_frame() reproduces the exact wire bytes from the capture."""
def test_frame_102_event_index_write_sub68(self, bw_frames: list[bytes]) -> None:
"""SUB 0x68 — event index write (frame 102)."""
cap_wire = bw_frames[102]
sub_cap, offset_cap, params_cap, data_cap, chk_cap = _decode_bw_frame(cap_wire)
assert sub_cap == 0x68
assert params_cap == bytes(10)
# Reconstruct using build_bw_write_frame with the same data and offset
built = build_bw_write_frame(0x68, data_cap, offset=offset_cap, params=params_cap)
assert built == cap_wire, (
f"SUB 0x68 wire mismatch\n"
f" built: {built.hex()}\n"
f" capt: {cap_wire.hex()}"
)
def test_frame_103_confirm_b_sub73(self, bw_frames: list[bytes]) -> None:
"""SUB 0x73 — confirm B (zero-data confirm frame 103)."""
cap_wire = bw_frames[103]
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
assert sub_cap == 0x73
assert data_cap == b""
assert offset_cap == 0x0000
built = build_bw_write_frame(0x73, b"")
assert built == cap_wire
def test_frame_104_compliance_chunk1_sub71(self, bw_frames: list[bytes]) -> None:
"""SUB 0x71 chunk 1 — 1027-byte compliance write (frame 104)."""
cap_wire = bw_frames[104]
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
assert sub_cap == 0x71
assert offset_cap == 0x1004
assert params_cap == bytes(10)
assert len(data_cap) == 1027
built = build_bw_write_frame(
0x71, data_cap,
offset=0x1004,
params=bytes(10),
)
assert built == cap_wire
def test_frame_105_compliance_chunk2_sub71(self, bw_frames: list[bytes]) -> None:
"""SUB 0x71 chunk 2 — 1055-byte compliance write (frame 105)."""
cap_wire = bw_frames[105]
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
_CHUNK2_PARAMS = bytes([0x00, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])
assert sub_cap == 0x71
assert offset_cap == 0x1004
assert params_cap == _CHUNK2_PARAMS
assert len(data_cap) == 1055
built = build_bw_write_frame(
0x71, data_cap,
offset=0x1004,
params=_CHUNK2_PARAMS,
)
assert built == cap_wire
def test_frame_106_compliance_chunk3_sub71(self, bw_frames: list[bytes]) -> None:
"""SUB 0x71 chunk 3 — 46-byte compliance write (frame 106)."""
cap_wire = bw_frames[106]
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
_CHUNK3_PARAMS = bytes([0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
assert sub_cap == 0x71
assert offset_cap == 0x002C
assert params_cap == _CHUNK3_PARAMS
assert len(data_cap) == 46
built = build_bw_write_frame(
0x71, data_cap,
offset=0x002C,
params=_CHUNK3_PARAMS,
)
assert built == cap_wire
def test_frame_107_confirm_a_sub72(self, bw_frames: list[bytes]) -> None:
"""SUB 0x72 — confirm A after compliance write (frame 107)."""
cap_wire = bw_frames[107]
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
assert sub_cap == 0x72
assert data_cap == b""
assert offset_cap == 0x0000
built = build_bw_write_frame(0x72, b"")
assert built == cap_wire
def test_frame_108_trigger_config_write_sub82(self, bw_frames: list[bytes]) -> None:
"""SUB 0x82 — trigger config write (frame 108)."""
cap_wire = bw_frames[108]
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
assert sub_cap == 0x82
assert params_cap == bytes(10)
assert len(data_cap) == 29
# Verify offset formula: data[1] + 2
assert offset_cap == data_cap[1] + 2, (
f"Trigger write offset formula mismatch: "
f"data[1]={data_cap[1]} → expected {data_cap[1]+2}, got {offset_cap}"
)
built = build_bw_write_frame(
0x82, data_cap,
offset=offset_cap,
params=params_cap,
)
assert built == cap_wire
def test_frame_109_trigger_confirm_sub83(self, bw_frames: list[bytes]) -> None:
"""SUB 0x83 — trigger confirm (frame 109)."""
cap_wire = bw_frames[109]
sub_cap, _, _, data_cap, _ = _decode_bw_frame(cap_wire)
assert sub_cap == 0x83
assert data_cap == b""
built = build_bw_write_frame(0x83, b"")
assert built == cap_wire
def test_frame_110_waveform_data_write_sub69(self, bw_frames: list[bytes]) -> None:
"""SUB 0x69 — waveform data write (frame 110)."""
cap_wire = bw_frames[110]
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
assert sub_cap == 0x69
assert params_cap == bytes(10)
assert len(data_cap) == 204
# Verify offset formula: data[1] + 2
assert offset_cap == data_cap[1] + 2, (
f"Waveform write offset formula mismatch: "
f"data[1]={data_cap[1]} → expected {data_cap[1]+2}, got {offset_cap}"
)
built = build_bw_write_frame(
0x69, data_cap,
offset=offset_cap,
params=params_cap,
)
assert built == cap_wire
def test_frame_111_confirm_c_sub74(self, bw_frames: list[bytes]) -> None:
"""SUB 0x74 — confirm C after waveform data write (frame 111)."""
cap_wire = bw_frames[111]
sub_cap, _, _, data_cap, _ = _decode_bw_frame(cap_wire)
assert sub_cap == 0x74
assert data_cap == b""
built = build_bw_write_frame(0x74, b"")
assert built == cap_wire
def test_frame_112_confirm_a_sub72_end(self, bw_frames: list[bytes]) -> None:
"""SUB 0x72 — final confirm A at end of write sequence (frame 112)."""
cap_wire = bw_frames[112]
sub_cap, _, _, data_cap, _ = _decode_bw_frame(cap_wire)
assert sub_cap == 0x72
assert data_cap == b""
built = build_bw_write_frame(0x72, b"")
assert built == cap_wire
class TestOffsetFormula:
"""Verify the offset = data[1] + 2 formula for single-chunk write commands."""
def test_event_index_offset_formula(self, bw_frames: list[bytes]) -> None:
"""Frame 102 (SUB 0x68): offset = data[1] + 2."""
_, offset_cap, _, data_cap, _ = _decode_bw_frame(bw_frames[102])
assert offset_cap == data_cap[1] + 2
def test_trigger_config_offset_formula(self, bw_frames: list[bytes]) -> None:
"""Frame 108 (SUB 0x82): offset = data[1] + 2."""
_, offset_cap, _, data_cap, _ = _decode_bw_frame(bw_frames[108])
assert offset_cap == data_cap[1] + 2
def test_waveform_data_offset_formula(self, bw_frames: list[bytes]) -> None:
"""Frame 110 (SUB 0x69): offset = data[1] + 2."""
_, offset_cap, _, data_cap, _ = _decode_bw_frame(bw_frames[110])
assert offset_cap == data_cap[1] + 2
class TestChecksumVerification:
"""Verify large-frame DLE-aware checksum for all write frames."""
def _verify_checksum(self, wire: bytes, label: str) -> None:
inner = wire[2:-1]
payload = _destuff(inner)
chk = payload[-1]
computed = (sum(b for b in payload[2:-1] if b != 0x10) + 0x10) & 0xFF
assert computed == chk, (
f"{label}: checksum mismatch — computed=0x{computed:02X}, got=0x{chk:02X}"
)
def test_all_write_frame_checksums(self, bw_frames: list[bytes]) -> None:
write_frames = {
102: "SUB 0x68 event index write",
103: "SUB 0x73 confirm B",
104: "SUB 0x71 compliance chunk 1",
105: "SUB 0x71 compliance chunk 2",
106: "SUB 0x71 compliance chunk 3",
107: "SUB 0x72 confirm A",
108: "SUB 0x82 trigger config write",
109: "SUB 0x83 trigger confirm",
110: "SUB 0x69 waveform data write",
111: "SUB 0x74 confirm C",
112: "SUB 0x72 confirm A (end)",
}
for idx, label in write_frames.items():
self._verify_checksum(bw_frames[idx], f"Frame {idx} ({label})")
class TestComplianceChunkSizes:
"""Verify compliance write chunk sizes and sequence."""
def test_chunk1_size(self, bw_frames: list[bytes]) -> None:
_, _, _, data, _ = _decode_bw_frame(bw_frames[104])
assert len(data) == 1027, f"Chunk 1 should be 1027 bytes, got {len(data)}"
def test_chunk2_size(self, bw_frames: list[bytes]) -> None:
_, _, _, data, _ = _decode_bw_frame(bw_frames[105])
assert len(data) == 1055, f"Chunk 2 should be 1055 bytes, got {len(data)}"
def test_chunk3_size(self, bw_frames: list[bytes]) -> None:
_, _, _, data, _ = _decode_bw_frame(bw_frames[106])
assert len(data) == 46, f"Chunk 3 should be 46 bytes, got {len(data)}"
def test_total_compliance_data(self, bw_frames: list[bytes]) -> None:
total = sum(
len(_decode_bw_frame(bw_frames[i])[3]) for i in [104, 105, 106]
)
assert total == 2128, f"Total compliance write data should be 2128 bytes, got {total}"
def test_chunk1_params(self, bw_frames: list[bytes]) -> None:
_, _, params, _, _ = _decode_bw_frame(bw_frames[104])
assert params == bytes(10)
def test_chunk2_params(self, bw_frames: list[bytes]) -> None:
_, _, params, _, _ = _decode_bw_frame(bw_frames[105])
assert params == bytes([0x00, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])
def test_chunk3_params(self, bw_frames: list[bytes]) -> None:
_, _, params, _, _ = _decode_bw_frame(bw_frames[106])
assert params == bytes([0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
def test_chunk1_offset(self, bw_frames: list[bytes]) -> None:
_, offset, _, _, _ = _decode_bw_frame(bw_frames[104])
assert offset == 0x1004
def test_chunk2_offset(self, bw_frames: list[bytes]) -> None:
_, offset, _, _, _ = _decode_bw_frame(bw_frames[105])
assert offset == 0x1004
def test_chunk3_offset(self, bw_frames: list[bytes]) -> None:
_, offset, _, _, _ = _decode_bw_frame(bw_frames[106])
assert offset == 0x002C
# ── Standalone runner ──────────────────────────────────────────────────────────
if __name__ == "__main__":
if not os.path.exists(CAPTURE_PATH):
print(f"ERROR: Capture file not found: {CAPTURE_PATH}")
sys.exit(1)
frames = _load_bw_frames(CAPTURE_PATH)
print(f"Loaded {len(frames)} BW frames from capture")
write_frame_indices = list(range(102, 113))
all_pass = True
print()
print(f"{'Frame':>6} {'SUB':>5} {'Offset':>8} {'DataLen':>8} {'Chk OK':>7} {'Rebuilt':>8}")
print("-" * 60)
for idx in write_frame_indices:
wire = frames[idx]
sub, offset, params, data, chk = _decode_bw_frame(wire)
payload = _destuff(wire[2:-1])
computed = (sum(b for b in payload[2:-1] if b != 0x10) + 0x10) & 0xFF
chk_ok = computed == chk
built = build_bw_write_frame(sub, data, offset=offset, params=params)
rebuilt_ok = built == wire
status = "" if (chk_ok and rebuilt_ok) else ""
print(
f" {idx:4d} 0x{sub:02X} 0x{offset:04X} {len(data):8d} "
f"{'' if chk_ok else '':>7} {'' if rebuilt_ok else '':>8} {status}"
)
if not (chk_ok and rebuilt_ok):
all_pass = False
print()
if all_pass:
print("All 11 write frames verified ✅")
else:
print("FAILURES DETECTED ❌")
sys.exit(1)