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>
This commit is contained in:
Brian Harrison
2026-03-31 17:19:31 -04:00
parent 8074bf0fee
commit 1078576023
2 changed files with 2757 additions and 2322 deletions

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 | §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-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 | §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 | | 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 | | 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 | | 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 | | 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 | | 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()`. 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 | | 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. 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 ### 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 | | | 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 | | | 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 | | | 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 | | **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 | | **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 | | **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 |

View File

@@ -1,10 +1,13 @@
#!/usr/bin/env python3 #!/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) Bridge — capture live serial traffic (wraps s3_bridge.py as subprocess)
Analyzer — parse, diff, and query captured frames 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: When the bridge starts:
- raw tap paths are auto-filled in the Analyzer tab - 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" BRIDGE_PATH = SCRIPT_DIR / "bridges" / "s3-bridge" / "s3_bridge.py"
PARSERS_DIR = SCRIPT_DIR / "parsers" PARSERS_DIR = SCRIPT_DIR / "parsers"
sys.path.insert(0, str(PARSERS_DIR)) sys.path.insert(0, str(PARSERS_DIR))
sys.path.insert(0, str(SCRIPT_DIR)) # for minimateplus package
from s3_analyzer import ( # noqa: E402 from s3_analyzer import ( # noqa: E402
AnnotatedFrame, AnnotatedFrame,
@@ -1066,6 +1070,399 @@ class AnalyzerPanel(tk.Frame):
w.configure(state="normal"); w.insert(tk.END, "\n"); w.configure(state="disabled") w.configure(state="normal"); w.insert(tk.END, "\n"); w.configure(state="disabled")
# ─────────────────────────────────────────────────────────────────────────────
# 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 # Main application window
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -1101,6 +1498,12 @@ class SeismoLab(tk.Tk):
self._analyzer_panel = AnalyzerPanel(nb, db=self._db) self._analyzer_panel = AnalyzerPanel(nb, db=self._db)
nb.add(self._analyzer_panel, text=" Analyzer ") 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._nb = nb
self.protocol("WM_DELETE_WINDOW", self._on_close) self.protocol("WM_DELETE_WINDOW", self._on_close)
@@ -1114,6 +1517,11 @@ class SeismoLab(tk.Tk):
def _on_bridge_stopped(self) -> None: def _on_bridge_stopped(self) -> None:
self._analyzer_panel.stop_live() 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: def _on_close(self) -> None:
self._bridge_panel.stop_bridge() self._bridge_panel.stop_bridge()
self.destroy() self.destroy()