v0.12.6 #10
@@ -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
@@ -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
@@ -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}")
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user