Manually Merged seismo lab persistent connection branch into the new direct download branch, creating a new branch called seismo-lab-new

This commit is contained in:
2026-05-01 15:13:50 -04:00
3 changed files with 458 additions and 60 deletions
+2
View File
@@ -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"), 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"), 0x83: ("TRIGGER_WRITE_CONFIRM", "BW→S3", "Short frame; commit step after 0x82"),
# S3→BW responses # 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"), 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"), 0xFE: ("FULL_CONFIG_RESPONSE", "S3→BW", "Response to SUB 01"),
0xF9: ("CHANNEL_CONFIG_RESPONSE", "S3→BW", "Response to SUB 06"), 0xF9: ("CHANNEL_CONFIG_RESPONSE", "S3→BW", "Response to SUB 06"),
0xF7: ("EVENT_INDEX_RESPONSE", "S3→BW", "Response to SUB 08; contains backlight/power-save"), 0xF7: ("EVENT_INDEX_RESPONSE", "S3→BW", "Response to SUB 08; contains backlight/power-save"),
+33 -36
View File
@@ -33,7 +33,7 @@ STX = 0x02
ETX = 0x03 ETX = 0x03
ACK = 0x41 ACK = 0x41
__version__ = "0.2.3" __version__ = "0.2.5"
@dataclass @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]: def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]:
frames: List[Frame] = [] frames: List[Frame] = []
IDLE = 0 IDLE = 0
IN_FRAME = 1 IN_FRAME = 1
AFTER_DLE = 2 IN_FRAME_DLE = 2 # saw DLE inside frame — waiting for next byte
state = IDLE state = IDLE
body = bytearray() body = bytearray()
@@ -206,66 +206,63 @@ def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]:
state = IN_FRAME state = IN_FRAME
i += 2 i += 2
continue continue
# ACK bytes, boot strings, garbage — silently ignored
elif state == IN_FRAME: elif state == IN_FRAME:
if b == DLE: if b == DLE:
state = AFTER_DLE state = IN_FRAME_DLE
i += 1 i += 1
continue continue
body.append(b)
else: # AFTER_DLE
if b == DLE:
body.append(DLE)
state = IN_FRAME
i += 1
continue
if b == ETX: if b == ETX:
# Bare ETX = real S3 frame terminator (confirmed from S3FrameParser)
end_offset = i + 1 end_offset = i + 1
trailer_start = i + 1 trailer_start = i + 1
trailer_end = trailer_start + trailer_len trailer_end = trailer_start + trailer_len
trailer = blob[trailer_start:trailer_end] trailer = blob[trailer_start:trailer_end]
chk_valid = None # S3 checksums are deliberately not validated here.
chk_type = None # Large S3 responses (A5 bulk waveform, E5 compliance) embed
chk_hex = None # inner DLE+ETX sub-frame terminators whose trailing 0x03 byte
payload = bytes(body) # lands where the parser would expect the SUM8 checksum, causing
# false failures. The live protocol (protocol.py _validate_frame)
if len(body) >= 1: # also skips S3 checksum enforcement for the same reason.
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
frames.append(Frame( frames.append(Frame(
index=idx, index=idx,
start_offset=start_offset, start_offset=start_offset,
end_offset=end_offset, end_offset=end_offset,
payload_raw=bytes(body), payload_raw=bytes(body),
payload=payload, payload=bytes(body),
trailer=trailer, trailer=trailer,
checksum_valid=chk_valid, checksum_valid=None,
checksum_type=chk_type, checksum_type=None,
checksum_hex=chk_hex checksum_hex=None
)) ))
idx += 1 idx += 1
state = IDLE state = IDLE
i = trailer_end i = trailer_end
continue 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 # Unexpected DLE + byte → treat as literal data
body.append(DLE) body.append(DLE)
body.append(b) body.append(b)
state = IN_FRAME state = IN_FRAME
i += 1
continue
i += 1 i += 1
+423 -24
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import datetime import datetime
import os import os
import queue import queue
import socket
import subprocess import subprocess
import sys import sys
import threading import threading
@@ -96,12 +97,25 @@ class AnalyzerState:
class BridgePanel(tk.Frame): class BridgePanel(tk.Frame):
""" """
All bridge controls and live log output. Bridge controls and live log output.
Calls on_bridge_started(struct_bin_path) when the bridge starts.
Calls on_capture_started(bw_path, s3_path, label) when a capture begins. Two modes selectable at the top:
Calls on_capture_complete(bw_path, s3_path, label) when a capture ends. - 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,
on_capture_started=None, on_capture_complete=None, **kw):
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped,
on_capture_started=None, on_capture_complete=None, **kw): on_capture_started=None, on_capture_complete=None, **kw):
super().__init__(parent, bg=BG2, **kw) super().__init__(parent, bg=BG2, **kw)
@@ -111,12 +125,29 @@ class BridgePanel(tk.Frame):
self._on_cap_complete = on_capture_complete # (bw, s3, label) self._on_cap_complete = on_capture_complete # (bw, s3, label)
self.process: Optional[subprocess.Popen] = None self.process: Optional[subprocess.Popen] = None
self._stdout_q: queue.Queue[str] = queue.Queue() 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")
# Capture state # Capture state
self._capturing = False self._capturing = False
self._cap_label: Optional[str] = None self._cap_label: Optional[str] = None
self._cap_history: list[dict] = [] # {label, status, bw, s3} self._cap_history: list[dict] = [] # {label, status, bw, s3}
self._build() self._build()
self._poll_stdout() self._poll_stdout()
self._poll_tcp_log()
# ── build ───────────────────────────────────────────────────────────── # ── build ─────────────────────────────────────────────────────────────
@@ -126,33 +157,68 @@ class BridgePanel(tk.Frame):
cfg = tk.Frame(self, bg=BG2) cfg = tk.Frame(self, bg=BG2)
cfg.pack(side=tk.TOP, fill=tk.X, padx=4, pady=4) cfg.pack(side=tk.TOP, fill=tk.X, padx=4, pady=4)
# Row 0: ports # Row 0: mode selector
tk.Label(cfg, text="BW COM:", bg=BG2, fg=FG, font=MONO).grid(row=0, column=0, sticky="e", **pad) 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") 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", bg=BG3, fg=FG, insertbackground=FG, relief="flat",
font=MONO).grid(row=0, column=1, sticky="w", **pad) 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") 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", bg=BG3, fg=FG, insertbackground=FG, relief="flat",
font=MONO).grid(row=0, column=3, sticky="w", **pad) 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") 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", bg=BG3, fg=FG, insertbackground=FG, relief="flat",
font=MONO).grid(row=0, column=5, sticky="w", **pad) font=MONO).grid(row=0, column=5, sticky="w", **pad)
# Row 1: log dir # Row 1b: TCP connection fields (hidden until TCP mode selected)
tk.Label(cfg, text="Log dir:", bg=BG2, fg=FG, font=MONO).grid(row=1, column=0, sticky="e", **pad) 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")) self.logdir_var = tk.StringVar(value=str(SCRIPT_DIR / "bridges" / "captures"))
tk.Entry(cfg, textvariable=self.logdir_var, width=40, tk.Entry(cfg, textvariable=self.logdir_var, width=40,
bg=BG3, fg=FG, insertbackground=FG, relief="flat", 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", 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: buttons + status # Row 2: buttons + status
btn_row = tk.Frame(self, bg=BG2) btn_row = tk.Frame(self, bg=BG2)
@@ -170,6 +236,18 @@ class BridgePanel(tk.Frame):
tk.Frame(btn_row, bg=BG2, width=16).pack(side=tk.LEFT) # spacer 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)
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", self.cap_btn = tk.Button(btn_row, text="⬤ New Capture", bg=ORANGE, fg="#000000",
relief="flat", padx=10, cursor="hand2", font=MONO_B, relief="flat", padx=10, cursor="hand2", font=MONO_B,
command=self._start_capture, state="disabled") command=self._start_capture, state="disabled")
@@ -211,11 +289,37 @@ class BridgePanel(tk.Frame):
self._hist_lb.pack(side=tk.LEFT, fill=tk.X, expand=True) self._hist_lb.pack(side=tk.LEFT, fill=tk.X, expand=True)
self._hist_lb.bind("<Double-Button-1>", self._on_hist_dblclick) 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)
# 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, 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) font=MONO_SM, anchor="e").pack(side=tk.RIGHT, padx=6)
# Log output # Log output
self.log_view = scrolledtext.ScrolledText( self.log_view = scrolledtext.ScrolledText(
self, height=14, font=MONO_SM,
self, height=14, font=MONO_SM, self, height=14, font=MONO_SM,
bg=BG, fg=FG, insertbackground=FG, bg=BG, fg=FG, insertbackground=FG,
relief="flat", state="disabled", relief="flat", state="disabled",
@@ -224,6 +328,14 @@ class BridgePanel(tk.Frame):
# ── helpers ─────────────────────────────────────────────────────────── # ── 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: def _choose_dir(self) -> None:
path = filedialog.askdirectory(initialdir=self.logdir_var.get()) path = filedialog.askdirectory(initialdir=self.logdir_var.get())
if path: if path:
@@ -235,9 +347,79 @@ class BridgePanel(tk.Frame):
self.log_view.see(tk.END) self.log_view.see(tk.END)
self.log_view.configure(state="disabled") 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: 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: if self.process and self.process.poll() is None:
messagebox.showinfo("Bridge", "Bridge is already running.") messagebox.showinfo("Bridge", "Bridge is already running.")
return return
@@ -280,8 +462,10 @@ class BridgePanel(tk.Frame):
self.start_btn.configure(state="disabled") self.start_btn.configure(state="disabled")
self.stop_btn.configure(state="normal", bg=RED) self.stop_btn.configure(state="normal", bg=RED)
self.cap_btn.configure(state="normal") self.cap_btn.configure(state="normal")
self.cap_btn.configure(state="normal")
self._append_log(f"== Bridge started [{ts}] ==\n") self._append_log(f"== Bridge started [{ts}] ==\n")
self._append_log(" Click 'New Capture' when ready to record a setting change.\n") self._append_log(" Click 'New Capture' when ready to record.\n")
self._on_started(struct_bin_path)
# Notify parent — no raw files yet, just the structured bin path # Notify parent — no raw files yet, just the structured bin path
self._on_started(struct_bin_path) self._on_started(struct_bin_path)
@@ -439,16 +623,231 @@ class BridgePanel(tk.Frame):
def add_mark(self) -> None: def add_mark(self) -> None:
if not self.process or not self.process.stdin or self.process.poll() is not None: if not self.process or not self.process.stdin or self.process.poll() is not None:
return 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) label = simpledialog.askstring("Mark", "Enter label for this mark:", parent=self)
if not label or not label.strip(): if not label or not label.strip():
return return
try: if self._mode.get() == "tcp":
self.process.stdin.write("m\n") ts = datetime.datetime.now().strftime("%H:%M:%S")
self.process.stdin.write(label.strip() + "\n") self._append_log(f"[MARK {ts}] {label.strip()}\n")
self.process.stdin.flush() else:
self._append_log(f"[MARK] {label.strip()}\n") if not self.process or not self.process.stdin or self.process.poll() is not None:
except Exception as e: return
messagebox.showerror("Error", f"Failed to send mark:\n{e}") 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}")
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────