From 9bbecea70fc4ae07b8b668920ad2ec05d735fdf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 20:23:18 +0000 Subject: [PATCH 1/7] =?UTF-8?q?fix(parser):=20correct=20S3=20frame=20termi?= =?UTF-8?q?nator=20=E2=80=94=20bare=20ETX,=20not=20DLE+ETX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_s3 had the S3 terminator logic inverted vs the real S3FrameParser in framing.py. It was terminating on DLE+ETX and treating bare ETX as payload, which caused every bare 0x03 to be swallowed — bundling multiple real S3 frames into one giant body until a DLE+ETX sequence happened to appear. Result: 583-byte POLL_RESPONSE 'frames' containing many real frames concatenated, all showing BAD CHK. Fix: mirror S3FrameParser exactly — - Bare ETX (0x03) = real frame terminator - DLE+ETX (0x10 0x03) = inner-frame literal data (A4/E5 sub-frames), appended to body and parsing continues https://claude.ai/code/session_014NczSHUz9uTzCAf4cVASTJ --- parsers/s3_parser.py | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/parsers/s3_parser.py b/parsers/s3_parser.py index 2f32933..4b8a2bc 100644 --- a/parsers/s3_parser.py +++ b/parsers/s3_parser.py @@ -33,7 +33,7 @@ STX = 0x02 ETX = 0x03 ACK = 0x41 -__version__ = "0.2.3" +__version__ = "0.2.4" @dataclass @@ -184,9 +184,9 @@ def validate_bw_body_auto(body: bytes) -> Optional[Tuple[bytes, bytes, str]]: def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]: frames: List[Frame] = [] - IDLE = 0 - IN_FRAME = 1 - AFTER_DLE = 2 + IDLE = 0 + IN_FRAME = 1 + IN_FRAME_DLE = 2 # saw DLE inside frame — waiting for next byte state = IDLE body = bytearray() @@ -206,22 +206,15 @@ def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]: state = IN_FRAME i += 2 continue + # ACK bytes, boot strings, garbage — silently ignored elif state == IN_FRAME: if b == DLE: - state = AFTER_DLE + state = IN_FRAME_DLE i += 1 continue - body.append(b) - - else: # AFTER_DLE - if b == DLE: - body.append(DLE) - state = IN_FRAME - i += 1 - continue - if b == ETX: + # Bare ETX = real S3 frame terminator (confirmed from S3FrameParser) end_offset = i + 1 trailer_start = i + 1 trailer_end = trailer_start + trailer_len @@ -259,13 +252,27 @@ def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]: state = IDLE i = trailer_end continue + body.append(b) + else: # IN_FRAME_DLE + if b == DLE: + # DLE DLE → literal 0x10 in payload + body.append(DLE) + state = IN_FRAME + i += 1 + continue + if b == ETX: + # DLE+ETX inside a frame = inner-frame terminator (A4/E5 sub-frames). + # Treat as literal data, NOT the outer frame end. + body.append(DLE) + body.append(ETX) + state = IN_FRAME + i += 1 + continue # Unexpected DLE + byte → treat as literal data body.append(DLE) body.append(b) state = IN_FRAME - i += 1 - continue i += 1 From e1150b30aad7142ff8b0afc49cf5742020ee63ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 20:40:45 +0000 Subject: [PATCH 2/7] fix(analyzer): name A5/5A frames; revert S3 checksum validation Add 0x5A (BULK_WAVEFORM_STREAM) and 0xA5 (BULK_WAVEFORM_RESPONSE) to SUB_TABLE so they display with real names instead of UNKNOWN_5A/A5. Revert S3 checksum validation to checksum_valid=None (the original intentional behavior). Large S3 frames (A5 bulk waveform, E5 compliance config) embed inner DLE+ETX sub-frame delimiters; the trailing 0x03 of the last inner delimiter can land where the parser expects the SUM8 checksum byte, causing false BAD CHK on every valid A5 frame. protocol.py _validate_frame documents and ignores exactly this issue. https://claude.ai/code/session_014NczSHUz9uTzCAf4cVASTJ --- parsers/s3_analyzer.py | 2 ++ parsers/s3_parser.py | 32 +++++++++++--------------------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/parsers/s3_analyzer.py b/parsers/s3_analyzer.py index c86477d..6feb32b 100644 --- a/parsers/s3_analyzer.py +++ b/parsers/s3_analyzer.py @@ -53,7 +53,9 @@ SUB_TABLE: dict[int, tuple[str, str, str]] = { 0x82: ("TRIGGER_CONFIG_WRITE", "BW→S3", "0x1C bytes; trigger config block; mirrors SUB 1C"), 0x83: ("TRIGGER_WRITE_CONFIRM", "BW→S3", "Short frame; commit step after 0x82"), # S3→BW responses + 0x5A: ("BULK_WAVEFORM_STREAM", "BW→S3", "Bulk waveform chunk request; response is A5 stream"), 0xA4: ("POLL_RESPONSE", "S3→BW", "Response to SUB 5B poll"), + 0xA5: ("BULK_WAVEFORM_RESPONSE", "S3→BW", "Response to SUB 5A; waveform chunks + metadata"), 0xFE: ("FULL_CONFIG_RESPONSE", "S3→BW", "Response to SUB 01"), 0xF9: ("CHANNEL_CONFIG_RESPONSE", "S3→BW", "Response to SUB 06"), 0xF7: ("EVENT_INDEX_RESPONSE", "S3→BW", "Response to SUB 08; contains backlight/power-save"), diff --git a/parsers/s3_parser.py b/parsers/s3_parser.py index 4b8a2bc..2bb3de1 100644 --- a/parsers/s3_parser.py +++ b/parsers/s3_parser.py @@ -33,7 +33,7 @@ STX = 0x02 ETX = 0x03 ACK = 0x41 -__version__ = "0.2.4" +__version__ = "0.2.5" @dataclass @@ -220,32 +220,22 @@ def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]: trailer_end = trailer_start + trailer_len trailer = blob[trailer_start:trailer_end] - chk_valid = None - chk_type = None - chk_hex = None - payload = bytes(body) - - if len(body) >= 1: - received_chk = body[-1] - computed_chk = checksum8_sum(bytes(body[:-1])) - if computed_chk == received_chk: - chk_valid = True - chk_type = "SUM8" - chk_hex = f"{received_chk:02x}" - payload = bytes(body[:-1]) - else: - chk_valid = False - + # S3 checksums are deliberately not validated here. + # Large S3 responses (A5 bulk waveform, E5 compliance) embed + # inner DLE+ETX sub-frame terminators whose trailing 0x03 byte + # lands where the parser would expect the SUM8 checksum, causing + # false failures. The live protocol (protocol.py _validate_frame) + # also skips S3 checksum enforcement for the same reason. frames.append(Frame( index=idx, start_offset=start_offset, end_offset=end_offset, payload_raw=bytes(body), - payload=payload, + payload=bytes(body), trailer=trailer, - checksum_valid=chk_valid, - checksum_type=chk_type, - checksum_hex=chk_hex + checksum_valid=None, + checksum_type=None, + checksum_hex=None )) idx += 1 From 897ac8a3f3968d112092a108927285b4d3761216 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 22:10:48 +0000 Subject: [PATCH 3/7] Add TCP MITM capture tab (TcpBridgePanel) New 'TCP Capture' tab in seismo_lab.py: listens on a configurable local port for an incoming Blastware connection, transparently forwards all traffic to the real seismograph device, and saves both directions to raw_bw_.bin / raw_s3_.bin in the same format the Analyzer already understands. Session start wires up Analyzer live mode automatically via the same on_bridge_started callback as the COM-port bridge. https://claude.ai/code/session_014NczSHUz9uTzCAf4cVASTJ --- seismo_lab.py | 287 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) diff --git a/seismo_lab.py b/seismo_lab.py index 2c85222..11e7f11 100644 --- a/seismo_lab.py +++ b/seismo_lab.py @@ -22,6 +22,7 @@ from __future__ import annotations import datetime import os import queue +import socket import subprocess import sys import threading @@ -309,6 +310,284 @@ class BridgePanel(tk.Frame): messagebox.showerror("Error", f"Failed to send mark:\n{e}") +# ───────────────────────────────────────────────────────────────────────────── +# TCP Bridge panel — MITM capture over IP +# ───────────────────────────────────────────────────────────────────────────── + +class TcpBridgePanel(tk.Frame): + """ + TCP man-in-the-middle capture panel. + + Listens on a local TCP port for an incoming Blastware connection, forwards + all traffic to the real device, and saves both directions to raw .bin files. + + Calls on_bridge_started(raw_bw_path, raw_s3_path, None) when a session + begins so the Analyzer can wire up live mode — same signature as BridgePanel. + """ + + def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, **kw): + super().__init__(parent, bg=BG2, **kw) + self._on_started = on_bridge_started + self._on_stopped = on_bridge_stopped + self._server: Optional[socket.socket] = None + self._stop_event = threading.Event() + self._log_q: queue.Queue[str] = queue.Queue() + self._build() + self._poll_log_q() + + # ── build ───────────────────────────────────────────────────────────── + + def _build(self) -> None: + pad = {"padx": 6, "pady": 4} + + cfg = tk.Frame(self, bg=BG2) + cfg.pack(side=tk.TOP, fill=tk.X, padx=4, pady=4) + + # Row 0: listen port + remote host + remote port + tk.Label(cfg, text="Listen port:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=0, sticky="e", **pad) + self.listen_port_var = tk.StringVar(value="9034") + tk.Entry(cfg, textvariable=self.listen_port_var, width=8, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=0, column=1, sticky="w", **pad) + + tk.Label(cfg, text="Device host:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=2, sticky="e", **pad) + self.remote_host_var = tk.StringVar(value="63.43.212.232") + tk.Entry(cfg, textvariable=self.remote_host_var, width=18, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=0, column=3, sticky="w", **pad) + + tk.Label(cfg, text="Port:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=4, sticky="e", **pad) + self.remote_port_var = tk.StringVar(value="9034") + tk.Entry(cfg, textvariable=self.remote_port_var, width=8, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=0, column=5, sticky="w", **pad) + + # Row 1: log dir + tk.Label(cfg, text="Log dir:", bg=BG2, fg=FG, font=MONO).grid(row=1, column=0, sticky="e", **pad) + self.logdir_var = tk.StringVar(value=str(SCRIPT_DIR / "bridges" / "captures" / "mitm")) + tk.Entry(cfg, textvariable=self.logdir_var, width=40, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=1, column=1, columnspan=4, sticky="we", **pad) + tk.Button(cfg, text="Browse", bg=BG3, fg=FG, relief="flat", cursor="hand2", + font=MONO, command=self._choose_dir).grid(row=1, column=5, **pad) + + # Row 2: capture checkboxes + self._raw_bw_on = tk.BooleanVar(value=True) + self._raw_s3_on = tk.BooleanVar(value=True) + tk.Checkbutton(cfg, text="Capture BW→device raw", variable=self._raw_bw_on, + bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, + font=MONO).grid(row=2, column=0, columnspan=2, sticky="w", **pad) + tk.Checkbutton(cfg, text="Capture device→BW raw", variable=self._raw_s3_on, + bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, + font=MONO).grid(row=2, column=2, columnspan=2, sticky="w", **pad) + + # Row 3: buttons + status + btn_row = tk.Frame(self, bg=BG2) + btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2) + + self.start_btn = tk.Button(btn_row, text="Start Listening", bg=GREEN, fg="#000000", + relief="flat", padx=12, cursor="hand2", font=MONO_B, + command=self.start_server) + self.start_btn.pack(side=tk.LEFT, padx=6) + + self.stop_btn = tk.Button(btn_row, text="Stop", bg=BG3, fg=FG, + relief="flat", padx=12, cursor="hand2", font=MONO, + command=self.stop_server, state="disabled") + self.stop_btn.pack(side=tk.LEFT, padx=4) + + self.status_var = tk.StringVar(value="Idle") + tk.Label(btn_row, textvariable=self.status_var, + bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=10) + + # Log output + self.log_view = scrolledtext.ScrolledText( + self, height=18, font=MONO_SM, + bg=BG, fg=FG, insertbackground=FG, + relief="flat", state="disabled", + ) + self.log_view.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) + + # ── helpers ─────────────────────────────────────────────────────────── + + def _choose_dir(self) -> None: + path = filedialog.askdirectory(initialdir=self.logdir_var.get()) + if path: + self.logdir_var.set(path) + + def _append_log(self, text: str) -> None: + self.log_view.configure(state="normal") + self.log_view.insert(tk.END, text) + self.log_view.see(tk.END) + self.log_view.configure(state="disabled") + + def _poll_log_q(self) -> None: + try: + while True: + msg = self._log_q.get_nowait() + if msg == "<>": + if self._server is not None: + self.status_var.set(f"Listening on :{self.listen_port_var.get()}") + self._on_stopped() + else: + self._append_log(msg) + except queue.Empty: + pass + finally: + self.after(100, self._poll_log_q) + + # ── server control ──────────────────────────────────────────────────── + + def start_server(self) -> None: + try: + listen_port = int(self.listen_port_var.get().strip()) + remote_host = self.remote_host_var.get().strip() + remote_port = int(self.remote_port_var.get().strip()) + except ValueError: + messagebox.showerror("Error", "Invalid port number.") + return + if not remote_host: + messagebox.showerror("Error", "Please enter the device host.") + return + + try: + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("0.0.0.0", listen_port)) + srv.listen(5) + srv.settimeout(1.0) + except OSError as e: + messagebox.showerror("Error", f"Cannot bind to port {listen_port}:\n{e}") + return + + self._server = srv + self._stop_event.clear() + self.start_btn.configure(state="disabled") + self.stop_btn.configure(state="normal", bg=RED) + self.status_var.set(f"Listening on :{listen_port}") + + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + self._append_log( + f"== TCP Bridge started [{ts}]\n" + f" Listening on 0.0.0.0:{listen_port}\n" + f" Forwarding to {remote_host}:{remote_port}\n" + f" Point Blastware call-home to this machine on port {listen_port}\n==\n" + ) + + logdir = self.logdir_var.get().strip() or "." + raw_bw_on = self._raw_bw_on.get() + raw_s3_on = self._raw_s3_on.get() + + threading.Thread( + target=self._accept_loop, + args=(srv, remote_host, remote_port, logdir, raw_bw_on, raw_s3_on), + daemon=True, + ).start() + + def stop_server(self) -> None: + self._stop_event.set() + if self._server: + try: + self._server.close() + except OSError: + pass + self._server = None + self.start_btn.configure(state="normal") + self.stop_btn.configure(state="disabled", bg=BG3) + self.status_var.set("Idle") + self._append_log("== TCP Bridge stopped ==\n") + self._on_stopped() + + # ── accept / session threads ────────────────────────────────────────── + + def _accept_loop(self, srv: socket.socket, remote_host: str, remote_port: int, + logdir: str, raw_bw_on: bool, raw_s3_on: bool) -> None: + while not self._stop_event.is_set(): + try: + client_sock, addr = srv.accept() + except socket.timeout: + continue + except OSError: + break + + peer = f"{addr[0]}:{addr[1]}" + self._log_q.put(f"[TCP] Blastware connected from {peer}\n") + + try: + dev_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + dev_sock.connect((remote_host, remote_port)) + except OSError as e: + self._log_q.put(f"[TCP] Cannot reach device {remote_host}:{remote_port}: {e}\n") + client_sock.close() + continue + + self._log_q.put(f"[TCP] Connected to device at {remote_host}:{remote_port}\n") + + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + os.makedirs(logdir, exist_ok=True) + raw_bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin") if raw_bw_on else None + raw_s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") if raw_s3_on else None + + self.after(0, self._notify_session_start, raw_bw_path, raw_s3_path, peer, remote_host, remote_port) + + self._run_session(client_sock, dev_sock, raw_bw_path, raw_s3_path, ts) + + self._log_q.put("<>") + + def _notify_session_start(self, raw_bw_path, raw_s3_path, peer, remote_host, remote_port) -> None: + self.status_var.set(f"Active: {peer} → {remote_host}:{remote_port}") + self._on_started(raw_bw_path, raw_s3_path, None) + + def _run_session(self, bw_sock: socket.socket, dev_sock: socket.socket, + raw_bw_path: Optional[str], raw_s3_path: Optional[str], + ts: str) -> None: + bw_fh = open(raw_bw_path, "wb") if raw_bw_path else None + s3_fh = open(raw_s3_path, "wb") if raw_s3_path else None + bw_bytes = [0] + s3_bytes = [0] + + def _pipe(src, dst, fh, counter): + try: + while True: + data = src.recv(4096) + if not data: + break + dst.sendall(data) + if fh: + fh.write(data) + fh.flush() + counter[0] += len(data) + except OSError: + pass + finally: + try: + dst.shutdown(socket.SHUT_WR) + except OSError: + pass + + t_bw = threading.Thread(target=_pipe, args=(bw_sock, dev_sock, bw_fh, bw_bytes), daemon=True) + t_s3 = threading.Thread(target=_pipe, args=(dev_sock, bw_sock, s3_fh, s3_bytes), daemon=True) + t_bw.start() + t_s3.start() + t_bw.join() + t_s3.join() + + bw_sock.close() + dev_sock.close() + if bw_fh: + bw_fh.close() + if s3_fh: + s3_fh.close() + + self._log_q.put( + f"[TCP] Session {ts} done " + f"BW→dev: {bw_bytes[0]} bytes dev→BW: {s3_bytes[0]} bytes\n" + ) + if raw_bw_path: + self._log_q.put(f"[TCP] BW capture: {raw_bw_path}\n") + if raw_s3_path: + self._log_q.put(f"[TCP] S3 capture: {raw_s3_path}\n") + + # ───────────────────────────────────────────────────────────────────────────── # Analyzer panel (tk.Frame — lives inside a notebook tab) # Extracted from gui_analyzer.py; accepts external path injection. @@ -1887,6 +2166,13 @@ class SeismoLab(tk.Tk): ) nb.add(self._bridge_panel, text=" Bridge ") + self._tcp_bridge_panel = TcpBridgePanel( + nb, + on_bridge_started=self._on_bridge_started, + on_bridge_stopped=self._on_bridge_stopped, + ) + nb.add(self._tcp_bridge_panel, text=" TCP Capture ") + self._analyzer_panel = AnalyzerPanel(nb, db=self._db) nb.add(self._analyzer_panel, text=" Analyzer ") @@ -1927,6 +2213,7 @@ class SeismoLab(tk.Tk): def _on_close(self) -> None: self._bridge_panel.stop_bridge() + self._tcp_bridge_panel.stop_server() self._serial_watch_panel._stop() self.destroy() From 6861d9ed970055d0d68d66f60bacc940d7f585e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 23:01:31 +0000 Subject: [PATCH 4/7] Merge TCP mode into Bridge tab (Serial/TCP radio toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the separate 'TCP Capture' tab and folds TCP MITM capture directly into the existing Bridge tab. A Serial/TCP radio selector at the top swaps the connection fields (COM ports vs. listen port + device host:port) while keeping the same Start Bridge / Stop Bridge / Add Mark buttons, capture checkboxes, log dir, and live log — identical UX for both modes. https://claude.ai/code/session_014NczSHUz9uTzCAf4cVASTJ --- seismo_lab.py | 381 +++++++++++++++++++++----------------------------- 1 file changed, 163 insertions(+), 218 deletions(-) diff --git a/seismo_lab.py b/seismo_lab.py index 11e7f11..0f757f2 100644 --- a/seismo_lab.py +++ b/seismo_lab.py @@ -97,19 +97,33 @@ class AnalyzerState: class BridgePanel(tk.Frame): """ - All bridge controls and live log output. - Calls on_bridge_started(raw_bw_path, raw_s3_path) when the bridge starts - so the parent can wire up the Analyzer. + Bridge controls and live log output. + + Two modes selectable at the top: + - Serial: wraps s3_bridge.py as a subprocess (two COM ports) + - TCP: MITM proxy — listens for an incoming Blastware connection, + forwards all bytes to the real device over IP, captures both + directions to raw .bin files + + Calls on_bridge_started(raw_bw_path, raw_s3_path, struct_bin_path) when + traffic begins so the parent can wire up the Analyzer in live mode. """ def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, **kw): super().__init__(parent, bg=BG2, **kw) - self._on_started = on_bridge_started # signature: (raw_bw, raw_s3, struct_bin) + self._on_started = on_bridge_started self._on_stopped = on_bridge_stopped + # serial state self.process: Optional[subprocess.Popen] = None - self._stdout_q: queue.Queue[str] = queue.Queue() + # tcp state + self._server: Optional[socket.socket] = None + self._tcp_stop_event = threading.Event() + # unified log queue (serial reader thread + TCP pipe threads both push here) + self._log_q: queue.Queue[str] = queue.Queue() + # mode + self._mode = tk.StringVar(value="serial") self._build() - self._poll_stdout() + self._poll_log_q() # ── build ───────────────────────────────────────────────────────────── @@ -119,45 +133,80 @@ class BridgePanel(tk.Frame): cfg = tk.Frame(self, bg=BG2) cfg.pack(side=tk.TOP, fill=tk.X, padx=4, pady=4) - # Row 0: ports - tk.Label(cfg, text="BW COM:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=0, sticky="e", **pad) + # Row 0: mode selector + mode_row = tk.Frame(cfg, bg=BG2) + mode_row.grid(row=0, column=0, columnspan=6, sticky="w", padx=6, pady=(4, 0)) + tk.Label(mode_row, text="Mode:", bg=BG2, fg=FG, font=MONO).pack(side=tk.LEFT, padx=(0, 8)) + tk.Radiobutton(mode_row, text="Serial", variable=self._mode, value="serial", + bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, + font=MONO, command=self._on_mode_change).pack(side=tk.LEFT, padx=4) + tk.Radiobutton(mode_row, text="TCP", variable=self._mode, value="tcp", + bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, + font=MONO, command=self._on_mode_change).pack(side=tk.LEFT, padx=4) + + # Row 1a: serial connection fields (shown by default) + self._serial_frame = tk.Frame(cfg, bg=BG2) + self._serial_frame.grid(row=1, column=0, columnspan=6, sticky="w") + + tk.Label(self._serial_frame, text="BW COM:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=0, sticky="e", **pad) self.bw_var = tk.StringVar(value="COM4") - tk.Entry(cfg, textvariable=self.bw_var, width=10, + tk.Entry(self._serial_frame, textvariable=self.bw_var, width=10, bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO).grid(row=0, column=1, sticky="w", **pad) - tk.Label(cfg, text="S3 COM:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=2, sticky="e", **pad) + tk.Label(self._serial_frame, text="S3 COM:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=2, sticky="e", **pad) self.s3_var = tk.StringVar(value="COM5") - tk.Entry(cfg, textvariable=self.s3_var, width=10, + tk.Entry(self._serial_frame, textvariable=self.s3_var, width=10, bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO).grid(row=0, column=3, sticky="w", **pad) - tk.Label(cfg, text="Baud:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=4, sticky="e", **pad) + tk.Label(self._serial_frame, text="Baud:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=4, sticky="e", **pad) self.baud_var = tk.StringVar(value="38400") - tk.Entry(cfg, textvariable=self.baud_var, width=8, + tk.Entry(self._serial_frame, textvariable=self.baud_var, width=8, bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO).grid(row=0, column=5, sticky="w", **pad) - # Row 1: log dir - tk.Label(cfg, text="Log dir:", bg=BG2, fg=FG, font=MONO).grid(row=1, column=0, sticky="e", **pad) + # Row 1b: TCP connection fields (hidden until TCP mode selected) + self._tcp_frame = tk.Frame(cfg, bg=BG2) + + tk.Label(self._tcp_frame, text="Listen port:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=0, sticky="e", **pad) + self.listen_port_var = tk.StringVar(value="9034") + tk.Entry(self._tcp_frame, textvariable=self.listen_port_var, width=8, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=0, column=1, sticky="w", **pad) + + tk.Label(self._tcp_frame, text="Device host:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=2, sticky="e", **pad) + self.remote_host_var = tk.StringVar(value="63.43.212.232") + tk.Entry(self._tcp_frame, textvariable=self.remote_host_var, width=18, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=0, column=3, sticky="w", **pad) + + tk.Label(self._tcp_frame, text="Port:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=4, sticky="e", **pad) + self.remote_port_var = tk.StringVar(value="9034") + tk.Entry(self._tcp_frame, textvariable=self.remote_port_var, width=8, + bg=BG3, fg=FG, insertbackground=FG, relief="flat", + font=MONO).grid(row=0, column=5, sticky="w", **pad) + + # Row 2: log dir + tk.Label(cfg, text="Log dir:", bg=BG2, fg=FG, font=MONO).grid(row=2, column=0, sticky="e", **pad) self.logdir_var = tk.StringVar(value=str(SCRIPT_DIR / "bridges" / "captures")) tk.Entry(cfg, textvariable=self.logdir_var, width=40, bg=BG3, fg=FG, insertbackground=FG, relief="flat", - font=MONO).grid(row=1, column=1, columnspan=4, sticky="we", **pad) + font=MONO).grid(row=2, column=1, columnspan=4, sticky="we", **pad) tk.Button(cfg, text="Browse", bg=BG3, fg=FG, relief="flat", cursor="hand2", - font=MONO, command=self._choose_dir).grid(row=1, column=5, **pad) + font=MONO, command=self._choose_dir).grid(row=2, column=5, **pad) - # Row 2: raw taps (always enabled — timestamped names generated at start) + # Row 3: raw capture checkboxes self._raw_bw_on = tk.BooleanVar(value=True) self._raw_s3_on = tk.BooleanVar(value=True) tk.Checkbutton(cfg, text="Capture BW->S3 raw", variable=self._raw_bw_on, bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, - font=MONO).grid(row=2, column=0, columnspan=2, sticky="w", **pad) + font=MONO).grid(row=3, column=0, columnspan=2, sticky="w", **pad) tk.Checkbutton(cfg, text="Capture S3->BW raw", variable=self._raw_s3_on, bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, - font=MONO).grid(row=2, column=2, columnspan=2, sticky="w", **pad) + font=MONO).grid(row=3, column=2, columnspan=2, sticky="w", **pad) - # Row 3: buttons + status + # Buttons + status btn_row = tk.Frame(self, bg=BG2) btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2) @@ -190,6 +239,14 @@ class BridgePanel(tk.Frame): # ── helpers ─────────────────────────────────────────────────────────── + def _on_mode_change(self) -> None: + if self._mode.get() == "serial": + self._tcp_frame.grid_remove() + self._serial_frame.grid(row=1, column=0, columnspan=6, sticky="w") + else: + self._serial_frame.grid_remove() + self._tcp_frame.grid(row=1, column=0, columnspan=6, sticky="w") + def _choose_dir(self) -> None: path = filedialog.askdirectory(initialdir=self.logdir_var.get()) if path: @@ -201,9 +258,50 @@ class BridgePanel(tk.Frame): self.log_view.see(tk.END) self.log_view.configure(state="disabled") - # ── bridge control ──────────────────────────────────────────────────── + # ── unified log-queue polling (serial subprocess + TCP threads both push here) + + def _poll_log_q(self) -> None: + try: + while True: + msg = self._log_q.get_nowait() + if msg == "<>": + self._bridge_ended() + self._on_stopped() + elif msg == "<>": + if self._server is not None: + self.status_var.set(f"Listening on :{self.listen_port_var.get()}") + self._on_stopped() + else: + self._append_log(msg) + except queue.Empty: + pass + finally: + self.after(100, self._poll_log_q) + + # ── bridge control (delegates to serial or TCP) ─────────────────────── def start_bridge(self) -> None: + if self._mode.get() == "tcp": + self._start_tcp() + else: + self._start_serial() + + def stop_bridge(self) -> None: + if self._mode.get() == "tcp": + self._stop_tcp() + else: + self._stop_serial() + + def _bridge_ended(self) -> None: + self.status_var.set("Stopped") + self.start_btn.configure(state="normal") + self.stop_btn.configure(state="disabled", bg=BG3) + self.mark_btn.configure(state="disabled") + self._append_log("== Bridge stopped ==\n") + + # ── serial mode ─────────────────────────────────────────────────────── + + def _start_serial(self) -> None: if self.process and self.process.poll() is None: messagebox.showinfo("Bridge", "Bridge is already running.") return @@ -231,7 +329,6 @@ class BridgePanel(tk.Frame): raw_s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") args += ["--raw-s3", raw_s3_path] - # Structured bin path — written by bridge automatically, named by ts struct_bin_path = os.path.join(logdir, f"s3_session_{ts}.bin") try: @@ -253,11 +350,9 @@ class BridgePanel(tk.Frame): self.stop_btn.configure(state="normal", bg=RED) self.mark_btn.configure(state="normal") self._append_log(f"== Bridge started [{ts}] ==\n") - - # Notify parent so Analyzer can wire up live mode self._on_started(raw_bw_path, raw_s3_path, struct_bin_path) - def stop_bridge(self) -> None: + def _stop_serial(self) -> None: if self.process and self.process.poll() is None: self.process.terminate() try: @@ -267,177 +362,20 @@ class BridgePanel(tk.Frame): self._bridge_ended() self._on_stopped() - def _bridge_ended(self) -> None: - self.status_var.set("Stopped") - self.start_btn.configure(state="normal") - self.stop_btn.configure(state="disabled", bg=BG3) - self.mark_btn.configure(state="disabled") - self._append_log("== Bridge stopped ==\n") - def _reader_thread(self) -> None: if not self.process or not self.process.stdout: return for line in self.process.stdout: - self._stdout_q.put(line) - self._stdout_q.put("<>") + self._log_q.put(line) + self._log_q.put("<>") - def _poll_stdout(self) -> None: - try: - while True: - line = self._stdout_q.get_nowait() - if line == "<>": - self._bridge_ended() - self._on_stopped() - break - self._append_log(line) - except queue.Empty: - pass - finally: - self.after(100, self._poll_stdout) + # ── TCP mode ────────────────────────────────────────────────────────── - def add_mark(self) -> None: - if not self.process or not self.process.stdin or self.process.poll() is not None: + def _start_tcp(self) -> None: + if self._server is not None: + messagebox.showinfo("Bridge", "TCP bridge is already listening.") return - label = simpledialog.askstring("Mark", "Enter label for this mark:", parent=self) - if not label or not label.strip(): - return - try: - self.process.stdin.write("m\n") - self.process.stdin.write(label.strip() + "\n") - self.process.stdin.flush() - self._append_log(f"[MARK] {label.strip()}\n") - except Exception as e: - messagebox.showerror("Error", f"Failed to send mark:\n{e}") - -# ───────────────────────────────────────────────────────────────────────────── -# TCP Bridge panel — MITM capture over IP -# ───────────────────────────────────────────────────────────────────────────── - -class TcpBridgePanel(tk.Frame): - """ - TCP man-in-the-middle capture panel. - - Listens on a local TCP port for an incoming Blastware connection, forwards - all traffic to the real device, and saves both directions to raw .bin files. - - Calls on_bridge_started(raw_bw_path, raw_s3_path, None) when a session - begins so the Analyzer can wire up live mode — same signature as BridgePanel. - """ - - def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, **kw): - super().__init__(parent, bg=BG2, **kw) - self._on_started = on_bridge_started - self._on_stopped = on_bridge_stopped - self._server: Optional[socket.socket] = None - self._stop_event = threading.Event() - self._log_q: queue.Queue[str] = queue.Queue() - self._build() - self._poll_log_q() - - # ── build ───────────────────────────────────────────────────────────── - - def _build(self) -> None: - pad = {"padx": 6, "pady": 4} - - cfg = tk.Frame(self, bg=BG2) - cfg.pack(side=tk.TOP, fill=tk.X, padx=4, pady=4) - - # Row 0: listen port + remote host + remote port - tk.Label(cfg, text="Listen port:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=0, sticky="e", **pad) - self.listen_port_var = tk.StringVar(value="9034") - tk.Entry(cfg, textvariable=self.listen_port_var, width=8, - bg=BG3, fg=FG, insertbackground=FG, relief="flat", - font=MONO).grid(row=0, column=1, sticky="w", **pad) - - tk.Label(cfg, text="Device host:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=2, sticky="e", **pad) - self.remote_host_var = tk.StringVar(value="63.43.212.232") - tk.Entry(cfg, textvariable=self.remote_host_var, width=18, - bg=BG3, fg=FG, insertbackground=FG, relief="flat", - font=MONO).grid(row=0, column=3, sticky="w", **pad) - - tk.Label(cfg, text="Port:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=4, sticky="e", **pad) - self.remote_port_var = tk.StringVar(value="9034") - tk.Entry(cfg, textvariable=self.remote_port_var, width=8, - bg=BG3, fg=FG, insertbackground=FG, relief="flat", - font=MONO).grid(row=0, column=5, sticky="w", **pad) - - # Row 1: log dir - tk.Label(cfg, text="Log dir:", bg=BG2, fg=FG, font=MONO).grid(row=1, column=0, sticky="e", **pad) - self.logdir_var = tk.StringVar(value=str(SCRIPT_DIR / "bridges" / "captures" / "mitm")) - tk.Entry(cfg, textvariable=self.logdir_var, width=40, - bg=BG3, fg=FG, insertbackground=FG, relief="flat", - font=MONO).grid(row=1, column=1, columnspan=4, sticky="we", **pad) - tk.Button(cfg, text="Browse", bg=BG3, fg=FG, relief="flat", cursor="hand2", - font=MONO, command=self._choose_dir).grid(row=1, column=5, **pad) - - # Row 2: capture checkboxes - self._raw_bw_on = tk.BooleanVar(value=True) - self._raw_s3_on = tk.BooleanVar(value=True) - tk.Checkbutton(cfg, text="Capture BW→device raw", variable=self._raw_bw_on, - bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, - font=MONO).grid(row=2, column=0, columnspan=2, sticky="w", **pad) - tk.Checkbutton(cfg, text="Capture device→BW raw", variable=self._raw_s3_on, - bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, - font=MONO).grid(row=2, column=2, columnspan=2, sticky="w", **pad) - - # Row 3: buttons + status - btn_row = tk.Frame(self, bg=BG2) - btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2) - - self.start_btn = tk.Button(btn_row, text="Start Listening", bg=GREEN, fg="#000000", - relief="flat", padx=12, cursor="hand2", font=MONO_B, - command=self.start_server) - self.start_btn.pack(side=tk.LEFT, padx=6) - - self.stop_btn = tk.Button(btn_row, text="Stop", bg=BG3, fg=FG, - relief="flat", padx=12, cursor="hand2", font=MONO, - command=self.stop_server, state="disabled") - self.stop_btn.pack(side=tk.LEFT, padx=4) - - self.status_var = tk.StringVar(value="Idle") - tk.Label(btn_row, textvariable=self.status_var, - bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=10) - - # Log output - self.log_view = scrolledtext.ScrolledText( - self, height=18, font=MONO_SM, - bg=BG, fg=FG, insertbackground=FG, - relief="flat", state="disabled", - ) - self.log_view.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) - - # ── helpers ─────────────────────────────────────────────────────────── - - def _choose_dir(self) -> None: - path = filedialog.askdirectory(initialdir=self.logdir_var.get()) - if path: - self.logdir_var.set(path) - - def _append_log(self, text: str) -> None: - self.log_view.configure(state="normal") - self.log_view.insert(tk.END, text) - self.log_view.see(tk.END) - self.log_view.configure(state="disabled") - - def _poll_log_q(self) -> None: - try: - while True: - msg = self._log_q.get_nowait() - if msg == "<>": - if self._server is not None: - self.status_var.set(f"Listening on :{self.listen_port_var.get()}") - self._on_stopped() - else: - self._append_log(msg) - except queue.Empty: - pass - finally: - self.after(100, self._poll_log_q) - - # ── server control ──────────────────────────────────────────────────── - - def start_server(self) -> None: try: listen_port = int(self.listen_port_var.get().strip()) remote_host = self.remote_host_var.get().strip() @@ -460,20 +398,20 @@ class TcpBridgePanel(tk.Frame): return self._server = srv - self._stop_event.clear() + self._tcp_stop_event.clear() self.start_btn.configure(state="disabled") self.stop_btn.configure(state="normal", bg=RED) + self.mark_btn.configure(state="normal") self.status_var.set(f"Listening on :{listen_port}") ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") self._append_log( f"== TCP Bridge started [{ts}]\n" f" Listening on 0.0.0.0:{listen_port}\n" - f" Forwarding to {remote_host}:{remote_port}\n" - f" Point Blastware call-home to this machine on port {listen_port}\n==\n" + f" Forwarding to {remote_host}:{remote_port}\n==\n" ) - logdir = self.logdir_var.get().strip() or "." + logdir = self.logdir_var.get().strip() or "." raw_bw_on = self._raw_bw_on.get() raw_s3_on = self._raw_s3_on.get() @@ -483,25 +421,20 @@ class TcpBridgePanel(tk.Frame): daemon=True, ).start() - def stop_server(self) -> None: - self._stop_event.set() + def _stop_tcp(self) -> None: + self._tcp_stop_event.set() if self._server: try: self._server.close() except OSError: pass self._server = None - self.start_btn.configure(state="normal") - self.stop_btn.configure(state="disabled", bg=BG3) - self.status_var.set("Idle") - self._append_log("== TCP Bridge stopped ==\n") + self._bridge_ended() self._on_stopped() - # ── accept / session threads ────────────────────────────────────────── - def _accept_loop(self, srv: socket.socket, remote_host: str, remote_port: int, logdir: str, raw_bw_on: bool, raw_s3_on: bool) -> None: - while not self._stop_event.is_set(): + while not self._tcp_stop_event.is_set(): try: client_sock, addr = srv.accept() except socket.timeout: @@ -527,19 +460,19 @@ class TcpBridgePanel(tk.Frame): raw_bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin") if raw_bw_on else None raw_s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") if raw_s3_on else None - self.after(0, self._notify_session_start, raw_bw_path, raw_s3_path, peer, remote_host, remote_port) - - self._run_session(client_sock, dev_sock, raw_bw_path, raw_s3_path, ts) - + self.after(0, self._notify_tcp_session_start, + raw_bw_path, raw_s3_path, peer, remote_host, remote_port) + self._run_tcp_session(client_sock, dev_sock, raw_bw_path, raw_s3_path, ts) self._log_q.put("<>") - def _notify_session_start(self, raw_bw_path, raw_s3_path, peer, remote_host, remote_port) -> None: + def _notify_tcp_session_start(self, raw_bw_path, raw_s3_path, + peer, remote_host, remote_port) -> None: self.status_var.set(f"Active: {peer} → {remote_host}:{remote_port}") self._on_started(raw_bw_path, raw_s3_path, None) - def _run_session(self, bw_sock: socket.socket, dev_sock: socket.socket, - raw_bw_path: Optional[str], raw_s3_path: Optional[str], - ts: str) -> None: + def _run_tcp_session(self, bw_sock: socket.socket, dev_sock: socket.socket, + raw_bw_path: Optional[str], raw_s3_path: Optional[str], + ts: str) -> None: bw_fh = open(raw_bw_path, "wb") if raw_bw_path else None s3_fh = open(raw_s3_path, "wb") if raw_s3_path else None bw_bytes = [0] @@ -587,6 +520,26 @@ class TcpBridgePanel(tk.Frame): if raw_s3_path: self._log_q.put(f"[TCP] S3 capture: {raw_s3_path}\n") + # ── marks ───────────────────────────────────────────────────────────── + + def add_mark(self) -> None: + label = simpledialog.askstring("Mark", "Enter label for this mark:", parent=self) + if not label or not label.strip(): + return + if self._mode.get() == "tcp": + ts = datetime.datetime.now().strftime("%H:%M:%S") + self._append_log(f"[MARK {ts}] {label.strip()}\n") + else: + if not self.process or not self.process.stdin or self.process.poll() is not None: + return + try: + self.process.stdin.write("m\n") + self.process.stdin.write(label.strip() + "\n") + self.process.stdin.flush() + self._append_log(f"[MARK] {label.strip()}\n") + except Exception as e: + messagebox.showerror("Error", f"Failed to send mark:\n{e}") + # ───────────────────────────────────────────────────────────────────────────── # Analyzer panel (tk.Frame — lives inside a notebook tab) @@ -2166,13 +2119,6 @@ class SeismoLab(tk.Tk): ) nb.add(self._bridge_panel, text=" Bridge ") - self._tcp_bridge_panel = TcpBridgePanel( - nb, - on_bridge_started=self._on_bridge_started, - on_bridge_stopped=self._on_bridge_stopped, - ) - nb.add(self._tcp_bridge_panel, text=" TCP Capture ") - self._analyzer_panel = AnalyzerPanel(nb, db=self._db) nb.add(self._analyzer_panel, text=" Analyzer ") @@ -2213,7 +2159,6 @@ class SeismoLab(tk.Tk): def _on_close(self) -> None: self._bridge_panel.stop_bridge() - self._tcp_bridge_panel.stop_server() self._serial_watch_panel._stop() self.destroy() From 9004241846aa357272b2f4d2dc5aef2382d7528f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 20:20:43 +0000 Subject: [PATCH 5/7] Restore multi-capture Bridge design + TCP mode Brings back the protocol-exp BridgePanel design: - Single bridge session stays up; New Capture / Stop Capture create labelled raw-file segments on demand (no files created at bridge start) - Capture history listbox shows all segments; double-click reloads in Analyzer - On capture complete: Analyzer auto-populates and runs analysis TCP mode integrated into same tab (Serial/TCP radio toggle): - Each incoming Blastware connection is automatically a capture segment - Session appears in history list; Analyzer wires up live on connect - Stop Capture disconnects current TCP session https://claude.ai/code/session_014NczSHUz9uTzCAf4cVASTJ --- seismo_lab.py | 388 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 274 insertions(+), 114 deletions(-) diff --git a/seismo_lab.py b/seismo_lab.py index 0f757f2..a8b6c35 100644 --- a/seismo_lab.py +++ b/seismo_lab.py @@ -100,30 +100,43 @@ class BridgePanel(tk.Frame): Bridge controls and live log output. Two modes selectable at the top: - - Serial: wraps s3_bridge.py as a subprocess (two COM ports) - - TCP: MITM proxy — listens for an incoming Blastware connection, - forwards all bytes to the real device over IP, captures both - directions to raw .bin files + - Serial: wraps s3_bridge.py as a subprocess (two COM ports). + Single bridge session; use New Capture / Stop Capture to create + labelled raw-file segments on demand. + - TCP: MITM proxy — listens for Blastware on a local port, forwards to + the real device. Each incoming connection is a capture; segments + appear in the history list automatically. - Calls on_bridge_started(raw_bw_path, raw_s3_path, struct_bin_path) when - traffic begins so the parent can wire up the Analyzer in live mode. + Callbacks (all optional except on_bridge_started / on_bridge_stopped): + on_bridge_started(struct_bin_path) — bridge is up + on_bridge_stopped() — bridge stopped + on_capture_started(bw_path, s3_path, label) — a capture segment began + on_capture_complete(bw_path, s3_path, label)— a capture segment finished """ - def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, **kw): + def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, + on_capture_started=None, on_capture_complete=None, **kw): super().__init__(parent, bg=BG2, **kw) - self._on_started = on_bridge_started - self._on_stopped = on_bridge_stopped + self._on_started = on_bridge_started + self._on_stopped = on_bridge_stopped + self._on_cap_started = on_capture_started + self._on_cap_complete = on_capture_complete # serial state self.process: Optional[subprocess.Popen] = None + self._stdout_q: queue.Queue[str] = queue.Queue() # tcp state self._server: Optional[socket.socket] = None self._tcp_stop_event = threading.Event() - # unified log queue (serial reader thread + TCP pipe threads both push here) - self._log_q: queue.Queue[str] = queue.Queue() + self._tcp_log_q: queue.Queue[str] = queue.Queue() + # shared capture state + self._capturing = False + self._cap_label: Optional[str] = None + self._cap_history: list[dict] = [] # {label, status, bw, s3} # mode self._mode = tk.StringVar(value="serial") self._build() - self._poll_log_q() + self._poll_stdout() + self._poll_tcp_log() # ── build ───────────────────────────────────────────────────────────── @@ -196,16 +209,6 @@ class BridgePanel(tk.Frame): tk.Button(cfg, text="Browse", bg=BG3, fg=FG, relief="flat", cursor="hand2", font=MONO, command=self._choose_dir).grid(row=2, column=5, **pad) - # Row 3: raw capture checkboxes - self._raw_bw_on = tk.BooleanVar(value=True) - self._raw_s3_on = tk.BooleanVar(value=True) - tk.Checkbutton(cfg, text="Capture BW->S3 raw", variable=self._raw_bw_on, - bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, - font=MONO).grid(row=3, column=0, columnspan=2, sticky="w", **pad) - tk.Checkbutton(cfg, text="Capture S3->BW raw", variable=self._raw_s3_on, - bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2, - font=MONO).grid(row=3, column=2, columnspan=2, sticky="w", **pad) - # Buttons + status btn_row = tk.Frame(self, bg=BG2) btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2) @@ -220,6 +223,18 @@ class BridgePanel(tk.Frame): command=self.stop_bridge, state="disabled") self.stop_btn.pack(side=tk.LEFT, padx=4) + tk.Frame(btn_row, bg=BG2, width=16).pack(side=tk.LEFT) # spacer + + self.cap_btn = tk.Button(btn_row, text="● New Capture", bg=ORANGE, fg="#000000", + relief="flat", padx=10, cursor="hand2", font=MONO_B, + command=self._start_capture, state="disabled") + self.cap_btn.pack(side=tk.LEFT, padx=4) + + self.stop_cap_btn = tk.Button(btn_row, text="■ Stop Capture", bg=BG3, fg=RED, + relief="flat", padx=10, cursor="hand2", font=MONO_B, + command=self._stop_capture, state="disabled") + self.stop_cap_btn.pack(side=tk.LEFT, padx=4) + self.mark_btn = tk.Button(btn_row, text="Add Mark", bg=BG3, fg=FG, relief="flat", padx=10, cursor="hand2", font=MONO, command=self.add_mark, state="disabled") @@ -229,9 +244,34 @@ class BridgePanel(tk.Frame): tk.Label(btn_row, textvariable=self.status_var, bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=10) + # Capture history list + hist_outer = tk.Frame(self, bg=BG2) + hist_outer.pack(side=tk.TOP, fill=tk.X, padx=4, pady=(2, 0)) + + tk.Label(hist_outer, text="Captures:", bg=BG2, fg=FG_DIM, + font=MONO_SM, anchor="w").pack(side=tk.LEFT, padx=(4, 6)) + + hist_inner = tk.Frame(hist_outer, bg=BG2) + hist_inner.pack(side=tk.LEFT, fill=tk.X, expand=True) + + self._hist_lb = tk.Listbox( + hist_inner, bg=BG3, fg=FG, font=MONO_SM, + height=3, relief="flat", selectbackground=BG, + selectforeground=ACCENT, activestyle="none", + highlightthickness=0, + ) + hist_vsb = ttk.Scrollbar(hist_inner, orient="vertical", command=self._hist_lb.yview) + self._hist_lb.configure(yscrollcommand=hist_vsb.set) + hist_vsb.pack(side=tk.RIGHT, fill=tk.Y) + self._hist_lb.pack(side=tk.LEFT, fill=tk.X, expand=True) + self._hist_lb.bind("", self._on_hist_dblclick) + + tk.Label(hist_outer, text="dbl-click to reload", bg=BG2, fg=FG_DIM, + font=MONO_SM, anchor="e").pack(side=tk.RIGHT, padx=6) + # Log output self.log_view = scrolledtext.ScrolledText( - self, height=18, font=MONO_SM, + self, height=14, font=MONO_SM, bg=BG, fg=FG, insertbackground=FG, relief="flat", state="disabled", ) @@ -258,25 +298,21 @@ class BridgePanel(tk.Frame): self.log_view.see(tk.END) self.log_view.configure(state="disabled") - # ── unified log-queue polling (serial subprocess + TCP threads both push here) + def _refresh_hist(self) -> None: + self._hist_lb.delete(0, tk.END) + for entry in self._cap_history: + icon = "\U0001f534" if entry["status"] == "recording" else "✅" + self._hist_lb.insert(tk.END, f" {icon} {entry['label'] or '(unlabeled)'}") + if self._cap_history: + self._hist_lb.see(tk.END) - def _poll_log_q(self) -> None: - try: - while True: - msg = self._log_q.get_nowait() - if msg == "<>": - self._bridge_ended() - self._on_stopped() - elif msg == "<>": - if self._server is not None: - self.status_var.set(f"Listening on :{self.listen_port_var.get()}") - self._on_stopped() - else: - self._append_log(msg) - except queue.Empty: - pass - finally: - self.after(100, self._poll_log_q) + def _on_hist_dblclick(self, _e=None) -> None: + sel = self._hist_lb.curselection() + if not sel: + return + entry = self._cap_history[sel[0]] + if entry["status"] == "done" and entry["bw"] and entry["s3"] and self._on_cap_complete: + self._on_cap_complete(entry["bw"], entry["s3"], entry["label"]) # ── bridge control (delegates to serial or TCP) ─────────────────────── @@ -296,9 +332,42 @@ class BridgePanel(tk.Frame): self.status_var.set("Stopped") self.start_btn.configure(state="normal") self.stop_btn.configure(state="disabled", bg=BG3) + self.cap_btn.configure(state="disabled") + self.stop_cap_btn.configure(state="disabled", bg=BG3) self.mark_btn.configure(state="disabled") + self._capturing = False + self._cap_label = None self._append_log("== Bridge stopped ==\n") + # ── capture lifecycle (shared by serial and TCP) ────────────────────── + + def _on_cap_started_msg(self, bw_path: str, s3_path: str) -> None: + for entry in reversed(self._cap_history): + if entry["status"] == "recording" and entry["bw"] is None: + entry["bw"] = bw_path + entry["s3"] = s3_path + break + self._refresh_hist() + if self._on_cap_started: + self._on_cap_started(bw_path, s3_path, self._cap_label or "") + + def _on_cap_stopped_msg(self, bw_path: str, s3_path: str) -> None: + label = self._cap_label or "capture" + for entry in reversed(self._cap_history): + if entry["status"] == "recording": + entry["status"] = "done" + entry["bw"] = bw_path + entry["s3"] = s3_path + break + self._refresh_hist() + self._capturing = False + self._cap_label = None + self.cap_btn.configure(state="normal") + self.stop_cap_btn.configure(state="disabled", bg=BG3) + self._append_log(f"[CAPTURE] Done: {label!r} — ready in Analyzer\n") + if self._on_cap_complete: + self._on_cap_complete(bw_path, s3_path, label) + # ── serial mode ─────────────────────────────────────────────────────── def _start_serial(self) -> None: @@ -321,14 +390,6 @@ class BridgePanel(tk.Frame): args = [sys.executable, str(BRIDGE_PATH), "--bw", bw, "--s3", s3, "--baud", baud, "--logdir", logdir] - raw_bw_path = raw_s3_path = None - if self._raw_bw_on.get(): - raw_bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin") - args += ["--raw-bw", raw_bw_path] - if self._raw_s3_on.get(): - raw_s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") - args += ["--raw-s3", raw_s3_path] - struct_bin_path = os.path.join(logdir, f"s3_session_{ts}.bin") try: @@ -348,9 +409,10 @@ class BridgePanel(tk.Frame): self.status_var.set(f"Running — {bw} <-> {s3}") self.start_btn.configure(state="disabled") self.stop_btn.configure(state="normal", bg=RED) - self.mark_btn.configure(state="normal") + self.cap_btn.configure(state="normal") self._append_log(f"== Bridge started [{ts}] ==\n") - self._on_started(raw_bw_path, raw_s3_path, struct_bin_path) + self._append_log(" Click 'New Capture' when ready to record.\n") + self._on_started(struct_bin_path) def _stop_serial(self) -> None: if self.process and self.process.poll() is None: @@ -366,8 +428,79 @@ class BridgePanel(tk.Frame): if not self.process or not self.process.stdout: return for line in self.process.stdout: - self._log_q.put(line) - self._log_q.put("<>") + self._stdout_q.put(line) + self._stdout_q.put("<>") + + def _poll_stdout(self) -> None: + try: + while True: + line = self._stdout_q.get_nowait() + if line == "<>": + self._bridge_ended() + self._on_stopped() + break + + stripped = line.strip() + if stripped.startswith("[CAP_START] ") and "\t" in stripped: + parts = stripped[12:].split("\t", 1) + if len(parts) == 2: + self._on_cap_started_msg(parts[0].strip(), parts[1].strip()) + elif stripped.startswith("[CAP_STOP] ") and "\t" in stripped: + parts = stripped[11:].split("\t", 1) + if len(parts) == 2: + self._on_cap_stopped_msg(parts[0].strip(), parts[1].strip()) + + self._append_log(line) + except queue.Empty: + pass + finally: + self.after(100, self._poll_stdout) + + def _start_capture(self) -> None: + if not self.process or self.process.poll() is not None: + return + label = simpledialog.askstring( + "New Capture", + "Label for this capture\n(e.g. 'copy_event_download').\nLeave blank for timestamp only:", + parent=self, + ) + if label is None: + return + label = label.strip() + try: + self.process.stdin.write(f"CAP_START:{label}\n") + self.process.stdin.flush() + except Exception as e: + messagebox.showerror("Error", f"Failed to start capture:\n{e}") + return + self._capturing = True + self._cap_label = label or datetime.datetime.now().strftime("%H%M%S") + self.cap_btn.configure(state="disabled") + self.stop_cap_btn.configure(state="normal", bg=RED) + self.mark_btn.configure(state="normal") + self._append_log(f"[CAPTURE] Starting: {self._cap_label!r}...\n") + self._cap_history.append({"label": self._cap_label, "status": "recording", + "bw": None, "s3": None}) + self._refresh_hist() + + def _stop_capture(self) -> None: + if self._mode.get() == "tcp": + # TCP: close the server so the current session ends naturally + self._tcp_stop_event.set() + if self._server: + try: + self._server.close() + except OSError: + pass + self._server = None + return + if not self.process or self.process.poll() is not None: + return + try: + self.process.stdin.write("CAP_STOP\n") + self.process.stdin.flush() + except Exception as e: + messagebox.showerror("Error", f"Failed to stop capture:\n{e}") # ── TCP mode ────────────────────────────────────────────────────────── @@ -401,23 +534,21 @@ class BridgePanel(tk.Frame): self._tcp_stop_event.clear() self.start_btn.configure(state="disabled") self.stop_btn.configure(state="normal", bg=RED) - self.mark_btn.configure(state="normal") self.status_var.set(f"Listening on :{listen_port}") ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") self._append_log( f"== TCP Bridge started [{ts}]\n" f" Listening on 0.0.0.0:{listen_port}\n" - f" Forwarding to {remote_host}:{remote_port}\n==\n" + f" Forwarding to {remote_host}:{remote_port}\n" + f" Each Blastware connection is automatically captured.\n==\n" ) + self._on_started(None) - logdir = self.logdir_var.get().strip() or "." - raw_bw_on = self._raw_bw_on.get() - raw_s3_on = self._raw_s3_on.get() - + logdir = self.logdir_var.get().strip() or "." threading.Thread( target=self._accept_loop, - args=(srv, remote_host, remote_port, logdir, raw_bw_on, raw_s3_on), + args=(srv, remote_host, remote_port, logdir), daemon=True, ).start() @@ -432,8 +563,8 @@ class BridgePanel(tk.Frame): self._bridge_ended() self._on_stopped() - def _accept_loop(self, srv: socket.socket, remote_host: str, remote_port: int, - logdir: str, raw_bw_on: bool, raw_s3_on: bool) -> None: + def _accept_loop(self, srv: socket.socket, remote_host: str, + remote_port: int, logdir: str) -> None: while not self._tcp_stop_event.is_set(): try: client_sock, addr = srv.accept() @@ -443,82 +574,98 @@ class BridgePanel(tk.Frame): break peer = f"{addr[0]}:{addr[1]}" - self._log_q.put(f"[TCP] Blastware connected from {peer}\n") + self._tcp_log_q.put(f"[TCP] Blastware connected from {peer}\n") try: dev_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) dev_sock.connect((remote_host, remote_port)) except OSError as e: - self._log_q.put(f"[TCP] Cannot reach device {remote_host}:{remote_port}: {e}\n") + self._tcp_log_q.put(f"[TCP] Cannot reach device {remote_host}:{remote_port}: {e}\n") client_sock.close() continue - self._log_q.put(f"[TCP] Connected to device at {remote_host}:{remote_port}\n") + self._tcp_log_q.put(f"[TCP] Connected to device at {remote_host}:{remote_port}\n") ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") os.makedirs(logdir, exist_ok=True) - raw_bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin") if raw_bw_on else None - raw_s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") if raw_s3_on else None + bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin") + s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") - self.after(0, self._notify_tcp_session_start, - raw_bw_path, raw_s3_path, peer, remote_host, remote_port) - self._run_tcp_session(client_sock, dev_sock, raw_bw_path, raw_s3_path, ts) - self._log_q.put("<>") + # Auto-register in history as recording + label = f"tcp_{ts}" + self.after(0, self._tcp_session_started, bw_path, s3_path, label, peer, remote_host, remote_port) - def _notify_tcp_session_start(self, raw_bw_path, raw_s3_path, - peer, remote_host, remote_port) -> None: + self._run_tcp_session(client_sock, dev_sock, bw_path, s3_path, ts) + + self._tcp_log_q.put(f"<>\t{bw_path}\t{s3_path}\t{label}") + + def _tcp_session_started(self, bw_path, s3_path, label, peer, remote_host, remote_port) -> None: self.status_var.set(f"Active: {peer} → {remote_host}:{remote_port}") - self._on_started(raw_bw_path, raw_s3_path, None) + self._cap_label = label + self._cap_history.append({"label": label, "status": "recording", + "bw": bw_path, "s3": s3_path}) + self._refresh_hist() + self.mark_btn.configure(state="normal") + self.stop_cap_btn.configure(state="normal", bg=RED) + if self._on_cap_started: + self._on_cap_started(bw_path, s3_path, label) def _run_tcp_session(self, bw_sock: socket.socket, dev_sock: socket.socket, - raw_bw_path: Optional[str], raw_s3_path: Optional[str], - ts: str) -> None: - bw_fh = open(raw_bw_path, "wb") if raw_bw_path else None - s3_fh = open(raw_s3_path, "wb") if raw_s3_path else None + bw_path: str, s3_path: str, ts: str) -> None: bw_bytes = [0] s3_bytes = [0] - def _pipe(src, dst, fh, counter): - try: - while True: - data = src.recv(4096) - if not data: - break - dst.sendall(data) - if fh: + with open(bw_path, "wb") as bw_fh, open(s3_path, "wb") as s3_fh: + def _pipe(src, dst, fh, counter): + try: + while True: + data = src.recv(4096) + if not data: + break + dst.sendall(data) fh.write(data) fh.flush() - counter[0] += len(data) - except OSError: - pass - finally: - try: - dst.shutdown(socket.SHUT_WR) + counter[0] += len(data) except OSError: pass + finally: + try: + dst.shutdown(socket.SHUT_WR) + except OSError: + pass - t_bw = threading.Thread(target=_pipe, args=(bw_sock, dev_sock, bw_fh, bw_bytes), daemon=True) - t_s3 = threading.Thread(target=_pipe, args=(dev_sock, bw_sock, s3_fh, s3_bytes), daemon=True) - t_bw.start() - t_s3.start() - t_bw.join() - t_s3.join() + t_bw = threading.Thread(target=_pipe, args=(bw_sock, dev_sock, bw_fh, bw_bytes), daemon=True) + t_s3 = threading.Thread(target=_pipe, args=(dev_sock, bw_sock, s3_fh, s3_bytes), daemon=True) + t_bw.start() + t_s3.start() + t_bw.join() + t_s3.join() bw_sock.close() dev_sock.close() - if bw_fh: - bw_fh.close() - if s3_fh: - s3_fh.close() - - self._log_q.put( + self._tcp_log_q.put( f"[TCP] Session {ts} done " f"BW→dev: {bw_bytes[0]} bytes dev→BW: {s3_bytes[0]} bytes\n" ) - if raw_bw_path: - self._log_q.put(f"[TCP] BW capture: {raw_bw_path}\n") - if raw_s3_path: - self._log_q.put(f"[TCP] S3 capture: {raw_s3_path}\n") + + def _poll_tcp_log(self) -> None: + try: + while True: + msg = self._tcp_log_q.get_nowait() + if msg.startswith("<>"): + parts = msg.split("\t") + if len(parts) == 4: + _, bw_path, s3_path, label = parts + self._on_cap_stopped_msg(bw_path, s3_path) + if self._server is not None: + self.status_var.set(f"Listening on :{self.listen_port_var.get()}") + self.stop_cap_btn.configure(state="disabled", bg=BG3) + else: + self._append_log(msg) + except queue.Empty: + pass + finally: + self.after(100, self._poll_tcp_log) # ── marks ───────────────────────────────────────────────────────────── @@ -2116,6 +2263,8 @@ class SeismoLab(tk.Tk): nb, on_bridge_started=self._on_bridge_started, on_bridge_stopped=self._on_bridge_stopped, + on_capture_started=self._on_capture_started, + on_capture_complete=self._on_capture_complete, ) nb.add(self._bridge_panel, text=" Bridge ") @@ -2137,16 +2286,27 @@ class SeismoLab(tk.Tk): self._nb = nb self.protocol("WM_DELETE_WINDOW", self._on_close) - def _on_bridge_started(self, raw_bw: Optional[str], raw_s3: Optional[str], - struct_bin: Optional[str] = None) -> None: - """Bridge started — inject paths into analyzer and start live mode.""" - self._analyzer_panel.set_live_files(raw_bw, raw_s3, struct_bin) - # Switch to Analyzer tab so the user can watch it update - self._nb.select(1) + def _on_bridge_started(self, struct_bin: Optional[str] = None) -> None: + """Bridge is up — store struct bin path; stay on Bridge tab.""" + if struct_bin: + self._analyzer_panel.bin_var.set(struct_bin) def _on_bridge_stopped(self) -> None: self._analyzer_panel.stop_live() + def _on_capture_started(self, bw_path: str, s3_path: str, label: str) -> None: + """A capture segment began — wire live mode and switch to Analyzer.""" + self._analyzer_panel.set_live_files(bw_path, s3_path) + self._nb.select(1) + + def _on_capture_complete(self, bw_path: str, s3_path: str, label: str) -> None: + """A capture segment finished — run full analysis and switch to Analyzer.""" + self._analyzer_panel.stop_live() + self._analyzer_panel.s3_var.set(s3_path) + self._analyzer_panel.bw_var.set(bw_path) + self._analyzer_panel._run_analyze() + self._nb.select(1) + 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) From b9ab368934d321e525fbb0c21731d3b3b4ebdfa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 20:26:31 +0000 Subject: [PATCH 6/7] Fix TCP capture: write files only when capture is active Previously every Blastware connection auto-created files. Now TCP mode works the same as serial mode: - Start Bridge: proxy listens and forwards silently, no files written - New Capture: opens raw_bw/raw_s3 files; pipe threads write to them - Stop Capture: flushes and closes files, fires Analyzer callback - No connection = no file; multiple captures per bridge session work correctly https://claude.ai/code/session_014NczSHUz9uTzCAf4cVASTJ --- seismo_lab.py | 179 ++++++++++++++++++++++++++------------------------ 1 file changed, 93 insertions(+), 86 deletions(-) diff --git a/seismo_lab.py b/seismo_lab.py index a8b6c35..c1fbd3d 100644 --- a/seismo_lab.py +++ b/seismo_lab.py @@ -128,6 +128,12 @@ class BridgePanel(tk.Frame): self._server: Optional[socket.socket] = None self._tcp_stop_event = threading.Event() self._tcp_log_q: queue.Queue[str] = queue.Queue() + # tcp capture file handles — written only when capture is active + self._tcp_cap_lock = threading.Lock() + self._tcp_cap_bw_fh = None + self._tcp_cap_s3_fh = None + self._tcp_cap_bw_path: Optional[str] = None + self._tcp_cap_s3_path: Optional[str] = None # shared capture state self._capturing = False self._cap_label: Optional[str] = None @@ -457,8 +463,6 @@ class BridgePanel(tk.Frame): self.after(100, self._poll_stdout) def _start_capture(self) -> None: - if not self.process or self.process.poll() is not None: - return label = simpledialog.askstring( "New Capture", "Label for this capture\n(e.g. 'copy_event_download').\nLeave blank for timestamp only:", @@ -467,32 +471,58 @@ class BridgePanel(tk.Frame): if label is None: return label = label.strip() - try: - self.process.stdin.write(f"CAP_START:{label}\n") - self.process.stdin.flush() - except Exception as e: - messagebox.showerror("Error", f"Failed to start capture:\n{e}") - return self._capturing = True self._cap_label = label or datetime.datetime.now().strftime("%H%M%S") + + if self._mode.get() == "tcp": + # TCP: open the capture files now; pipe threads write here while active + logdir = self.logdir_var.get().strip() or "." + os.makedirs(logdir, exist_ok=True) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin") + s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") + with self._tcp_cap_lock: + self._tcp_cap_bw_fh = open(bw_path, "wb") + self._tcp_cap_s3_fh = open(s3_path, "wb") + self._tcp_cap_bw_path = bw_path + self._tcp_cap_s3_path = s3_path + self._cap_history.append({"label": self._cap_label, "status": "recording", + "bw": bw_path, "s3": s3_path}) + self._refresh_hist() + self._on_cap_started_msg(bw_path, s3_path) + else: + if not self.process or self.process.poll() is not None: + return + try: + self.process.stdin.write(f"CAP_START:{label}\n") + self.process.stdin.flush() + except Exception as e: + messagebox.showerror("Error", f"Failed to start capture:\n{e}") + return + self._cap_history.append({"label": self._cap_label, "status": "recording", + "bw": None, "s3": None}) + self._refresh_hist() + self.cap_btn.configure(state="disabled") self.stop_cap_btn.configure(state="normal", bg=RED) self.mark_btn.configure(state="normal") self._append_log(f"[CAPTURE] Starting: {self._cap_label!r}...\n") - self._cap_history.append({"label": self._cap_label, "status": "recording", - "bw": None, "s3": None}) - self._refresh_hist() def _stop_capture(self) -> None: if self._mode.get() == "tcp": - # TCP: close the server so the current session ends naturally - self._tcp_stop_event.set() - if self._server: - try: - self._server.close() - except OSError: - pass - self._server = None + with self._tcp_cap_lock: + bw_path = self._tcp_cap_bw_path + s3_path = self._tcp_cap_s3_path + if self._tcp_cap_bw_fh: + self._tcp_cap_bw_fh.close() + self._tcp_cap_bw_fh = None + if self._tcp_cap_s3_fh: + self._tcp_cap_s3_fh.close() + self._tcp_cap_s3_fh = None + self._tcp_cap_bw_path = None + self._tcp_cap_s3_path = None + if bw_path and s3_path: + self._on_cap_stopped_msg(bw_path, s3_path) return if not self.process or self.process.poll() is not None: return @@ -534,6 +564,7 @@ class BridgePanel(tk.Frame): self._tcp_stop_event.clear() self.start_btn.configure(state="disabled") self.stop_btn.configure(state="normal", bg=RED) + self.cap_btn.configure(state="normal") self.status_var.set(f"Listening on :{listen_port}") ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") @@ -541,18 +572,25 @@ class BridgePanel(tk.Frame): f"== TCP Bridge started [{ts}]\n" f" Listening on 0.0.0.0:{listen_port}\n" f" Forwarding to {remote_host}:{remote_port}\n" - f" Each Blastware connection is automatically captured.\n==\n" + f" Click 'New Capture' before the operation you want to record.\n==\n" ) self._on_started(None) - logdir = self.logdir_var.get().strip() or "." threading.Thread( target=self._accept_loop, - args=(srv, remote_host, remote_port, logdir), + args=(srv, remote_host, remote_port), daemon=True, ).start() def _stop_tcp(self) -> None: + # Close any open capture files first + with self._tcp_cap_lock: + if self._tcp_cap_bw_fh: + self._tcp_cap_bw_fh.close() + self._tcp_cap_bw_fh = None + if self._tcp_cap_s3_fh: + self._tcp_cap_s3_fh.close() + self._tcp_cap_s3_fh = None self._tcp_stop_event.set() if self._server: try: @@ -563,8 +601,7 @@ class BridgePanel(tk.Frame): self._bridge_ended() self._on_stopped() - def _accept_loop(self, srv: socket.socket, remote_host: str, - remote_port: int, logdir: str) -> None: + def _accept_loop(self, srv: socket.socket, remote_host: str, remote_port: int) -> None: while not self._tcp_stop_event.is_set(): try: client_sock, addr = srv.accept() @@ -585,83 +622,53 @@ class BridgePanel(tk.Frame): continue self._tcp_log_q.put(f"[TCP] Connected to device at {remote_host}:{remote_port}\n") + self._run_tcp_session(client_sock, dev_sock) + self._tcp_log_q.put(f"[TCP] Connection from {peer} closed\n") - ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - os.makedirs(logdir, exist_ok=True) - bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin") - s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") - - # Auto-register in history as recording - label = f"tcp_{ts}" - self.after(0, self._tcp_session_started, bw_path, s3_path, label, peer, remote_host, remote_port) - - self._run_tcp_session(client_sock, dev_sock, bw_path, s3_path, ts) - - self._tcp_log_q.put(f"<>\t{bw_path}\t{s3_path}\t{label}") - - def _tcp_session_started(self, bw_path, s3_path, label, peer, remote_host, remote_port) -> None: - self.status_var.set(f"Active: {peer} → {remote_host}:{remote_port}") - self._cap_label = label - self._cap_history.append({"label": label, "status": "recording", - "bw": bw_path, "s3": s3_path}) - self._refresh_hist() - self.mark_btn.configure(state="normal") - self.stop_cap_btn.configure(state="normal", bg=RED) - if self._on_cap_started: - self._on_cap_started(bw_path, s3_path, label) - - def _run_tcp_session(self, bw_sock: socket.socket, dev_sock: socket.socket, - bw_path: str, s3_path: str, ts: str) -> None: + def _run_tcp_session(self, bw_sock: socket.socket, dev_sock: socket.socket) -> None: + """Forward bytes in both directions; write to capture files only when active.""" bw_bytes = [0] s3_bytes = [0] - with open(bw_path, "wb") as bw_fh, open(s3_path, "wb") as s3_fh: - def _pipe(src, dst, fh, counter): + def _pipe(src, dst, get_fh, counter): + try: + while True: + data = src.recv(4096) + if not data: + break + dst.sendall(data) + with self._tcp_cap_lock: + fh = get_fh() + if fh: + fh.write(data) + fh.flush() + counter[0] += len(data) + except OSError: + pass + finally: try: - while True: - data = src.recv(4096) - if not data: - break - dst.sendall(data) - fh.write(data) - fh.flush() - counter[0] += len(data) + dst.shutdown(socket.SHUT_WR) except OSError: pass - finally: - try: - dst.shutdown(socket.SHUT_WR) - except OSError: - pass - - t_bw = threading.Thread(target=_pipe, args=(bw_sock, dev_sock, bw_fh, bw_bytes), daemon=True) - t_s3 = threading.Thread(target=_pipe, args=(dev_sock, bw_sock, s3_fh, s3_bytes), daemon=True) - t_bw.start() - t_s3.start() - t_bw.join() - t_s3.join() + t_bw = threading.Thread(target=_pipe, + args=(bw_sock, dev_sock, + lambda: self._tcp_cap_bw_fh, bw_bytes), daemon=True) + t_s3 = threading.Thread(target=_pipe, + args=(dev_sock, bw_sock, + lambda: self._tcp_cap_s3_fh, s3_bytes), daemon=True) + t_bw.start() + t_s3.start() + t_bw.join() + t_s3.join() bw_sock.close() dev_sock.close() - self._tcp_log_q.put( - f"[TCP] Session {ts} done " - f"BW→dev: {bw_bytes[0]} bytes dev→BW: {s3_bytes[0]} bytes\n" - ) def _poll_tcp_log(self) -> None: try: while True: msg = self._tcp_log_q.get_nowait() - if msg.startswith("<>"): - parts = msg.split("\t") - if len(parts) == 4: - _, bw_path, s3_path, label = parts - self._on_cap_stopped_msg(bw_path, s3_path) - if self._server is not None: - self.status_var.set(f"Listening on :{self.listen_port_var.get()}") - self.stop_cap_btn.configure(state="disabled", bg=BG3) - else: - self._append_log(msg) + self._append_log(msg) except queue.Empty: pass finally: From b14f31f3b03a7b80e15aaa7899d8371b30865b33 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 20:48:10 +0000 Subject: [PATCH 7/7] Include capture label in TCP raw filename Matches serial bridge naming: raw_bw_{ts}_{label}.bin / raw_s3_{ts}_{label}.bin https://claude.ai/code/session_014NczSHUz9uTzCAf4cVASTJ --- seismo_lab.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/seismo_lab.py b/seismo_lab.py index c1fbd3d..dc96d59 100644 --- a/seismo_lab.py +++ b/seismo_lab.py @@ -479,8 +479,10 @@ class BridgePanel(tk.Frame): logdir = self.logdir_var.get().strip() or "." os.makedirs(logdir, exist_ok=True) ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin") - s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin") + safe_label = self._cap_label.replace(" ", "_") if self._cap_label else "" + suffix = f"_{safe_label}" if safe_label else "" + bw_path = os.path.join(logdir, f"raw_bw_{ts}{suffix}.bin") + s3_path = os.path.join(logdir, f"raw_s3_{ts}{suffix}.bin") with self._tcp_cap_lock: self._tcp_cap_bw_fh = open(bw_path, "wb") self._tcp_cap_s3_fh = open(s3_path, "wb")