6 Commits

Author SHA1 Message Date
Claude b14f31f3b0 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
2026-04-27 20:48:10 +00:00
Claude b9ab368934 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
2026-04-27 20:26:31 +00:00
Claude 9004241846 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
2026-04-27 20:20:43 +00:00
Claude 6861d9ed97 Merge TCP mode into Bridge tab (Serial/TCP radio toggle)
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
2026-04-26 23:01:45 +00:00
claude 5cd5652560 Merge branch 'seismo-lab' of https://github.com/serversdwn/seismo-relay into seismo-lab 2026-04-26 18:16:52 -04:00
Claude 897ac8a3f3 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_<ts>.bin / raw_s3_<ts>.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
2026-04-26 22:10:48 +00:00
+467 -66
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import datetime
import os
import queue
import socket
import subprocess
import sys
import threading
@@ -96,19 +97,52 @@ 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).
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.
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 # signature: (raw_bw, raw_s3, struct_bin)
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()
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
self._cap_history: list[dict] = [] # {label, status, bw, s3}
# mode
self._mode = tk.StringVar(value="serial")
self._build()
self._poll_stdout()
self._poll_tcp_log()
# ── build ─────────────────────────────────────────────────────────────
@@ -118,45 +152,70 @@ 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)
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)
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)
# 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)
@@ -170,6 +229,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")
@@ -179,9 +250,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("<Double-Button-1>", 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",
)
@@ -189,6 +285,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:
@@ -200,9 +304,79 @@ class BridgePanel(tk.Frame):
self.log_view.see(tk.END)
self.log_view.configure(state="disabled")
# ── bridge control ────────────────────────────────────────────────────
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 _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) ───────────────────────
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.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:
if self.process and self.process.poll() is None:
messagebox.showinfo("Bridge", "Bridge is already running.")
return
@@ -222,15 +396,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]
# Structured bin path — written by bridge automatically, named by ts
struct_bin_path = os.path.join(logdir, f"s3_session_{ts}.bin")
try:
@@ -250,13 +415,12 @@ 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._append_log(" Click 'New Capture' when ready to record.\n")
self._on_started(struct_bin_path)
# 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:
@@ -266,13 +430,6 @@ 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
@@ -288,25 +445,256 @@ class BridgePanel(tk.Frame):
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 add_mark(self) -> None:
if not self.process or not self.process.stdin or self.process.poll() is not None:
def _start_capture(self) -> None:
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()
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")
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")
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")
def _stop_capture(self) -> None:
if self._mode.get() == "tcp":
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
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 ──────────────────────────────────────────────────────────
def _start_tcp(self) -> None:
if self._server is not None:
messagebox.showinfo("Bridge", "TCP bridge is already listening.")
return
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._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")
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" Click 'New Capture' before the operation you want to record.\n==\n"
)
self._on_started(None)
threading.Thread(
target=self._accept_loop,
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:
self._server.close()
except OSError:
pass
self._server = None
self._bridge_ended()
self._on_stopped()
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()
except socket.timeout:
continue
except OSError:
break
peer = f"{addr[0]}:{addr[1]}"
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._tcp_log_q.put(f"[TCP] Cannot reach device {remote_host}:{remote_port}: {e}\n")
client_sock.close()
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")
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]
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:
dst.shutdown(socket.SHUT_WR)
except OSError:
pass
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()
def _poll_tcp_log(self) -> None:
try:
while True:
msg = self._tcp_log_q.get_nowait()
self._append_log(msg)
except queue.Empty:
pass
finally:
self.after(100, self._poll_tcp_log)
# ── 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
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}")
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}")
# ─────────────────────────────────────────────────────────────────────────────
@@ -1884,6 +2272,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 ")
@@ -1905,16 +2295,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)