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:
@@ -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 |
|
||||
|
||||
412
seismo_lab.py
412
seismo_lab.py
@@ -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