diff --git a/settings.json b/settings.json index b1b8a18..7c2df7d 100644 --- a/settings.json +++ b/settings.json @@ -2,7 +2,7 @@ "general": { "scan_limit": 60, "max_range": 100, - "geometry": "1200x1024+463+195", + "geometry": "1200x1024+85+163", "last_selected_scenario": "scenario_9g", "connection": { "target": { diff --git a/target_simulator/gui/payload_router.py b/target_simulator/gui/payload_router.py new file mode 100644 index 0000000..200fc8f --- /dev/null +++ b/target_simulator/gui/payload_router.py @@ -0,0 +1,235 @@ +"""Payload router for buffering SFP payloads for the GUI. + +This module extracts the DebugPayloadRouter class so the router can be +reused and tested independently from the Tkinter window. +""" +import threading +import collections +import datetime +import os +import logging +from typing import Dict, Optional, Any +from target_simulator.core.sfp_structures import SFPHeader, SfpRisStatusPayload + + +class DebugPayloadRouter: + """ + A router that buffers the last received payload for each flow, + allowing the GUI to sample the data at a lower frequency. + This class is thread-safe. + """ + + def __init__(self): + self._log_prefix = "[DebugPayloadRouter]" + self._lock = threading.Lock() + # Buffer to store the last received payload for each flow type + self._latest_payloads: Dict[str, bytearray] = {} + # Buffer to store the last raw packet received (bytes, addr) + self._last_raw_packet: Optional[tuple] = None + # History of raw packets (timestamp, addr, raw bytes) + self._history_size = 20 + self._history = collections.deque(maxlen=self._history_size) + self._persist = False + # default persist dir: repository Temp/ folder + project_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..") + ) + self._persist_dir = os.path.join(project_root, "Temp") + try: + os.makedirs(self._persist_dir, exist_ok=True) + except Exception: + pass + logging.info(f"{self._log_prefix} Initialized.") + + def get_handlers(self) -> Dict[int, Any]: + """Returns handlers that update the internal last-payload buffer.""" + return { + ord("M"): lambda payload: self._update_last_payload("MFD", payload), + ord("S"): lambda payload: self._update_last_payload("SAR", payload), + ord("B"): lambda payload: self._update_last_payload("BIN", payload), + ord("J"): lambda payload: self._update_last_payload("JSON", payload), + # Support both uppercase 'R' and lowercase 'r' as RIS/status flows + ord("R"): lambda payload: self._handle_ris_status(payload), + ord("r"): lambda payload: self._handle_ris_status(payload), + } + + def _update_last_payload(self, flow_id: str, payload: bytearray): + """Thread-safely stores the latest payload for a given flow.""" + with self._lock: + self._latest_payloads[flow_id] = payload + + def _handle_ris_status(self, payload: bytearray): + """Try to parse a RIS status payload and store a concise summary. + + If parsing fails, store the raw payload as before. + """ + try: + if len(payload) >= SfpRisStatusPayload.size(): + # Interpret the first bytes as the status payload + parsed = SfpRisStatusPayload.from_buffer_copy( + bytes(payload[: SfpRisStatusPayload.size()]) + ) + sc = parsed.scenario + lines = [] + lines.append("RIS Status Payload:\n") + # Scenario block + lines.append("Scenario:") + lines.append(f" timetag : {sc.timetag}") + lines.append(f" platform_azim : {sc.platform_azimuth:.6f}") + lines.append(f" vx,vy,vz : {sc.vx:.3f}, {sc.vy:.3f}, {sc.vz:.3f}") + lines.append(f" baro_altitude : {sc.baro_altitude:.3f}") + lines.append(f" latitude : {sc.latitude:.6f}") + lines.append(f" longitude : {sc.longitude:.6f}") + lines.append(f" true_heading : {sc.true_heading:.3f}\n") + + # Targets block + lines.append("Targets (first non-zero flags shown):") + any_target = False + for idx, t in enumerate(parsed.tgt.tgt): + if t.flags != 0: + any_target = True + lines.append( + f" [{idx}] flags={t.flags} heading={t.heading:.3f} x={t.x:.3f} y={t.y:.3f} z={t.z:.3f}" + ) + if not any_target: + lines.append(" (no enabled targets)") + + # NOTE: omit hex sample from RIS textual summary to avoid + # cluttering the application log with large binary dumps. + # The structured JSON payload (RIS_STATUS_JSON) contains + # the parsed values that the UI consumes. + + text_out = "\n".join(lines) + # Build structured JSON for UI table consumption + try: + import json + + scenario_dict = { + "timetag": int(parsed.scenario.timetag), + "platform_azimuth": float(parsed.scenario.platform_azimuth), + "vx": float(parsed.scenario.vx), + "vy": float(parsed.scenario.vy), + "vz": float(parsed.scenario.vz), + "baro_altitude": float(parsed.scenario.baro_altitude), + "latitude": float(parsed.scenario.latitude), + "longitude": float(parsed.scenario.longitude), + "true_heading": float(parsed.scenario.true_heading), + } + targets_list = [] + for idx, t in enumerate(parsed.tgt.tgt): + targets_list.append( + { + "index": idx, + "flags": int(t.flags), + "heading": float(t.heading), + "x": float(t.x), + "y": float(t.y), + "z": float(t.z), + } + ) + struct = {"scenario": scenario_dict, "targets": targets_list} + json_bytes = bytearray(json.dumps(struct).encode("utf-8")) + except Exception: + json_bytes = bytearray(b"{}") + + # Store textual representation and structured JSON so GUI can display it directly + self._update_last_payload("RIS_STATUS", bytearray(text_out.encode("utf-8"))) + self._update_last_payload("RIS_STATUS_JSON", json_bytes) + return + except Exception: + # fall through to storing raw payload + pass + + # Fallback: store raw payload (as hex dump) + try: + text_out = "\n".join([f"{b:02X}" for b in payload]) + self._update_last_payload("RIS_STATUS", bytearray(text_out.encode("utf-8"))) + except Exception: + self._update_last_payload("RIS_STATUS", payload) + + def get_and_clear_latest_payloads(self) -> Dict[str, bytearray]: + """ + Thread-safely retrieves all new payloads received since the last call + and clears the internal buffer. + + Returns: + Dict[str, bytearray]: A dictionary of the latest payload for each flow. + """ + with self._lock: + # Atomically swap the buffer with an empty one + new_payloads = self._latest_payloads + self._latest_payloads = {} + return new_payloads + + def update_raw_packet(self, raw_bytes: bytes, addr: tuple): + """Store the last raw packet received (overwritten by subsequent packets).""" + with self._lock: + # Keep last packet for immediate display + self._last_raw_packet = (raw_bytes, addr) + # Append to history with timestamp and small metadata + entry = { + "ts": datetime.datetime.utcnow(), + "addr": addr, + "raw": raw_bytes, + } + # Try to parse SFP header to capture flow/TID for list display + try: + hdr = SFPHeader.from_buffer_copy(raw_bytes) + entry["flow"] = int(hdr.SFP_FLOW) + entry["tid"] = int(hdr.SFP_TID) + # map common flows to names when possible + flow_map = { + ord("M"): "MFD", + ord("S"): "SAR", + ord("B"): "BIN", + ord("J"): "JSON", + } + entry["flow_name"] = flow_map.get( + entry["flow"], + ( + chr(entry["flow"]) if 32 <= entry["flow"] < 127 else str(entry["flow"]) + ), + ) + except Exception: + # best-effort: leave flow/tid absent + pass + self._history.append(entry) + # Optionally persist to disk (each entry as binary) + if self._persist: + try: + ts = entry["ts"].strftime("%Y%m%dT%H%M%S.%f") + fname = f"sfp_raw_{ts}_{addr[0].replace(':','_')}_{addr[1]}.bin" + path = os.path.join(self._persist_dir, fname) + with open(path, "wb") as f: + f.write(raw_bytes) + except Exception: + # don't propagate persistence errors to caller + pass + + def get_and_clear_raw_packet(self) -> Optional[tuple]: + with self._lock: + pkt = self._last_raw_packet + self._last_raw_packet = None + return pkt + + def get_history(self): + with self._lock: + return list(self._history) + + def clear_history(self): + with self._lock: + self._history.clear() + + def set_history_size(self, n: int): + with self._lock: + try: + n = max(1, int(n)) + except Exception: + return + self._history_size = n + new_deque = collections.deque(self._history, maxlen=self._history_size) + self._history = new_deque + + def set_persist(self, enabled: bool): + with self._lock: + self._persist = bool(enabled) diff --git a/target_simulator/gui/sfp_debug_window.py b/target_simulator/gui/sfp_debug_window.py index f8b5011..8cbd461 100644 --- a/target_simulator/gui/sfp_debug_window.py +++ b/target_simulator/gui/sfp_debug_window.py @@ -34,329 +34,63 @@ from target_simulator.core.sfp_structures import ( SFPHeader, SfpRisStatusPayload, ) - -# --- Helper Class for Routing and Buffering Payloads --- - - -class DebugPayloadRouter: - """ - A router that buffers the last received payload for each flow, - allowing the GUI to sample the data at a lower frequency. - This class is thread-safe. - """ - - def __init__(self): - self._log_prefix = "[DebugPayloadRouter]" - self._lock = threading.Lock() - # Buffer to store the last received payload for each flow type - self._latest_payloads: Dict[str, bytearray] = {} - # Buffer to store the last raw packet received (bytes, addr) - self._last_raw_packet: Optional[tuple] = None - # History of raw packets (timestamp, addr, raw bytes) - self._history_size = 20 - self._history = collections.deque(maxlen=self._history_size) - self._persist = False - # default persist dir: repository Temp/ folder - project_root = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..") - ) - self._persist_dir = os.path.join(project_root, "Temp") - try: - os.makedirs(self._persist_dir, exist_ok=True) - except Exception: - pass - logging.info(f"{self._log_prefix} Initialized.") - - def get_handlers(self) -> Dict[int, PayloadHandler]: - """Returns handlers that update the internal last-payload buffer.""" - return { - ord("M"): lambda payload: self._update_last_payload("MFD", payload), - ord("S"): lambda payload: self._update_last_payload("SAR", payload), - ord("B"): lambda payload: self._update_last_payload("BIN", payload), - ord("J"): lambda payload: self._update_last_payload("JSON", payload), - # Support both uppercase 'R' and lowercase 'r' as RIS/status flows - ord("R"): lambda payload: self._handle_ris_status(payload), - ord("r"): lambda payload: self._handle_ris_status(payload), - } - - def _update_last_payload(self, flow_id: str, payload: bytearray): - """Thread-safely stores the latest payload for a given flow.""" - with self._lock: - self._latest_payloads[flow_id] = payload - - def _handle_ris_status(self, payload: bytearray): - """Try to parse a RIS status payload and store a concise summary. - - If parsing fails, store the raw payload as before. - """ - try: - if len(payload) >= SfpRisStatusPayload.size(): - # Interpret the first bytes as the status payload - parsed = SfpRisStatusPayload.from_buffer_copy( - bytes(payload[: SfpRisStatusPayload.size()]) - ) - sc = parsed.scenario - lines = [] - lines.append("RIS Status Payload:\n") - # Scenario block - lines.append("Scenario:") - lines.append(f" timetag : {sc.timetag}") - lines.append(f" platform_azim : {sc.platform_azimuth:.6f}") - lines.append(f" vx,vy,vz : {sc.vx:.3f}, {sc.vy:.3f}, {sc.vz:.3f}") - lines.append(f" baro_altitude : {sc.baro_altitude:.3f}") - lines.append(f" latitude : {sc.latitude:.6f}") - lines.append(f" longitude : {sc.longitude:.6f}") - lines.append(f" true_heading : {sc.true_heading:.3f}\n") - - # Targets block - lines.append("Targets (first non-zero flags shown):") - any_target = False - for idx, t in enumerate(parsed.tgt.tgt): - if t.flags != 0: - any_target = True - lines.append( - f" [{idx}] flags={t.flags} heading={t.heading:.3f} x={t.x:.3f} y={t.y:.3f} z={t.z:.3f}" - ) - if not any_target: - lines.append(" (no enabled targets)") - - # Attach a short hex summary of the first bytes after the header so user can correlate - try: - sample_len = min(48, len(payload)) - sample = payload[:sample_len] - hex_sample = " ".join(f"{b:02X}" for b in sample) - lines.append("\nPayload sample (hex, first %d bytes):" % sample_len) - lines.append(f" {hex_sample}") - except Exception: - pass - - text_out = "\n".join(lines) - # Build structured JSON for UI table consumption - try: - import json - - scenario_dict = { - "timetag": int(parsed.scenario.timetag), - "platform_azimuth": float(parsed.scenario.platform_azimuth), - "vx": float(parsed.scenario.vx), - "vy": float(parsed.scenario.vy), - "vz": float(parsed.scenario.vz), - "baro_altitude": float(parsed.scenario.baro_altitude), - "latitude": float(parsed.scenario.latitude), - "longitude": float(parsed.scenario.longitude), - "true_heading": float(parsed.scenario.true_heading), - } - targets_list = [] - for idx, t in enumerate(parsed.tgt.tgt): - targets_list.append( - { - "index": idx, - "flags": int(t.flags), - "heading": float(t.heading), - "x": float(t.x), - "y": float(t.y), - "z": float(t.z), - } - ) - struct = {"scenario": scenario_dict, "targets": targets_list} - json_bytes = bytearray(json.dumps(struct).encode("utf-8")) - except Exception: - json_bytes = bytearray(b"{}") - - # Store textual representation and structured JSON so GUI can display it directly - self._update_last_payload("RIS_STATUS", bytearray(text_out.encode("utf-8"))) - self._update_last_payload("RIS_STATUS_JSON", json_bytes) - return - except Exception: - # fall through to storing raw payload - pass - - # Fallback: store raw payload (as hex dump) - try: - text_out = "\n".join([f"{b:02X}" for b in payload]) - self._update_last_payload("RIS_STATUS", bytearray(text_out.encode("utf-8"))) - except Exception: - self._update_last_payload("RIS_STATUS", payload) - - def get_and_clear_latest_payloads(self) -> Dict[str, bytearray]: - """ - Thread-safely retrieves all new payloads received since the last call - and clears the internal buffer. - - Returns: - Dict[str, bytearray]: A dictionary of the latest payload for each flow. - """ - with self._lock: - # Atomically swap the buffer with an empty one - new_payloads = self._latest_payloads - self._latest_payloads = {} - return new_payloads - - def update_raw_packet(self, raw_bytes: bytes, addr: tuple): - """Store the last raw packet received (overwritten by subsequent packets).""" - with self._lock: - # Keep last packet for immediate display - self._last_raw_packet = (raw_bytes, addr) - # Append to history with timestamp and small metadata - entry = { - "ts": datetime.datetime.utcnow(), - "addr": addr, - "raw": raw_bytes, - } - # Try to parse SFP header to capture flow/TID for list display - try: - hdr = SFPHeader.from_buffer_copy(raw_bytes) - entry["flow"] = int(hdr.SFP_FLOW) - entry["tid"] = int(hdr.SFP_TID) - # map common flows to names when possible - flow_map = { - ord("M"): "MFD", - ord("S"): "SAR", - ord("B"): "BIN", - ord("J"): "JSON", - } - entry["flow_name"] = flow_map.get( - entry["flow"], - ( - chr(entry["flow"]) - if 32 <= entry["flow"] < 127 - else str(entry["flow"]) - ), - ) - except Exception: - # best-effort: leave flow/tid absent - pass - self._history.append(entry) - # Optionally persist to disk (each entry as binary) - if self._persist: - try: - ts = entry["ts"].strftime("%Y%m%dT%H%M%S.%f") - fname = f"sfp_raw_{ts}_{addr[0].replace(':','_')}_{addr[1]}.bin" - path = os.path.join(self._persist_dir, fname) - with open(path, "wb") as f: - f.write(raw_bytes) - except Exception: - # don't propagate persistence errors to caller - pass - - def get_and_clear_raw_packet(self) -> Optional[tuple]: - with self._lock: - pkt = self._last_raw_packet - self._last_raw_packet = None - return pkt - - def get_history(self): - with self._lock: - return list(self._history) - - def clear_history(self): - with self._lock: - self._history.clear() - - def set_history_size(self, n: int): - with self._lock: - try: - n = max(1, int(n)) - except Exception: - return - self._history_size = n - new_deque = collections.deque(self._history, maxlen=self._history_size) - self._history = new_deque - - def set_persist(self, enabled: bool): - with self._lock: - self._persist = bool(enabled) - - -# --- Main Debug Window Class --- +from target_simulator.gui.payload_router import DebugPayloadRouter class SfpDebugWindow(tk.Toplevel): - """A self-contained SFP debugging and packet inspection window.""" + """Top-level window for SFP debugging and payload inspection. - GUI_POLL_INTERVAL_MS = 250 # Poll for new data 4 times per second + This class was previously defining the DebugPayloadRouter inline; the + router implementation has been moved to `target_simulator.gui.payload_router` + to decouple routing logic from the Tk window and allow independent tests. + """ - def __init__(self, master): + GUI_POLL_INTERVAL_MS = 250 + + def __init__(self, master=None): super().__init__(master) - self.title("SFP Packet Inspector") - self.geometry("900x700") - self.transient(master) - + self.master = master self.logger = logging.getLogger(__name__) - self.sfp_transport: Optional[SfpTransport] = None + # Router instance (buffers latest payloads per flow) self.payload_router = DebugPayloadRouter() + # Transport reference (set when connecting) + self.sfp_transport = None + # Image display defaults + self.image_area_size = 150 + # Connection fields + self.ip_var = tk.StringVar(value="127.0.0.1") + self.port_var = tk.StringVar(value="60002") - # Try to apply saved debug settings (history size, persist) from ConfigManager + # --- Connection Frame (IP / Port / Connect controls) --- + conn_frame = ttk.Frame(self) + conn_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 2)) + + ttk.Label(conn_frame, text="IP:").pack(side=tk.LEFT, padx=(4, 2)) + ttk.Entry(conn_frame, textvariable=self.ip_var, width=18).pack( + side=tk.LEFT, padx=(0, 6) + ) + ttk.Label(conn_frame, text="Port:").pack(side=tk.LEFT, padx=(0, 2)) + ttk.Entry(conn_frame, textvariable=self.port_var, width=8).pack( + side=tk.LEFT, padx=(0, 6) + ) + + self.connect_btn = ttk.Button(conn_frame, text="Connect", command=self._on_connect) + self.connect_btn.pack(side=tk.LEFT, padx=(0, 6)) + self.disconnect_btn = ttk.Button(conn_frame, text="Disconnect", command=self._on_disconnect) + self.disconnect_btn.pack(side=tk.LEFT, padx=(0, 6)) + # Start with disconnect disabled until connected try: - gm = getattr(master, "config_manager", None) - general = gm.get_general_settings() if gm else {} - sfp_debug_conf = general.get("sfp_debug", {}) - hist_size = int(sfp_debug_conf.get("history_size", 20)) - persist_raw = bool(sfp_debug_conf.get("persist_raw", False)) - # apply to router - try: - self.payload_router.set_history_size(hist_size) - self.payload_router.set_persist(persist_raw) - except Exception: - pass + self.disconnect_btn.config(state=tk.DISABLED) except Exception: pass - self.mfd_photo: Optional[ImageTk.PhotoImage] = None - self.sar_photo: Optional[ImageTk.PhotoImage] = None + # Quick utility buttons + self.send_probe_btn = ttk.Button(conn_frame, text="Send probe", command=self._on_send_probe) + self.send_probe_btn.pack(side=tk.LEFT, padx=(6, 4)) + self.send_ack_btn = ttk.Button(conn_frame, text="Send ACK", command=self._on_send_ack) + self.send_ack_btn.pack(side=tk.LEFT) - # Read image display size from settings (general.image_display.size) - try: - gm = getattr(master, "config_manager", None) - general = gm.get_general_settings() if gm else {} - img_conf = general.get("image_display", {}) - self.image_area_size = int(img_conf.get("size", 150)) - except Exception: - self.image_area_size = 150 - - self._create_widgets() - self.protocol("WM_DELETE_WINDOW", self._on_close) - - self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) - # Track last raw update time to throttle high-volume flows - self._last_raw_update_ts = 0.0 - - def _create_widgets(self): - # --- Connection Controls (unchanged) --- - conn_frame = ttk.LabelFrame(self, text="Connection", padding=5) - conn_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) - ttk.Label(conn_frame, text="IP:").pack(side=tk.LEFT, padx=(5, 2)) - self.ip_var = tk.StringVar(value="127.0.0.1") - ttk.Entry(conn_frame, textvariable=self.ip_var, width=15).pack(side=tk.LEFT) - ttk.Label(conn_frame, text="Port:").pack(side=tk.LEFT, padx=(10, 2)) - self.port_var = tk.StringVar(value="60002") ##55556 per mfd - ttk.Entry(conn_frame, textvariable=self.port_var, width=7).pack(side=tk.LEFT) - self.connect_btn = ttk.Button( - conn_frame, text="Connect", command=self._on_connect - ) - self.connect_btn.pack(side=tk.LEFT, padx=(10, 5)) - self.disconnect_btn = ttk.Button( - conn_frame, - text="Disconnect", - command=self._on_disconnect, - state=tk.DISABLED, - ) - self.disconnect_btn.pack(side=tk.LEFT, padx=5) - # Button to configure image display size - self.image_size_btn = ttk.Button( - conn_frame, text="Image size...", command=self._open_image_size_dialog - ) - self.image_size_btn.pack(side=tk.LEFT, padx=5) - # Button to send a simple UDP probe to the configured IP:Port - self.send_probe_btn = ttk.Button( - conn_frame, text="Send probe", command=self._on_send_probe - ) - self.send_probe_btn.pack(side=tk.LEFT, padx=5) - # Button to send a minimal SFP ACK packet to the configured IP:Port - self.send_ack_btn = ttk.Button( - conn_frame, text="Send ACK", command=self._on_send_ack - ) - self.send_ack_btn.pack(side=tk.LEFT, padx=5) + # Note: DebugPayloadRouter has been moved to `target_simulator.gui.payload_router`. # --- Script Sender Frame (below connection) --- script_frame = ttk.Frame(self) @@ -383,11 +117,49 @@ class SfpDebugWindow(tk.Toplevel): self.notebook.add(self.mfd_tab["frame"], text="MFD Image") self.sar_tab = self._create_image_tab("SAR Image") self.notebook.add(self.sar_tab["frame"], text="SAR Image") - # RIS status tab: textual decoded status of RIS payloads - self.ris_tab = scrolledtext.ScrolledText( - self.notebook, state=tk.DISABLED, wrap=tk.WORD, font=("Consolas", 10) - ) - self.notebook.add(self.ris_tab, text="RIS Status") + # RIS status tab: two-column layout with scenario (left) and targets (right) + ris_frame = ttk.Frame(self.notebook) + paned = ttk.Panedwindow(ris_frame, orient=tk.HORIZONTAL) + paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Left: scenario table (field, value) + left = ttk.Frame(paned) + self.scenario_tree = ttk.Treeview(left, columns=("field", "value"), show="headings", height=12) + self.scenario_tree.heading("field", text="Field") + self.scenario_tree.heading("value", text="Value") + self.scenario_tree.column("field", width=140, anchor="w") + self.scenario_tree.column("value", width=160, anchor="w") + self.scenario_tree.pack(fill=tk.BOTH, expand=True) + paned.add(left, weight=1) + + # Right: compact targets table + right = ttk.Frame(paned) + cols = ("idx", "flags", "heading", "x", "y", "z") + self.ris_tree = ttk.Treeview(right, columns=cols, show="headings", height=12) + for c, txt in zip(cols, ("#", "flags", "heading", "x", "y", "z")): + self.ris_tree.heading(c, text=txt) + self.ris_tree.column(c, width=70, anchor="center") + + # Apply smaller font to make table compact + try: + style = ttk.Style() + small_font = ("Consolas", 8) + style.configure("Small.Treeview", font=small_font) + self.ris_tree.configure(style="Small.Treeview") + self.scenario_tree.configure(style="Small.Treeview") + except Exception: + pass + + self.ris_tree.pack(fill=tk.BOTH, expand=True) + paned.add(right, weight=2) + + # Save CSV button under the paned window + btn_frame = ttk.Frame(ris_frame) + btn_frame.pack(fill=tk.X, padx=5, pady=(0, 5)) + self.ris_save_csv_btn = ttk.Button(btn_frame, text="Save CSV", command=lambda: self._on_save_ris_csv()) + self.ris_save_csv_btn.pack(side=tk.RIGHT) + + self.notebook.add(ris_frame, text="RIS Status") # Raw SFP packet view with history on the left and details on the right raw_frame = ttk.Frame(self.notebook) # Left: history listbox @@ -479,6 +251,13 @@ class SfpDebugWindow(tk.Toplevel): ) self.notebook.add(self.json_tab, text="JSON") + # Start the periodic GUI poll loop to process latest payloads from the router + try: + self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) + except Exception: + # If the Tk mainloop isn't running in tests, this will be a no-op + pass + def _create_image_tab(self, title: str) -> Dict: frame = ttk.Frame(self.notebook) # Fixed-size container to keep UI tidy. Image area will be size x size px. @@ -572,6 +351,7 @@ class SfpDebugWindow(tk.Toplevel): port=port, payload_handlers=self.payload_router.get_handlers(), ack_config=ack_config, + raw_packet_callback=self.payload_router.update_raw_packet, ) if self.sfp_transport.start(): self._log_to_widget( @@ -583,18 +363,6 @@ class SfpDebugWindow(tk.Toplevel): self._log_to_widget("Connection failed. Check IP/Port and logs.", "ERROR") self.sfp_transport = None # Register raw packet callback regardless of start result (safe no-op if None) - if self.sfp_transport: - # Provide the router.update_raw_packet method as callback - try: - self.sfp_transport._raw_packet_callback = ( - self.payload_router.update_raw_packet - ) - except Exception: - self.logger.exception( - "Failed to register raw_packet_callback on SfpTransport" - ) - - # Bind history tree selection to show past packet try: self.history_tree.bind( "<>", lambda e: self._on_history_select() @@ -806,21 +574,56 @@ class SfpDebugWindow(tk.Toplevel): elif flow_id == "JSON": self._display_json_data(payload, self.json_tab) elif flow_id == "RIS_STATUS": - # Display the textual RIS status in the RIS tab + # textual fallback: we intentionally do not write the + # full RIS textual summary into the generic log to avoid + # clutter; the structured JSON payload is used for UI. + # Keep this branch present in case future handling is + # needed. + pass + elif flow_id == "RIS_STATUS_JSON": + # Populate the scenario tree and the RIS targets tree from structured JSON try: - text = ( - payload.decode("utf-8") - if isinstance(payload, (bytes, bytearray)) - else str(payload) - ) + import json + + struct = json.loads(payload.decode("utf-8")) if isinstance(payload, (bytes, bytearray)) else payload + # scenario table (field, value) + for iid in self.scenario_tree.get_children(): + self.scenario_tree.delete(iid) + scenario = struct.get("scenario", {}) if isinstance(struct, dict) else {} + if scenario: + # Insert in deterministic order + order = [ + ("timetag", "timetag"), + ("platform_azimuth", "platform_azimuth"), + ("vx", "vx"), + ("vy", "vy"), + ("vz", "vz"), + ("baro_altitude", "baro_altitude"), + ("latitude", "latitude"), + ("longitude", "longitude"), + ("true_heading", "true_heading"), + ] + for label, key in order: + if key in scenario: + self.scenario_tree.insert("", tk.END, values=(label, scenario.get(key))) + + # targets + for iid in self.ris_tree.get_children(): + self.ris_tree.delete(iid) + targets = struct.get("targets", []) if isinstance(struct, dict) else [] + for t in targets: + vals = ( + t.get("index"), + t.get("flags"), + f"{t.get('heading'):.3f}", + f"{t.get('x'):.3f}", + f"{t.get('y'):.3f}", + f"{t.get('z'):.3f}", + ) + self.ris_tree.insert("", tk.END, values=vals) except Exception: - text = str(payload) - self.ris_tab.config(state=tk.NORMAL) - self.ris_tab.delete("1.0", tk.END) - self.ris_tab.insert("1.0", text) - self.ris_tab.config(state=tk.DISABLED) - # Optionally show the RIS tab automatically - # self.notebook.select(self.ris_tab) + # ignore malformed JSON for now + pass # self.notebook.select(self.json_tab) # Reschedule the next check @@ -1248,6 +1051,45 @@ class SfpDebugWindow(tk.Toplevel): self.log_tab.config(state=tk.DISABLED) self.log_tab.see(tk.END) + def _on_save_ris_csv(self): + try: + import csv + + # collect rows from tree + + # collect scenario rows + scenario_rows = [self.scenario_tree.item(iid, "values") for iid in self.scenario_tree.get_children()] + # collect target rows + rows = [self.ris_tree.item(iid, "values") for iid in self.ris_tree.get_children()] + + if not scenario_rows and not rows: + self._log_to_widget("No RIS data to save.", "INFO") + return + + # ensure Temp dir exists + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + temp_dir = os.path.join(project_root, "Temp") + os.makedirs(temp_dir, exist_ok=True) + ts = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%S") + fname = f"ris_targets_{ts}.csv" + path = os.path.join(temp_dir, fname) + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + # write scenario fields first + if scenario_rows: + writer.writerow(["Scenario Field", "Value"]) + for s in scenario_rows: + writer.writerow(s) + writer.writerow([]) + # write targets + writer.writerow(["index", "flags", "heading", "x", "y", "z"]) + for r in rows: + writer.writerow(r) + + self._log_to_widget(f"Saved RIS targets CSV to {path}", "INFO") + except Exception as e: + self._log_to_widget(f"Failed to save RIS CSV: {e}", "ERROR") + def _format_hex_dump(self, data: bytes, length=16) -> str: lines = [] for i in range(0, len(data), length): diff --git a/tests/core/test_ris_parsing.py b/tests/core/test_ris_parsing.py new file mode 100644 index 0000000..70b28f2 --- /dev/null +++ b/tests/core/test_ris_parsing.py @@ -0,0 +1,52 @@ +import json +import ctypes + +from target_simulator.core.sfp_structures import ( + SfpRisStatusPayload, + RisScenario, + RisTarget, + RisTargetsBlock, + DSP_RIS_MAX_TGT, +) +from target_simulator.gui.sfp_debug_window import DebugPayloadRouter + + +def build_test_payload(): + # build a minimal payload with scenario + one target enabled + payload = SfpRisStatusPayload() + # fill scenario + payload.scenario.timetag = 123456 + payload.scenario.platform_azimuth = 1.23 + payload.scenario.vx = 10.0 + payload.scenario.vy = -2.5 + payload.scenario.vz = 0.0 + payload.scenario.baro_altitude = 100.0 + payload.scenario.true_heading = 45.0 + payload.scenario.latitude = 12.345678 + payload.scenario.longitude = 98.765432 + + # fill first target + payload.tgt.tgt[0].flags = 1 + payload.tgt.tgt[0].heading = 90.0 + payload.tgt.tgt[0].x = 1.0 + payload.tgt.tgt[0].y = 2.0 + payload.tgt.tgt[0].z = 3.0 + + return bytes(payload) + + +def test_ris_router_parsing(): + router = DebugPayloadRouter() + data = build_test_payload() + # call handler directly + router._handle_ris_status(bytearray(data)) + # retrieve latest payloads + latest = router.get_and_clear_latest_payloads() + # Expect RIS_STATUS textual summary or raw + assert "RIS_STATUS" in latest + # textual content should include timetag and heading + txt = latest["RIS_STATUS"].decode("utf-8") + assert "timetag" in txt.lower() or "timetag" in txt + + # Note: _handle_ris_status also stores RIS_STATUS as text; JSON variant stored by SfpDebugWindow logic + # If JSON is not present in router (router stores only last payload per key) we at least ensure textual parse exists diff --git a/tests/gui/test_sfp_debug_polling.py b/tests/gui/test_sfp_debug_polling.py new file mode 100644 index 0000000..f949bfe --- /dev/null +++ b/tests/gui/test_sfp_debug_polling.py @@ -0,0 +1,28 @@ +import pytest + +from target_simulator.gui import sfp_debug_window + + +def test_sfpdebugwindow_starts_polling(monkeypatch): + calls = [] + + # Replace the after method on the class to capture scheduling attempts + def fake_after(self, ms, callback): + calls.append((ms, callback)) + return "fake_after_id" + + monkeypatch.setattr( + sfp_debug_window.SfpDebugWindow, "after", fake_after, raising=False + ) + + # Instantiate the window (should call after in __init__) + w = sfp_debug_window.SfpDebugWindow(master=None) + + # Ensure at least one scheduling call was made + assert calls, "SfpDebugWindow did not schedule the polling loop via after()" + + # Verify that one of the calls uses the configured GUI_POLL_INTERVAL_MS + assert any(ms == w.GUI_POLL_INTERVAL_MS for ms, _ in calls) + + # Verify that the callback scheduled is the internal _process_latest_payloads method + assert any(cb == w._process_latest_payloads for _, cb in calls)