Compare commits
2 Commits
8074bf0fee
...
6a0422a6fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a0422a6fc | |||
| 1078576023 |
@@ -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 0x56–0x57 (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`).
|
||||
@@ -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
|
||||
|
||||
@@ -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 0x53–0x57. Two-unit comparison: BE18189 (calibrated 2023) has `07 E7` at 0x56–0x57; BE11529 (calibrated 2025) has `07 E9`. Bytes 0x56–0x57 = uint16 BE calibration year ✅ CONFIRMED. Adjacent bytes at 0x53–0x55 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 |
|
||||
| 0x56–0x57 | `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 0x56–0x57. Confirmed via two-unit comparison: BE18189 (calibrated 2023) = `07 E7`; BE11529 (calibrated 2025) = `07 E9`. Adjacent bytes at 0x53–0x55 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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user