2 Commits

Author SHA1 Message Date
claude 6a0422a6fc docs: add CHANGELOG, rewrite README for v0.5.0
- Establish v0.5.0 as first versioned release
- README rewritten to reflect current scope: Blastware replacement in
  progress, not just a reverse-engineering capture tool
- Documents all current components: seismo_lab.py, minimateplus,
  sfm/server.py, Console tab, TCP/cellular transport
- Adds ACEmanager required settings table (Quiet Mode etc.)
- Adds roadmap section
- CHANGELOG.md created with entries from v0.1.0 through v0.5.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:26:25 -04:00
claude 1078576023 Add Console tab to seismo_lab + document RV50/RV55 modem config
seismo_lab.py:
- Add ConsolePanel — third tab for direct device connections over serial
  or TCP without the bridge subprocess
- Commands: POLL, Serial #, Full Config, Event Index (open/close per cmd)
- Colour-coded output: TX blue, RX raw teal, parsed green, errors red
- Save Log and Send to Analyzer buttons; auto-saves to bridges/captures/
- Queue/after(100) pattern — no performance impact
- Add SCRIPT_DIR to sys.path so minimateplus imports work from GUI

docs/instantel_protocol_reference.md:
- Confirm calibration year field at SUB FE payload offset 0x56–0x57
  (uint16 BE): 0x07E7=2023 (BE18189), 0x07E9=2025 (BE11529)
- Document full Sierra Wireless RV50/RV55 required ACEmanager settings
  (Quiet Mode, Data Forwarding Timeout, TCP Connect Response Delay, etc.)
- Correct §14.2: RV50/RV55 sends RING/CONNECT over TCP to caller even
  with Quiet Mode on; parser handles by scanning for DLE+STX
- Confirm "Operating System" boot string capture via cold-start Console
- Resolve open question: 0x07E7 field = calibration year

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:19:31 -04:00
4 changed files with 3009 additions and 2517 deletions
+84
View File
@@ -0,0 +1,84 @@
# Changelog
All notable changes to seismo-relay are documented here.
---
## v0.5.0 — 2026-03-31
### Added
- **Console tab in `seismo_lab.py`** — direct device connection without the bridge subprocess.
- Serial and TCP transport selectable via radio buttons.
- Four one-click commands: POLL, Serial #, Full Config, Event Index.
- Colour-coded scrolling output: TX (blue), RX raw hex (teal), parsed/decoded (green), errors (red).
- Save Log and Send to Analyzer buttons; logs auto-saved to `bridges/captures/console_<ts>.log`.
- Queue/`after(100)` pattern — no UI blocking or performance impact.
- **`minimateplus` package** — clean Python client library for the MiniMate Plus S3 protocol.
- `SerialTransport` and `TcpTransport` (for Sierra Wireless RV50/RV55 cellular modems).
- `MiniMateProtocol` — DLE frame parser/builder, two-step paged reads, checksum validation.
- `MiniMateClient` — high-level client: `connect()`, `get_serial()`, `get_config()`, `get_events()`.
- **TCP/cellular transport** (`TcpTransport`) — connect to field units via Sierra Wireless RV50/RV55 modems over cellular.
- `read_until_idle(idle_gap=1.5s)` to handle modem data-forwarding buffer delay.
- Confirmed working end-to-end: TCP → RV50/RV55 → RS-232 → MiniMate Plus.
- **`bridges/tcp_serial_bridge.py`** — local TCP-to-serial bridge for bench testing `TcpTransport` without a cellular modem.
- **SFM REST server** (`sfm/server.py`) — FastAPI server with device info, event list, and event record endpoints over both serial and TCP.
### Fixed
- `protocol.py` `startup()` was using a hardcoded `POLL_RECV_TIMEOUT = 10.0` constant, ignoring the configurable `self._recv_timeout`. Fixed to use `self._recv_timeout` throughout.
- `sfm/server.py` now retries once on `ProtocolError` for TCP connections to handle cold-boot timing on first connect.
### Protocol / Documentation
- **Sierra Wireless RV50/RV55 modem config** — confirmed required ACEmanager settings: Quiet Mode = Enable, Data Forwarding Timeout = 1, TCP Connect Response Delay = 0. Quiet Mode disabled causes modem to inject `RING\r\nCONNECT\r\n` onto the serial line, breaking the S3 handshake.
- **Calibration year** confirmed at SUB FE (Full Config) destuffed payload offset 0x560x57 (uint16 BE). `0x07E7` = 2023, `0x07E9` = 2025.
- **`"Operating System"` boot string** — 16-byte UART boot message captured on cold-start before unit enters DLE-framed mode. Parser handles correctly by scanning for DLE+STX.
- RV50/RV55 sends `RING`/`CONNECT` over TCP to the calling client even with Quiet Mode enabled — this is normal behaviour, parser discards it.
---
## v0.4.0 — 2026-03-12
### Added
- **`seismo_lab.py`** — combined Bridge + Analyzer GUI. Single window with two tabs; bridge start auto-wires live mode in the Analyzer.
- **`frame_db.py`** — SQLite frame database. Captures accumulate over time; Query DB tab searches across all sessions.
- **`bridges/s3-bridge/proxy.py`** — bridge proxy module.
- Large BW→S3 write frame checksum algorithm confirmed and implemented (`SUM8` of payload `[2:-1]` skipping `0x10` bytes, plus constant `0x10`, mod 256).
- SUB `A4` identified as composite container frame with embedded inner frames; `_extract_a4_inner_frames()` and `_diff_a4_payloads()` reduce diff noise from 2300 → 17 meaningful entries.
### Fixed
- BAD CHK false positives on BW POLL frames — BW frame terminator `03 41` was being included in the de-stuffed payload. Fixed to strip correctly.
- Aux Trigger read location confirmed at SUB FE offset `0x0109`.
---
## v0.3.0 — 2026-03-09
### Added
- Record time confirmed at SUB E5 page2 offset `+0x28` as float32 BE.
- Trigger Sample Width confirmed at BW→S3 write frame SUB `0x82`, destuffed payload offset `[22]`.
- Mode-gating documented: several settings only appear on the wire when the appropriate mode is active.
### Fixed
- `0x082A` mystery resolved — fixed-size E5 payload length (2090 bytes), not a record-time field.
---
## v0.2.0 — 2026-03-01
### Added
- Channel config float layout fully confirmed: trigger level, alarm level, and unit string per channel (IEEE 754 BE floats).
- Blastware `.set` file format decoded — little-endian binary struct mirroring the wire payload.
- Operator manual (716U0101 Rev 15) added as cross-reference source.
---
## v0.1.0 — 2026-02-26
### Added
- Initial `s3_bridge.py` serial bridge — transparent RS-232 tap between Blastware and MiniMate Plus.
- `s3_parser.py` — deterministic DLE state machine frame extractor.
- `s3_analyzer.py` — session parser, frame differ, Claude export.
- `gui_bridge.py` and `gui_analyzer.py` — Tkinter GUIs.
- DLE framing confirmed: `DLE+STX` / `DLE+ETX`, `0x41` = ACK (not STX), DLE stuffing rule.
- Response SUB rule confirmed: `response_SUB = 0xFF - request_SUB`.
- Year `0x07CB` = 1995 confirmed as MiniMate factory RTC default.
- Full write command family documented (SUBs `68``83`).
+169 -196
View File
@@ -1,9 +1,14 @@
# seismo-relay
# seismo-relay `v0.5.0`
Tools for capturing and reverse-engineering the RS-232 serial protocol between
**Blastware** software and **Instantel MiniMate Plus** seismographs.
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
software for managing MiniMate Plus seismographs.
Built for Windows, stdlib-only (plus `pyserial` for the bridge).
Built in Python. Runs on Windows. Connects to instruments over direct RS-232
or cellular modem (Sierra Wireless RV50 / RV55).
> **Status:** Active development. Core read pipeline working (device info,
> config, event index). Event download and write commands in progress.
> See [CHANGELOG.md](CHANGELOG.md) for version history.
---
@@ -11,217 +16,177 @@ Built for Windows, stdlib-only (plus `pyserial` for the bridge).
```
seismo-relay/
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
├── minimateplus/ ← MiniMate Plus client library
│ ├── transport.py ← SerialTransport and TcpTransport
│ ├── protocol.py ← DLE frame layer (read/write/parse)
│ ├── client.py ← High-level client (connect, get_config, etc.)
│ ├── framing.py ← Frame builder/parser primitives
│ └── models.py ← DeviceInfo, EventRecord, etc.
├── sfm/ ← SFM REST API server (FastAPI)
│ └── server.py ← /device/info, /device/events, /device/event
├── bridges/
│ ├── s3-bridge/
│ │ └── s3_bridge.py ← The serial bridge (core capture tool)
│ ├── gui_bridge.py ← Tkinter GUI wrapper for s3_bridge
── raw_capture.py ← Simpler raw-only capture tool
└── parsers/
├── s3_parser.py ← Low-level DLE frame extractor
├── s3_analyzer.py ← Protocol analyzer (sessions, diffs, exports)
├── gui_analyzer.py ← Tkinter GUI for the analyzer
└── frame_db.py ← SQLite frame database
```
---
## How it all fits together
The workflow has two phases: **capture**, then **analyze**.
```
Blastware PC
│ │ └── s3_bridge.py ← RS-232 serial bridge (capture tool)
│ ├── tcp_serial_bridge.py Local TCP↔serial bridge (bench testing)
── gui_bridge.py Standalone bridge GUI (legacy)
│ └── raw_capture.py ← Simple raw capture tool
Virtual COM (e.g. COM4)
├── parsers/
│ ├── s3_parser.py ← DLE frame extractor
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
│ ├── gui_analyzer.py ← Standalone analyzer GUI (legacy)
│ └── frame_db.py ← SQLite frame database
s3_bridge.py ←─── sits in the middle, forwards all bytes both ways
│ writes raw_bw.bin and raw_s3.bin
Physical COM (e.g. COM5)
MiniMate Plus seismograph
└── docs/
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec
```
After capturing, you point the analyzer at the two `.bin` files to inspect
what happened.
---
## Part 1 — The Bridge
## Quick start
### `s3_bridge.py` — Serial bridge
### Seismo Lab (main GUI)
Transparently forwards bytes between Blastware and the seismograph while
logging everything to disk. Blastware operates normally and has no idea the
bridge is there.
The all-in-one tool. Three tabs: **Bridge**, **Analyzer**, **Console**.
**Run it:**
```
python bridges/s3-bridge/s3_bridge.py --bw COM4 --s3 COM5 --logdir captures/
python seismo_lab.py
```
**Key flags:**
| Flag | Default | Description |
|------|---------|-------------|
| `--bw` | required | COM port connected to Blastware |
| `--s3` | required | COM port connected to the seismograph |
| `--baud` | 38400 | Baud rate (match your device) |
| `--logdir` | `.` | Where to write log/bin files |
| `--raw-bw` | off | Also write a flat raw file for BW→S3 traffic |
| `--raw-s3` | off | Also write a flat raw file for S3→BW traffic |
### SFM REST server
**Output files (in `--logdir`):**
- `s3_session_<timestamp>.bin` — structured binary log with timestamps
and direction tags (record format: `[type:1][ts_us:8][len:4][payload]`)
- `s3_session_<timestamp>.log` — human-readable hex dump (text)
- `raw_bw.bin` — flat BW→S3 byte stream (if `--raw-bw` used)
- `raw_s3.bin` — flat S3→BW byte stream (if `--raw-s3` used)
Exposes MiniMate Plus commands as a REST API for integration with other systems.
> The analyzer needs `raw_bw.bin` + `raw_s3.bin`. Always use `--raw-bw` and
> `--raw-s3` when capturing.
```
cd sfm
uvicorn server:app --reload
```
**Interactive commands** (type while bridge is running):
- `m` + Enter → prompts for a label and inserts a MARK record into the log
- `q` + Enter → quit
**Endpoints:**
| Method | URL | Description |
|--------|-----|-------------|
| `GET` | `/device/info?port=COM5` | Device info via serial |
| `GET` | `/device/info?host=1.2.3.4&tcp_port=9034` | Device info via cellular modem |
| `GET` | `/device/events?port=COM5` | Event index |
| `GET` | `/device/event?port=COM5&index=0` | Single event record |
---
### `gui_bridge.py` — Bridge GUI
## Seismo Lab tabs
A simple point-and-click wrapper around `s3_bridge.py`. Easier than the
command line if you don't want to type flags every time.
### Bridge tab
Captures live RS-232 traffic between Blastware and the seismograph. Sits in
the middle as a transparent pass-through while logging everything to disk.
```
python bridges/gui_bridge.py
Blastware → COM4 (virtual) ↔ s3_bridge ↔ COM5 (physical) → MiniMate Plus
```
Set your COM ports, log directory, and tick the raw tap checkboxes before
hitting **Start**. The **Add Mark** button lets you annotate the capture
at any point (e.g. "changed record time to 13s").
Set your COM ports and log directory, then hit **Start Bridge**. Use
**Add Mark** to annotate the capture at specific moments (e.g. "changed
trigger level"). When the bridge starts, the Analyzer tab automatically wires
up to the live files and starts updating in real time.
### Analyzer tab
Parses raw captures into DLE-framed protocol sessions, diffs consecutive
sessions to show exactly which bytes changed, and lets you query across all
historical captures via the built-in SQLite database.
- **Inventory** — all frames in a session, click to drill in
- **Hex Dump** — full payload hex dump with changed-byte annotations
- **Diff** — byte-level before/after diff between sessions
- **Full Report** — plain text session report
- **Query DB** — search across all captures by SUB, direction, or byte value
Use **Export for Claude** to generate a self-contained `.md` report for
AI-assisted field mapping.
### Console tab
Direct connection to a MiniMate Plus — no bridge, no Blastware. Useful for
diagnosing field units over cellular without a full capture session.
**Connection:** choose Serial (COM port + baud) or TCP (IP + port for
cellular modem).
**Commands:**
| Button | What it does |
|--------|-------------|
| POLL | Startup handshake — confirms unit is alive and identifies model |
| Serial # | Reads unit serial number |
| Full Config | Reads full 166-byte config block (firmware version, channel scales, etc.) |
| Event Index | Reads stored event list |
Output is colour-coded: TX in blue, raw RX bytes in teal, decoded fields in
green, errors in red. **Save Log** writes a timestamped `.log` file to
`bridges/captures/`. **Send to Analyzer** injects the captured bytes into the
Analyzer tab for deeper inspection.
---
## Part 2 — The Analyzer
## Connecting over cellular (RV50 / RV55 modems)
After capturing, you have `raw_bw.bin` (bytes Blastware sent) and `raw_s3.bin`
(bytes the seismograph replied with). The analyzer parses these into protocol
frames, groups them into sessions, and helps you figure out what each byte means.
Field units connect via Sierra Wireless RV50 or RV55 cellular modems. Use
TCP mode in the Console or SFM:
### What's a "session"?
```
# Console tab
Transport: TCP
Host: <modem public IP>
Port: 9034 ← Device Port in ACEmanager (call-up mode)
```
Each time you open the settings dialog in Blastware and click Apply/OK, that's
one session — a complete read/modify/write cycle. The bridge detects session
boundaries by watching for the final write-confirm packet (SUB `0x74`).
```python
# In code
from minimateplus.transport import TcpTransport
from minimateplus.client import MiniMateClient
Each session contains a sequence of request/response frame pairs:
- Blastware sends a **request** (BW→S3): "give me your config block"
- The seismograph sends a **response** (S3→BW): here it is
- At the end, Blastware sends the modified settings back in a series of write packets
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 9034))
info = client.connect()
```
The analyzer lines these up and diffs consecutive sessions to show you exactly
which bytes changed.
### Required ACEmanager settings (Serial tab)
These must match exactly — a single wrong setting causes the unit to beep
on connect but never respond:
| Setting | Value | Why |
|---------|-------|-----|
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
| Flow Control | `None` | Hardware flow control blocks unit TX if pins unconnected |
| **Quiet Mode** | **Enable** | **Critical.** Disabled → modem injects `RING`/`CONNECT` onto serial line, corrupting the S3 handshake |
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency; `5` works but is sluggish |
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
---
### `gui_analyzer.py` — Analyzer GUI
## minimateplus library
```python
from minimateplus import MiniMateClient
from minimateplus.transport import SerialTransport, TcpTransport
# Serial
client = MiniMateClient(port="COM5")
# TCP (cellular modem)
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 9034), timeout=30.0)
with client:
info = client.connect() # DeviceInfo — model, serial, firmware
serial = client.get_serial() # Serial number string
config = client.get_config() # Full config block (bytes)
events = client.get_events() # Event index
```
python parsers/gui_analyzer.py
```
This is the main tool. It has five tabs:
#### Toolbar
- **S3 raw / BW raw** — browse to your `raw_s3.bin` and `raw_bw.bin` files
- **Analyze** — parse and load the captures
- **Live: OFF/ON** — watch the files grow in real time while the bridge is running
- **Export for Claude** — generate a self-contained `.md` report for AI-assisted analysis
#### Inventory tab
Shows all frames in the selected session — direction, SUB command, page,
length, and checksum status. Click any frame in the left tree to drill in.
#### Hex Dump tab
Full hex dump of the selected frame's payload. If the frame had changed bytes
vs the previous session, those are listed below the dump with before/after values
and field names where known.
#### Diff tab
Side-by-side byte-level diff between the current session and the previous one.
Only SUBs (command types) that actually changed are shown.
#### Full Report tab
Raw text version of the session report — useful for copying into notes.
#### Query DB tab
Search across all your captured sessions using the built-in database.
---
### `s3_analyzer.py` — Analyzer (command line)
If you prefer the terminal:
```
python parsers/s3_analyzer.py --s3 raw_s3.bin --bw raw_bw.bin
```
**Flags:**
| Flag | Description |
|------|-------------|
| `--s3` | Path to raw_s3.bin |
| `--bw` | Path to raw_bw.bin |
| `--live` | Tail files in real time (poll mode) |
| `--export` | Also write a `claude_export_<ts>.md` file |
| `--outdir` | Where to write `.report` files (default: same folder as input) |
| `--poll` | Live mode poll interval in seconds (default: 0.05) |
Writes one `.report` file per session and prints a summary to the console.
---
## The Frame Database
Every time you click **Analyze**, the frames are automatically saved to a
SQLite database at:
```
C:\Users\<you>\.seismo_lab\frames.db
```
This accumulates captures over time so you can query across sessions and dates.
### Query DB tab
Use the filter bar to search:
- **Capture** — narrow to a specific capture (timestamp shown)
- **Dir** — BW (requests) or S3 (responses) only
- **SUB** — filter by command type (e.g. `0xF7` = EVENT_INDEX_RESPONSE)
- **Offset** — filter to frames that have a specific byte offset
- **Value** — combined with Offset: "show frames where byte 85 = 0x0A"
Click any result row, then use the **Byte interpretation** panel at the bottom
to see what that offset's bytes look like as uint8, int8, uint16 BE/LE,
uint32 BE/LE, and float32 BE/LE simultaneously.
This is the main tool for mapping unknown fields — if you change one setting in
Blastware, capture before and after, then query for frames where that offset
moved, you can pin down exactly which byte controls what.
---
## Export for Claude
The **Export for Claude** button (orange, in the toolbar) generates a single
`.md` file containing:
1. Protocol background and known field map
2. Capture summary (session count, frame counts, what changed)
3. Per-diff tables — before/after bytes for every changed offset, with field
names where known
4. Full hex dumps of all frames in the baseline session
Paste this file into a Claude conversation to get help mapping unknown fields,
interpreting data structures, or understanding sequences.
---
@@ -232,47 +197,55 @@ interpreting data structures, or understanding sequences.
| DLE | `0x10` | Data Link Escape |
| STX | `0x02` | Start of frame |
| ETX | `0x03` | End of frame |
| ACK | `0x41` | Frame start marker (BW side) |
| ACK | `0x41` (`'A'`) | Frame-start marker sent before every frame |
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
**S3-side frame** (seismograph → Blastware): `DLE STX [payload] DLE ETX`
**BW-side frame** (Blastware → seismograph): `ACK STX [payload] ETX`
**S3-side frame** (seismograph → Blastware): `ACK DLE+STX [payload] CHK DLE+ETX`
**De-stuffed payload header** (first 5 bytes after de-stuffing):
**De-stuffed payload header:**
```
[0] CMD 0x10 = BW request, 0x00 = S3 response
[1] ? 0x00 (BW) or 0x10 (S3)
[1] ? unknown (0x00 BW / 0x10 S3)
[2] SUB Command/response identifier ← the key field
[3] OFFSET_HI Page address high byte
[4] OFFSET_LO Page address low byte
[3] PAGE_HI Page address high byte
[4] PAGE_LO Page address low byte
[5+] DATA Payload content
```
**Response SUB rule:** `response_SUB = 0xFF - request_SUB`
Example: request SUB `0x08` → response SUB `0xF7`
Example: request SUB `0x08` (Event Index) → response SUB `0xF7`
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
---
## Requirements
```
pip install pyserial
pip install pyserial fastapi uvicorn
```
Python 3.10+. Everything else is stdlib (Tkinter, sqlite3, struct, hashlib).
Tkinter is included with the standard Python installer on Windows. If it's
missing, reinstall Python and make sure "tcl/tk and IDLE" is checked.
Python 3.10+. Tkinter is included with the standard Python installer on
Windows (make sure "tcl/tk and IDLE" is checked during install).
---
## Virtual COM ports
## Virtual COM ports (bridge capture)
The bridge needs two COM ports on the same PC — one that Blastware connects to,
and one wired to the actual seismograph. On Windows, use a virtual COM port pair
(e.g. **com0com** or **VSPD**) to give Blastware a port to talk to while the
bridge sits in the middle.
The bridge needs two COM ports on the same PC — one that Blastware connects
to, and one wired to the seismograph. Use a virtual COM port pair
(**com0com** or **VSPD**) to give Blastware a port to talk to.
```
Blastware → COM4 (virtual) ↔ s3_bridge ↔ COM5 (physical) → MiniMate
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
```
---
## Roadmap
- [ ] Event download — pull waveform records from the unit (SUBs `1E``0A``0C``5A`)
- [ ] Write commands — push config changes to the unit (compliance setup, channel config, trigger settings)
- [ ] ACH inbound server — accept call-home connections from field units
- [ ] Modem manager — push standard configs to RV50/RV55 fleet via Sierra Wireless API
- [ ] Full Blastware parity — complete read/write/download cycle without Blastware
+29 -2
View File
@@ -59,6 +59,10 @@
| 2026-03-30 | §3, §5.1 | **CONFIRMED — BW→S3 two-step read offset is at payload[5], NOT payload[3:4].** All BW read-command frames have `payload[3] = 0x00` and `payload[4] = 0x00` unconditionally. The two-step offset byte lives at `payload[5]`: `0x00` for the length-probe step, `DATA_LEN` for the data-fetch step. Validated against all captured frames in `bridges/captures/3-11-26/raw_bw_*.bin` — every frame is an exact bit-for-bit match when built with offset at `[5]`. The `page_hi`/`page_lo` framing in the docstring was a misattribution from the S3-side response layout (where `[3]`/`[4]` ARE page bytes). |
| 2026-03-30 | §4, §5.2 | **CONFIRMED — S3 probe response page_key is always 0x0000.** The S3 response to a length-probe step does NOT carry the data length back in `page_hi`/`page_lo`. Both bytes are `0x00` in every observed probe response. Data lengths for each SUB are fixed constants (see §5.1 table). The `minimateplus` library now uses a hardcoded `DATA_LENGTHS` dict rather than trying to read the length from the probe response. |
| 2026-03-31 | §12 TCP Transport | **NEW SECTION — TCP/modem transport confirmed transparent from Blastware Operator Manual (714U0301 Rev 22).** Key facts confirmed: (1) Protocol bytes over TCP are bit-for-bit identical to RS-232 — no handshake framing. (2) No ENQ byte on TCP connect (`Enable ENQ on TCP Connect: 0-Disable` in Raven ACEmanager). (3) Raven modem `Data Forwarding Timeout = 1 second` — modem buffers serial bytes up to 1s before forwarding over TCP; `TcpTransport.read_until_idle` uses `idle_gap=1.5s` to compensate. (4) TCP port is user-configurable (12335 in manual example; user's install uses 12345). (5) Baud rate over serial link to modem is 38400,8N1 regardless of TCP path. (6) ACH (Auto Call Home) = INBOUND to server (unit calls home); "call up" = OUTBOUND from client (Blastware/SFM connects to modem IP). `TcpTransport` implements outbound (call-up) mode. |
| 2026-03-31 | §14.3 | **NEW — Sierra Wireless RV50/RV55 Quiet Mode requirement confirmed.** Quiet Mode (ATQ) must be **enabled** on the serial port. When disabled (+ Verbose mode on), the modem injects `RING\r\nCONNECT\r\n` onto the RS-232 serial line at connection time — MiniMate receives unexpected bytes, loses protocol sync, and never responds to POLL (unit beeps but returns no S3 frame). Working RV50 field config: Quiet Mode enabled, Data Forwarding Timeout=1, TCP Connect Response Delay=0. Misconfigured RV55 had all three wrong. |
| 2026-03-31 | §14.2 | **CORRECTED — Sierra Wireless RV50/RV55 sends `RING`/`CONNECT` over TCP to caller even with Quiet Mode enabled.** Quiet Mode suppresses these only on the serial port (protecting the MiniMate). TCP client still receives `\r\nRING\r\n\r\nCONNECT\r\n` prefixed before the first S3 frame bytes. Parser handles correctly by scanning for DLE+STX (`0x10 0x02`) and discarding prefix bytes. Previous note "no CONNECT string" described Raven X ENQ-disable behaviour; RV50/RV55 differ. |
| 2026-03-31 | §7.3 | **NEW — Calibration date field confirmed** at Full Config (SUB FE) destuffed payload offsets 0x530x57. Two-unit comparison: BE18189 (calibrated 2023) has `07 E7` at 0x560x57; BE11529 (calibrated 2025) has `07 E9`. Bytes 0x560x57 = uint16 BE calibration year ✅ CONFIRMED. Adjacent bytes at 0x530x55 likely encode month/day (both units show `0x10` at offset 0x54 = BCD October; 0x53 and 0x55 differ between units). Full date layout 🔶 INFERRED — pending third-unit capture or recalibration diff. Resolves open question. |
| 2026-03-31 | §9 | **CONFIRMED via Console cold-start capture**`"Operating System"` (16 B: `4f 70 65 72 61 74 69 6e 67 20 53 79 73 74 65 6d`) arrives as first TCP bytes on cold-connect before unit enters DLE-framed mode. `TcpTransport` + retry logic handles gracefully: first attempt times out waiting for SUB A4; second connect (after unit fully booted) succeeds. |
---
@@ -357,6 +361,10 @@ Unit 2: serial="BE11529" trail=70 11 firmware=S337.17
| 0x1C | `3F 80 00 00` ×6 | IEEE 754 float = **1.0** ×6 (remaining channel scales) | 🔶 INFERRED |
| 0x34 | `53 33 33 37 2E 31 37 00` | `"S337.17\x00"` — Firmware version | ✅ CONFIRMED |
| 0x3C | `31 30 2E 37 32 00` | `"10.72\x00"` — DSP / secondary firmware version | ✅ CONFIRMED |
| 0x53 | varies | Likely calibration day or time field — 0x15 (BE18189), 0x1D (BE11529) | 🔶 INFERRED |
| 0x54 | `10` | Calibration month — BCD `0x10` = October (both units) | 🔶 INFERRED |
| 0x55 | varies | Calibration day — `0x02` (BE18189), `0x04` (BE11529) | 🔶 INFERRED |
| 0x560x57 | `07 E7` / `07 E9` | Calibration year — uint16 BE. `0x07E7`=2023, `0x07E9`=2025 | ✅ CONFIRMED — 2026-03-31 |
| 0x44 | `49 6E 73 74 61 6E 74 65 6C...` | `"Instantel"` — Manufacturer (repeated) | ✅ CONFIRMED |
| 0x6D | `4D 69 6E 69 4D 61 74 65 20 50 6C 75 73` | `"MiniMate Plus"` — Model name | ✅ CONFIRMED |
@@ -937,7 +945,11 @@ Enable ENQ on TCP Connect: 0-Disable
When a TCP connection is established (in either direction), **no ENQ byte or other handshake marker is sent** by the modem before the protocol stream starts. The first byte from either side is a raw protocol byte — for SFM-initiated call-up, SFM sends POLL_PROBE immediately after `connect()`.
No banner, no "CONNECT" string, no Telnet negotiation preamble. The Raven modem's TCP dialog is configured with:
**Sierra Wireless RV50/RV55 note:** Even with Quiet Mode enabled, these modems send `\r\nRING\r\n\r\nCONNECT\r\n` over the TCP connection to the calling client at connect time. Quiet Mode only suppresses these strings on the *serial* port (protecting the MiniMate Plus). The TCP client must tolerate these prefix bytes — scan for DLE+STX (`0x10 0x02`) and discard everything before it. This is the same approach used for the `"Operating System"` boot string (§9).
The Raven X (deprecated) did not exhibit this behaviour. The note below about "no CONNECT string" describes Raven X with ENQ-disable; it does **not** apply to RV50/RV55.
No ENQ byte or other application-layer handshake is added. The Raven modem's TCP dialog is configured with:
| ACEmanager Setting | Value | Meaning |
|---|---|---|
@@ -971,6 +983,21 @@ The **Data Forwarding Timeout** is the most protocol-critical setting. The mode
If connecting to a unit via a direct Ethernet connection (no serial modem in the path), the 1.5 s idle gap will still work but will feel slower. In that case you can pass `idle_gap=0.1` explicitly.
#### Sierra Wireless RV50 / RV55 Required Settings
> ✅ **CONFIRMED — 2026-03-31** from working RV50 field config vs misconfigured RV55.
The following ACEmanager Serial settings are required for correct transparent operation. A single wrong setting is enough to break the protocol exchange (unit beeps on connect but never returns an S3 frame):
| ACEmanager Setting | Required Value | Why |
|---|---|---|
| **Quiet Mode** | **Enable** | Disabling it causes the modem to inject `RING\r\nCONNECT\r\n` onto the RS-232 serial line at connection time, corrupting the S3 handshake. |
| **Configure Serial Port** | `38400,8N1` | Must match MiniMate baud rate. |
| **Flow Control** | `None` | Hardware flow control (CTS/RTS) will block unit's serial TX if pins are not wired. |
| **Data Forwarding Timeout** | `1` (= 0.1 s) | Controls RS-232→TCP forwarding latency. `5` (0.5 s) works but is sluggish; `1` matches working field units. |
| **TCP Connect Response Delay** | `0` | Any non-zero value causes the modem to silently discard our POLL frame during the delay window. |
| **TCP Idle Timeout** | `2` (minutes) | Prevents premature disconnect. Too low and the modem drops the session before the unit responds. |
| **DB9 Serial Echo** | `Disable` | Echo would corrupt the S3 stream. |
---
### 14.4 Connection Timeouts on the Unit Side
@@ -1052,7 +1079,7 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
| Unknown uint16 fields at channel block +0A (=80), +0C (=15), +0E (=40), +10 (=21) — manual describes "Sensitive (Gain=8) / Normal (Gain=1)" per-channel range; 80/15/40/21 might encode gain, sensitivity, or ADC config. | LOW | 2026-03-01 | |
| Full trigger configuration field mapping (SUB `1C` / write `82`) | LOW | 2026-02-26 | |
| Whether SUB `24`/`25` are distinct from SUB `5A` or redundant | LOW | 2026-02-26 | |
| Meaning of `0x07 E7` field in config block | LOW | 2026-02-26 | |
| **Meaning of `0x07 E7` field in config block — RESOLVED:** Calibration year. uint16 BE at destuffed payload offset 0x560x57. Confirmed via two-unit comparison: BE18189 (calibrated 2023) = `07 E7`; BE11529 (calibrated 2025) = `07 E9`. Adjacent bytes at 0x530x55 encode remaining calibration date (month confirmed as BCD October for both units; full layout 🔶 INFERRED). | RESOLVED | 2026-02-26 | Resolved 2026-03-31 |
| **Trigger Sample Width****RESOLVED:** BW→S3 write frame SUB `0x82`, destuffed payload offset `[22]`, uint8. Width=4 → `0x04`, Width=3 → `0x03`. Confirmed via BW-side capture diff. Only visible in `raw_bw.bin` write traffic, not in S3-side compliance reads. | RESOLVED | 2026-03-02 | Confirmed 2026-03-09 |
| **Auto Window** — "1 to 9 seconds" per manual (§3.13.1b). **Mode-gated:** only transmitted/active when Record Stop Mode = Auto. Capture attempted in Fixed mode (3→9 change) — no wire change observed in any frame. Deferred pending mode switch. | LOW | 2026-03-02 | Updated 2026-03-09 |
| **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 |
+410 -2
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3
"""
seismo_lab.py — Combined S3 Bridge + Protocol Analyzer GUI.
seismo_lab.py — Combined S3 Bridge + Protocol Analyzer + Device Console GUI.
Single window with two top-level tabs:
Single window with three top-level tabs:
Bridge — capture live serial traffic (wraps s3_bridge.py as subprocess)
Analyzer — parse, diff, and query captured frames
Console — direct device connection; runs commands and shows raw bytes +
decoded output; colour-coded TX/RX console with log save and
Send-to-Analyzer support
When the bridge starts:
- raw tap paths are auto-filled in the Analyzer tab
@@ -32,6 +35,7 @@ SCRIPT_DIR = Path(__file__).parent
BRIDGE_PATH = SCRIPT_DIR / "bridges" / "s3-bridge" / "s3_bridge.py"
PARSERS_DIR = SCRIPT_DIR / "parsers"
sys.path.insert(0, str(PARSERS_DIR))
sys.path.insert(0, str(SCRIPT_DIR)) # for minimateplus package
from s3_analyzer import ( # noqa: E402
AnnotatedFrame,
@@ -1066,6 +1070,399 @@ class AnalyzerPanel(tk.Frame):
w.configure(state="normal"); w.insert(tk.END, "\n"); w.configure(state="disabled")
# ─────────────────────────────────────────────────────────────────────────────
# Console panel (tk.Frame — lives inside a notebook tab)
# ─────────────────────────────────────────────────────────────────────────────
class ConsolePanel(tk.Frame):
"""
Direct device connection and diagnostic console.
Lets you run individual protocol commands against a MiniMate Plus via
serial or TCP, showing colour-coded TX/RX bytes and decoded output in a
scrolling console.
Colour scheme:
TX frames — ACCENT blue (#569cd6)
RX raw hex — teal (#4ec9b0)
Parsed/decoded — green (#4caf50)
Errors — red (#f44747)
Status/info — dim grey (#6a6a6a)
Section heads — yellow (#dcdcaa)
Log is auto-saved on "Save Log"; "Send to Analyzer" writes the captured
RX bytes as a raw .bin file and injects the path into the Analyzer tab.
"""
TAG_TX = "tx"
TAG_RX_RAW = "rx_raw"
TAG_PARSED = "parsed"
TAG_ERROR = "error"
TAG_STATUS = "status"
TAG_HEAD = "head"
MAX_LINES = 5000
def __init__(self, parent: tk.Widget, on_send_to_analyzer=None, **kw):
super().__init__(parent, bg=BG2, **kw)
self._on_send_to_analyzer = on_send_to_analyzer
self._q: queue.Queue = queue.Queue()
self._running = False
self._log_lines: list[str] = []
self._last_raw_rx: Optional[bytes] = None
self._cmd_btns: list[tk.Button] = []
self._build()
self._poll_q()
# ── build ─────────────────────────────────────────────────────────────
def _build(self) -> None:
pad = {"padx": 5, "pady": 3}
# ── top config row ────────────────────────────────────────────────
cfg = tk.Frame(self, bg=BG2)
cfg.pack(side=tk.TOP, fill=tk.X, padx=6, pady=4)
# Transport radio buttons
self._transport_var = tk.StringVar(value="tcp")
tk.Radiobutton(
cfg, text="TCP", variable=self._transport_var, value="tcp",
bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2,
font=MONO, command=self._on_transport_change,
).grid(row=0, column=0, padx=(0, 4))
tk.Radiobutton(
cfg, text="Serial", variable=self._transport_var, value="serial",
bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2,
font=MONO, command=self._on_transport_change,
).grid(row=0, column=1, padx=(0, 12))
# TCP fields
self._tcp_frame = tk.Frame(cfg, bg=BG2)
self._tcp_frame.grid(row=0, column=2, sticky="w")
tk.Label(self._tcp_frame, text="Host:", bg=BG2, fg=FG, font=MONO).pack(side=tk.LEFT, **pad)
self._host_var = tk.StringVar(value="127.0.0.1")
tk.Entry(
self._tcp_frame, textvariable=self._host_var, width=18,
bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO,
).pack(side=tk.LEFT, padx=2)
tk.Label(self._tcp_frame, text="Port:", bg=BG2, fg=FG, font=MONO).pack(side=tk.LEFT, padx=(10, 4))
self._tcp_port_var = tk.StringVar(value="9034")
tk.Entry(
self._tcp_frame, textvariable=self._tcp_port_var, width=6,
bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO,
).pack(side=tk.LEFT, padx=2)
# Serial fields (hidden by default)
self._serial_frame = tk.Frame(cfg, bg=BG2)
tk.Label(self._serial_frame, text="Port:", bg=BG2, fg=FG, font=MONO).pack(side=tk.LEFT, **pad)
self._port_var = tk.StringVar(value="COM5")
tk.Entry(
self._serial_frame, textvariable=self._port_var, width=10,
bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO,
).pack(side=tk.LEFT, padx=2)
tk.Label(self._serial_frame, text="Baud:", bg=BG2, fg=FG, font=MONO).pack(side=tk.LEFT, padx=(10, 4))
self._baud_var = tk.StringVar(value="38400")
tk.Entry(
self._serial_frame, textvariable=self._baud_var, width=8,
bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO,
).pack(side=tk.LEFT, padx=2)
# Timeout
tk.Label(cfg, text="Timeout:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=3, padx=(18, 4))
self._timeout_var = tk.StringVar(value="30")
tk.Entry(
cfg, textvariable=self._timeout_var, width=5,
bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO,
).grid(row=0, column=4, padx=2)
tk.Label(cfg, text="s", bg=BG2, fg=FG_DIM, font=MONO).grid(row=0, column=5)
# ── command buttons row ───────────────────────────────────────────
cmd_row = tk.Frame(self, bg=BG2)
cmd_row.pack(side=tk.TOP, fill=tk.X, padx=6, pady=(0, 4))
tk.Label(cmd_row, text="Commands:", bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=(0, 10))
for label, cmd in [
("POLL", "poll"),
("Serial #", "serial_number"),
("Full Config", "full_config"),
("Event Index", "event_index"),
]:
btn = tk.Button(
cmd_row, text=label, bg=ACCENT, fg="#ffffff",
relief="flat", padx=10, cursor="hand2", font=MONO,
command=lambda c=cmd: self._run_command(c),
)
btn.pack(side=tk.LEFT, padx=4)
self._cmd_btns.append(btn)
self._status_var = tk.StringVar(value="Ready")
tk.Label(cmd_row, textvariable=self._status_var,
bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=14)
# ── console output ────────────────────────────────────────────────
self._console = scrolledtext.ScrolledText(
self, height=20, font=MONO_SM,
bg=BG, fg=FG, insertbackground=FG,
relief="flat", state="disabled",
)
self._console.pack(fill=tk.BOTH, expand=True, padx=6, pady=4)
self._console.tag_configure(self.TAG_TX, foreground=ACCENT)
self._console.tag_configure(self.TAG_RX_RAW, foreground=COL_S3)
self._console.tag_configure(self.TAG_PARSED, foreground=GREEN)
self._console.tag_configure(self.TAG_ERROR, foreground=RED)
self._console.tag_configure(self.TAG_STATUS, foreground=FG_DIM)
self._console.tag_configure(self.TAG_HEAD, foreground=YELLOW, font=MONO_B)
# ── bottom bar ────────────────────────────────────────────────────
bot = tk.Frame(self, bg=BG2)
bot.pack(side=tk.BOTTOM, fill=tk.X, padx=6, pady=4)
tk.Button(
bot, text="Clear", bg=BG3, fg=FG, relief="flat",
padx=10, cursor="hand2", font=MONO,
command=self._clear_console,
).pack(side=tk.LEFT, padx=4)
tk.Button(
bot, text="Save Log", bg=BG3, fg=FG, relief="flat",
padx=10, cursor="hand2", font=MONO,
command=self._save_log,
).pack(side=tk.LEFT, padx=4)
self._send_btn = tk.Button(
bot, text="Send to Analyzer", bg=BG3, fg=FG_DIM, relief="flat",
padx=10, cursor="hand2", font=MONO,
command=self._send_to_analyzer, state="disabled",
)
self._send_btn.pack(side=tk.LEFT, padx=4)
# ── transport toggle ──────────────────────────────────────────────────
def _on_transport_change(self) -> None:
if self._transport_var.get() == "tcp":
self._serial_frame.grid_remove()
self._tcp_frame.grid(row=0, column=2, sticky="w")
else:
self._tcp_frame.grid_remove()
self._serial_frame.grid(row=0, column=2, sticky="w")
# ── console helpers ───────────────────────────────────────────────────
def _append(self, text: str, tag: str = "status") -> None:
"""Append coloured text (main thread only — called via _poll_q)."""
self._log_lines.append(text)
self._console.configure(state="normal")
self._console.insert(tk.END, text, tag)
line_count = int(self._console.index("end-1c").split(".")[0])
if line_count > self.MAX_LINES:
self._console.delete("1.0", f"{line_count - self.MAX_LINES}.0")
self._console.see(tk.END)
self._console.configure(state="disabled")
def _clear_console(self) -> None:
self._console.configure(state="normal")
self._console.delete("1.0", tk.END)
self._console.configure(state="disabled")
self._log_lines.clear()
def _save_log(self) -> None:
cap_dir = SCRIPT_DIR / "bridges" / "captures"
cap_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
path = cap_dir / f"console_{ts}.log"
try:
path.write_text("".join(self._log_lines), encoding="utf-8")
self._q.put(("status", f"Log saved → {path.name}"))
except Exception as exc:
messagebox.showerror("Save Error", str(exc))
def _send_to_analyzer(self) -> None:
if not self._last_raw_rx or not self._on_send_to_analyzer:
return
cap_dir = SCRIPT_DIR / "bridges" / "captures"
cap_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
raw_path = cap_dir / f"console_s3_{ts}.bin"
try:
raw_path.write_bytes(self._last_raw_rx)
self._on_send_to_analyzer(str(raw_path))
self._q.put(("status", f"Sent to Analyzer → {raw_path.name}"))
except Exception as exc:
messagebox.showerror("Error", str(exc))
# ── command dispatch ──────────────────────────────────────────────────
def _set_buttons_state(self, state: str) -> None:
for btn in self._cmd_btns:
btn.configure(state=state)
def _run_command(self, cmd: str) -> None:
if self._running:
return
# Snapshot config in main thread before handing off to worker
config = {
"transport": self._transport_var.get(),
"host": self._host_var.get().strip(),
"tcp_port": int(self._tcp_port_var.get().strip() or "9034"),
"port": self._port_var.get().strip(),
"baud": int(self._baud_var.get().strip() or "38400"),
"timeout": float(self._timeout_var.get().strip() or "30"),
"cmd": cmd,
}
self._running = True
self._set_buttons_state("disabled")
self._status_var.set("Running…")
threading.Thread(target=self._worker, args=(config,), daemon=True).start()
# ── worker thread ─────────────────────────────────────────────────────
def _worker(self, cfg: dict) -> None:
"""Background thread — open transport, run command, post results to queue."""
q = self._q
def post(kind: str, text: str) -> None:
q.put((kind, text))
try:
from minimateplus.transport import SerialTransport, TcpTransport
from minimateplus.protocol import (
MiniMateProtocol,
SUB_SERIAL_NUMBER,
SUB_FULL_CONFIG,
SUB_EVENT_INDEX,
)
except ImportError as exc:
post("error", f"Import error: {exc}\nIs minimateplus installed?\n")
q.put(("done", None))
return
timeout = cfg["timeout"]
cmd = cfg["cmd"]
# Build transport
if cfg["transport"] == "tcp":
host = cfg["host"]
tcp_port = cfg["tcp_port"]
post("status", f"Connecting {host}:{tcp_port}")
transport = TcpTransport(host, tcp_port, connect_timeout=timeout)
else:
port = cfg["port"]
baud = cfg["baud"]
post("status", f"Opening {port} @ {baud} baud…")
transport = SerialTransport(port, baud)
# Wrap transport to capture every TX/RX byte
raw_rx = bytearray()
orig_write = transport.write
orig_read = transport.read
def logged_write(data: bytes) -> None:
post("tx", f"TX [{len(data):3d}B]: {data.hex()}\n")
orig_write(data)
def logged_read(n: int) -> bytes:
result = orig_read(n)
if result:
raw_rx.extend(result)
post("rx_raw", f"RX [{len(result):3d}B]: {result.hex()}\n")
return result
transport.write = logged_write # type: ignore[method-assign]
transport.read = logged_read # type: ignore[method-assign]
try:
with transport:
post("status", "Connected.")
proto = MiniMateProtocol(transport, recv_timeout=timeout)
if cmd == "poll":
post("head", "\n── POLL startup ─────────────────────────────\n")
frame = proto.startup()
post("parsed", f" payload ({len(frame.data)} B): {frame.data.hex()}\n")
try:
text = frame.data.decode("ascii", errors="replace")
post("parsed", f" text: {text!r}\n")
except Exception:
pass
elif cmd == "serial_number":
post("head", "\n── POLL startup ─────────────────────────────\n")
proto.startup()
post("head", "\n── Serial Number (SUB 0x15) ─────────────────\n")
data = proto.read(SUB_SERIAL_NUMBER)
post("parsed", f" raw ({len(data)} B): {data.hex()}\n")
sn = data.rstrip(b"\x00").decode("ascii", errors="replace").strip()
post("parsed", f" serial: {sn!r}\n")
elif cmd == "full_config":
post("head", "\n── POLL startup ─────────────────────────────\n")
proto.startup()
post("head", "\n── Full Config (SUB 0x01) ───────────────────\n")
data = proto.read(SUB_FULL_CONFIG)
post("parsed", f" raw ({len(data)} B):\n")
for i in range(0, len(data), 16):
chunk = data[i:i + 16]
hex_part = " ".join(f"{b:02X}" for b in chunk)
asc_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
post("parsed", f" {i:04X}: {hex_part:<48} {asc_part}\n")
elif cmd == "event_index":
post("head", "\n── POLL startup ─────────────────────────────\n")
proto.startup()
post("head", "\n── Event Index (SUB 0x08) ───────────────────\n")
data = proto.read(SUB_EVENT_INDEX)
post("parsed", f" raw ({len(data)} B):\n")
for i in range(0, len(data), 16):
chunk = data[i:i + 16]
hex_part = " ".join(f"{b:02X}" for b in chunk)
asc_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
post("parsed", f" {i:04X}: {hex_part:<48} {asc_part}\n")
post("status", "Done.")
q.put(("save_raw", bytes(raw_rx)))
except Exception as exc:
post("error", f"\nError: {exc}\n")
finally:
q.put(("done", None))
# ── queue poll ────────────────────────────────────────────────────────
def _poll_q(self) -> None:
try:
while True:
kind, payload = self._q.get_nowait()
if kind == "tx":
self._append(payload, self.TAG_TX)
elif kind == "rx_raw":
self._append(payload, self.TAG_RX_RAW)
elif kind == "parsed":
self._append(payload, self.TAG_PARSED)
elif kind == "error":
self._append(payload, self.TAG_ERROR)
elif kind == "head":
self._append(payload, self.TAG_HEAD)
elif kind == "status":
self._status_var.set(str(payload))
self._append(f" [{payload}]\n", self.TAG_STATUS)
elif kind == "save_raw":
self._last_raw_rx = payload
if payload:
self._send_btn.configure(state="normal", fg=FG)
elif kind == "done":
self._running = False
self._set_buttons_state("normal")
self._status_var.set("Ready")
except queue.Empty:
pass
finally:
self.after(100, self._poll_q)
# ─────────────────────────────────────────────────────────────────────────────
# Main application window
# ─────────────────────────────────────────────────────────────────────────────
@@ -1101,6 +1498,12 @@ class SeismoLab(tk.Tk):
self._analyzer_panel = AnalyzerPanel(nb, db=self._db)
nb.add(self._analyzer_panel, text=" Analyzer ")
self._console_panel = ConsolePanel(
nb,
on_send_to_analyzer=self._on_console_send_to_analyzer,
)
nb.add(self._console_panel, text=" Console ")
self._nb = nb
self.protocol("WM_DELETE_WINDOW", self._on_close)
@@ -1114,6 +1517,11 @@ class SeismoLab(tk.Tk):
def _on_bridge_stopped(self) -> None:
self._analyzer_panel.stop_live()
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)
self._nb.select(1)
def _on_close(self) -> None:
self._bridge_panel.stop_bridge()
self.destroy()