diff --git a/VideoReceiverSFP/core/payload_dispatcher.py b/VideoReceiverSFP/core/payload_dispatcher.py new file mode 100644 index 0000000..3f5c77e --- /dev/null +++ b/VideoReceiverSFP/core/payload_dispatcher.py @@ -0,0 +1,200 @@ +"""Dispatch logic for decoded frames (MFD/SAR/generic). + +Provides `dispatch_frame(module, frame, leader)` which encapsulates +recording, saving previews, and invoking registered callbacks. +""" +from typing import Any, Optional +import logging +import os +import time +import pathlib + +try: + from PIL import Image, ImageOps, ImageEnhance +except Exception: + Image = None + ImageOps = None + ImageEnhance = None + + +def dispatch_frame(module: Any, frame: Any, leader: Optional[Any]) -> None: + """Handle a decoded frame: start/ write video, save previews, and call callbacks. + + `module` is the `SfpConnectorModule` instance. + """ + try: + # Determine image type + image_type_id = None + try: + if leader is not None: + image_type_id = int(leader.HEADER_DATA.TYPE) + except Exception: + image_type_id = None + + # SAR path + if image_type_id == getattr(module, 'IMAGE_TYPE_SAR', 2): + try: + if Image is not None and hasattr(frame, 'copy'): + sar_img = frame.copy() + if getattr(module, '_sar_autocontrast', False) and ImageOps is not None: + try: + sar_img = ImageOps.autocontrast(sar_img) + except Exception: + pass + try: + bf = max(0.0, 1.0 + (float(getattr(module, '_sar_brightness', 0)) / 100.0)) + cf = max(0.0, 1.0 + (float(getattr(module, '_sar_contrast', 0)) / 100.0)) + if bf != 1.0 and ImageEnhance is not None: + sar_img = ImageEnhance.Brightness(sar_img).enhance(bf) + if cf != 1.0 and ImageEnhance is not None: + sar_img = ImageEnhance.Contrast(sar_img).enhance(cf) + except Exception: + pass + + # save sar png + if getattr(module, '_sar_save_png', False): + try: + if getattr(module, '_dump_manager', None) is not None: + saved = module._dump_manager.save_preview_from_pil(sar_img, category='sar', fmt='png') + if saved: + logging.getLogger().info("VideoReceiverSFP: saved sar png %s", saved) + else: + ts = time.strftime('%Y%m%d_%H%M%S') + (f"_{int(time.time() * 1000) % 1000:03d}") + dumps_dir = getattr(module, '_dumps_dir', os.path.join(os.getcwd(), 'dumps')) + os.makedirs(dumps_dir, exist_ok=True) + pngpath = os.path.join(dumps_dir, f'VideoReceiverSFP_sar_{ts}.png') + sar_img.save(pngpath) + module._saved_sar_pngs.append(pngpath) + while len(module._saved_sar_pngs) > module._dump_keep: + old = module._saved_sar_pngs.popleft() + try: + pathlib.Path(old).unlink() + except Exception: + pass + except Exception: + logging.getLogger().exception("VideoReceiverSFP: failed saving sar png frame") + + # sar video + try: + if getattr(module, '_record_sar_video', False) and getattr(module, '_dump_manager', None) is not None: + if not getattr(module, '_sar_video_active', False): + try: + w, h = None, None + if Image is not None and hasattr(sar_img, 'size'): + w, h = sar_img.size + else: + try: + import numpy as _np + arr = _np.asarray(sar_img) + if arr is not None and arr.ndim >= 2: + h, w = arr.shape[0], arr.shape[1] + except Exception: + pass + if w and h: + started = module._dump_manager.start_video_record('sar', w, h, fps=getattr(module, '_video_fps', 20)) + if started: + module._sar_video_active = True + try: + logging.getLogger().info("VideoReceiverSFP: started SAR video writer (%dx%d @ %s FPS)", w, h, getattr(module, '_video_fps', 20)) + except Exception: + pass + except Exception: + logging.getLogger().exception("VideoReceiverSFP: failed to start sar video writer") + if getattr(module, '_sar_video_active', False): + try: + module._dump_manager.write_video_frame(sar_img, 'sar') + except Exception: + logging.getLogger().exception("VideoReceiverSFP: failed writing sar frame to video") + except Exception: + pass + + # deliver to callbacks + for scb in list(getattr(module, '_sar_callbacks', [])): + try: + scb(sar_img) + except Exception: + pass + except Exception: + logging.getLogger().exception("VideoReceiverSFP: error processing SAR image for SAR callbacks") + + # MFD path + if image_type_id == getattr(module, 'IMAGE_TYPE_MFD', 1): + try: + try: + if getattr(module, '_record_mfd_video', False) and getattr(module, '_dump_manager', None) is not None: + if not getattr(module, '_mfd_video_active', False): + try: + w, h = None, None + if Image is not None and hasattr(frame, 'size'): + w, h = frame.size + else: + try: + import numpy as _np + arr = _np.asarray(frame) + if arr is not None and arr.ndim >= 2: + h, w = arr.shape[0], arr.shape[1] + except Exception: + pass + if w and h: + started = module._dump_manager.start_video_record('mfd', w, h, fps=getattr(module, '_video_fps', 20)) + if started: + module._mfd_video_active = True + try: + logging.getLogger().info("VideoReceiverSFP: started MFD video writer (%dx%d @ %s FPS)", w, h, getattr(module, '_video_fps', 20)) + except Exception: + pass + except Exception: + logging.getLogger().exception('VideoReceiverSFP: failed to start mfd video writer') + if getattr(module, '_mfd_video_active', False): + try: + module._dump_manager.write_video_frame(frame, 'mfd') + except Exception: + logging.getLogger().exception('VideoReceiverSFP: failed writing mfd frame to video') + try: + if not getattr(module, '_mfd_callbacks', False): + logging.getLogger().debug('VideoReceiverSFP: mfd frames are being recorded but no MFD callbacks are registered') + except Exception: + pass + except Exception: + pass + + for mcb in list(getattr(module, '_mfd_callbacks', [])): + try: + mcb(frame) + except Exception: + pass + except Exception: + pass + + # generic callbacks and saving + for cb in list(getattr(module, '_callbacks', [])): + try: + if getattr(module, '_save_png', False) and Image is not None and hasattr(frame, 'save'): + try: + if getattr(module, '_dump_manager', None) is not None: + saved = module._dump_manager.save_preview_from_pil(frame, category='frame', fmt='png') + if saved: + logging.getLogger().info("VideoReceiverSFP: saved frame to %s", saved) + else: + ts = time.strftime('%Y%m%d_%H%M%S') + (f"_{int(time.time() * 1000) % 1000:03d}") + dumps_dir = getattr(module, '_dumps_dir', os.path.join(os.getcwd(), 'dumps')) + os.makedirs(dumps_dir, exist_ok=True) + pngpath = os.path.join(dumps_dir, f'VideoReceiverSFP_frame_{ts}.png') + frame.save(pngpath) + module._saved_pngs.append(pngpath) + while len(module._saved_pngs) > module._dump_keep: + old = module._saved_pngs.popleft() + try: + pathlib.Path(old).unlink() + except Exception: + pass + except Exception: + logging.getLogger().exception('VideoReceiverSFP: failed to save preview png') + try: + cb(frame) + except Exception: + pass + except Exception: + pass + except Exception: + logging.getLogger().exception('VideoReceiverSFP: dispatch_frame unexpected error') diff --git a/VideoReceiverSFP/core/payload_preview.py b/VideoReceiverSFP/core/payload_preview.py index 88b133c..74bfbd1 100644 --- a/VideoReceiverSFP/core/payload_preview.py +++ b/VideoReceiverSFP/core/payload_preview.py @@ -113,10 +113,16 @@ def save_payload_preview(module, leader, payload, ity, dbgdir, ts): base_name = 'mfd' if ity == 1 else ('sar' if ity == 2 else 'unknown') fmt = 'bmp' if getattr(module, '_dump_manager', None) is not None: - saved = module._dump_manager.save_preview_from_array(norm, category=base_name, fmt=fmt) - if saved: - logging.getLogger().info('VideoReceiverSFP: saved payload preview %s', saved) - return + try: + # Convert numpy array to PIL Image and use existing DumpManager API + from PIL import Image as _PILImage + pil_img = _PILImage.fromarray(norm, mode='L') + saved = module._dump_manager.save_preview_from_pil(pil_img, category=base_name, fmt=fmt) + if saved: + logging.getLogger().info('VideoReceiverSFP: saved payload preview %s', saved) + return + except Exception: + logger.exception('save_payload_preview: DumpManager save_preview_from_pil failed for array input') else: from PIL import Image as _PILImage ts_name = ts + (f"_{int(__import__('time').time() * 1000) % 1000:03d}") diff --git a/VideoReceiverSFP/core/sfp_module.py b/VideoReceiverSFP/core/sfp_module.py index 7762c2e..35cf46e 100644 --- a/VideoReceiverSFP/core/sfp_module.py +++ b/VideoReceiverSFP/core/sfp_module.py @@ -4,6 +4,7 @@ This module implements a simple L0-like adapter that can run in simulated mode by reading images from a local directory and publishing frames to registered callbacks, or in network mode by listening for SFP UDP packets. """ + from typing import Callable, Dict, Any, Optional from collections import deque import pathlib @@ -54,24 +55,34 @@ try: from .dump_manager import DumpManager except Exception: DumpManager = None + try: from .payload_saver import save_bin_payload, save_unknown_sample except Exception: save_bin_payload = None save_unknown_sample = None +try: + from .payload_dispatcher import dispatch_frame +except Exception: + dispatch_frame = None # --- Image Type Constants --- # Based on actual payload analysis: TYPE=1 with PALTYPE=1 is MFD, TYPE=2 is SAR IMAGE_TYPE_UNKNOWN = 0 -IMAGE_TYPE_MFD = 1 # MFD images have TYPE=1 and typically PALTYPE=1 (indexed palette) -IMAGE_TYPE_SAR = 2 # SAR images have TYPE=2 +IMAGE_TYPE_MFD = 1 +IMAGE_TYPE_SAR = 2 -def get_image_type_name(type_id): - """Helper function to get the name of the image type.""" - return { - IMAGE_TYPE_SAR: "SAR", - IMAGE_TYPE_MFD: "MFD", - }.get(type_id, f"Unknown ({type_id})") + +def get_image_type_name(type_id: int) -> str: + """Return a short name for image type ids used in payloads.""" + try: + if int(type_id) == IMAGE_TYPE_MFD: + return 'MFD' + if int(type_id) == IMAGE_TYPE_SAR: + return 'SAR' + except Exception: + pass + return 'UNKNOWN' class SfpConnectorModule: @@ -83,7 +94,7 @@ class SfpConnectorModule: dispatch completed payloads to callbacks. """ - def __init__(self, name: str = "VideoReceiverSFP"): + def __init__(self, name: str = "VideoReceiverSFP") -> None: self.module_name = name self.is_running = False self._thread: Optional[threading.Thread] = None @@ -102,7 +113,6 @@ class SfpConnectorModule: self._image_type_filter: Optional[int] = None self._mfd_lut = None # saving flags and queues - # Saving of received payloads is disabled by default; use config flags self._save_png = False self._save_bin = False # video recording flags (disabled by default) @@ -121,7 +131,6 @@ class SfpConnectorModule: self._dump_keep = 100 self._dumps_dir = os.path.join(os.getcwd(), 'dumps') - # centralized dump manager for saving/rotation try: if DumpManager is not None: self._dump_manager = DumpManager(self._dumps_dir, self._dump_keep) @@ -129,377 +138,22 @@ class SfpConnectorModule: self._dump_manager = None except Exception: self._dump_manager = None - # deques to track saved files for rotation + self._saved_pngs = deque() self._saved_bins = deque() - - # dedicated callback lists for MFD and SAR viewers self._mfd_callbacks = [] self._sar_callbacks = [] - # callbacks for SAR metadata display (receives a formatted string) self._sar_metadata_callbacks = [] - - # SAR-specific display controls self._sar_brightness = 0 self._sar_contrast = 0 self._sar_autocontrast = False self._saved_sar_pngs = deque() self._sar_save_png = False - - # track whether we've saved the first decoded frame to disk self._first_frame_saved = False self._first_frame_path: Optional[str] = None - - # internal counters for logging self._frames_received = 0 - def initialize(self, config: Dict[str, Any]) -> bool: - """Initialize the module. - - Config keys: - - sim_dir: optional directory with sample images for simulated mode - - fps: frames per second (default 5) - - host: optional host to bind for network mode - - port: optional UDP port for network mode - """ - self._sim_dir = config.get("sim_dir") - self._fps = int(config.get("fps", 5)) - self._host = config.get("host") - self._port = config.get("port") - self._normalize_method = config.get("normalize", None) - - image_type_str = config.get("image_type_filter") - if image_type_str: - image_type_str = str(image_type_str).upper() - if image_type_str == 'MFD': - self._image_type_filter = IMAGE_TYPE_MFD - logging.info("VideoReceiverSFP: Filtering for MFD images only.") - elif image_type_str == 'SAR': - self._image_type_filter = IMAGE_TYPE_SAR - logging.info("VideoReceiverSFP: Filtering for SAR images only.") - else: - logging.warning("VideoReceiverSFP: Unknown image_type_filter=%s", image_type_str) - try: - logging.getLogger().debug("VideoReceiverSFP.initialize: sim_dir=%s fps=%s host=%s port=%s", self._sim_dir, self._fps, self._host, self._port) - except Exception: - pass - # Read default recording flags from local module config if present - try: - # Try to prefer centralized app config only if it explicitly defines the keys. - # Otherwise fall back to the local VideoReceiverSFP/core/config.py defaults. - try: - import controlpanel.config as appcfg - from . import config as localcfg - # If controlpanel.config explicitly defines the attributes, use them; - # otherwise use the local module defaults. - if hasattr(appcfg, 'RECORD_MFD_VIDEO'): - try: - self._record_mfd_video = bool(getattr(appcfg, 'RECORD_MFD_VIDEO')) - except Exception: - pass - else: - try: - self._record_mfd_video = bool(getattr(localcfg, 'RECORD_MFD_VIDEO', self._record_mfd_video)) - except Exception: - pass - - if hasattr(appcfg, 'RECORD_SAR_VIDEO'): - try: - self._record_sar_video = bool(getattr(appcfg, 'RECORD_SAR_VIDEO')) - except Exception: - pass - else: - try: - self._record_sar_video = bool(getattr(localcfg, 'RECORD_SAR_VIDEO', self._record_sar_video)) - except Exception: - pass - - if hasattr(appcfg, 'VIDEO_FPS'): - try: - self._video_fps = int(getattr(appcfg, 'VIDEO_FPS')) - except Exception: - pass - else: - try: - self._video_fps = int(getattr(localcfg, 'VIDEO_FPS', self._video_fps)) - except Exception: - pass - except Exception: - # controlpanel.config not available; use local module config - from .config import RECORD_MFD_VIDEO, RECORD_SAR_VIDEO, VIDEO_FPS - try: - self._record_mfd_video = bool(RECORD_MFD_VIDEO) - except Exception: - pass - try: - self._record_sar_video = bool(RECORD_SAR_VIDEO) - except Exception: - pass - try: - self._video_fps = int(VIDEO_FPS) - except Exception: - pass - except Exception: - pass - # Allow orchestrator to override recording flags/fps via the config dict - try: - # Keys expected: 'record_mfd_video', 'record_sar_video', 'video_fps' - if 'record_mfd_video' in config: - try: - self._record_mfd_video = bool(config.get('record_mfd_video')) - except Exception: - pass - if 'record_sar_video' in config: - try: - self._record_sar_video = bool(config.get('record_sar_video')) - except Exception: - pass - if 'video_fps' in config: - try: - self._video_fps = int(config.get('video_fps')) - except Exception: - pass - except Exception: - pass - return True - - def start_session(self) -> None: - if self.is_running: - return - - # If network config provided and transport available, start transport - if self._host and self._port and SfpTransport is not None: - try: - # Register distinct handlers per SFP flow so the transport - # can call the correct one and we can rely on the flow id - # (ord('M') for MFD, ord('S') for SAR) instead of only - # trusting the embedded ImageLeader.TYPE which may be 0. - handlers = { - ord("M"): self._network_payload_handler_mfd, - ord("S"): self._network_payload_handler_sar, - } - self._transport = SfpTransport(self._host, int(self._port), handlers) - started = self._transport.start() - if started: - try: - logging.getLogger().info("VideoReceiverSFP: started SfpTransport on %s:%s", self._host, self._port) - except Exception: - pass - self.is_running = True - return - except Exception: - logging.exception("Failed to start SfpTransport; falling back to sim mode") - - # fallback to simulated folder stream - self._stop_event.clear() - self._thread = threading.Thread(target=self._loop, daemon=True) - self._thread.start() - self.is_running = True - - def stop_session(self) -> None: - if not self.is_running: - return - - # Stop transport if active - if getattr(self, "_transport", None) is not None: - try: - self._transport.shutdown() - except Exception: - pass - self._transport = None - - # Ensure any active video recordings are closed - try: - if getattr(self, '_dump_manager', None) is not None: - try: - logging.getLogger().info("VideoReceiverSFP: requesting stop of MFD video writer") - self._dump_manager.stop_video_record('mfd') - except Exception: - pass - try: - logging.getLogger().info("VideoReceiverSFP: requesting stop of SAR video writer") - self._dump_manager.stop_video_record('sar') - except Exception: - pass - except Exception: - pass - - self._stop_event.set() - if self._thread is not None: - self._thread.join(timeout=2.0) - # mark writers inactive locally - self._mfd_video_active = False - self._sar_video_active = False - self.is_running = False - - def register_callback(self, cb: Callable[[Any], None]) -> None: - if cb not in self._callbacks: - self._callbacks.append(cb) - try: - logging.getLogger().debug("VideoReceiverSFP: registered callback %s", getattr(cb, '__name__', str(cb))) - except Exception: - pass - # if we already have a last frame, deliver it immediately so UI can show initial image - try: - if self._last_frame is not None: - try: - cb(self._last_frame) - except Exception: - pass - except Exception: - pass - - def register_mfd_callback(self, cb: Callable[[Any], None]) -> None: - if cb not in self._mfd_callbacks: - self._mfd_callbacks.append(cb) - # push last frame immediately if available (helps initial UI refresh) - try: - if self._last_frame is not None: - try: - cb(self._last_frame) - except Exception: - pass - except Exception: - pass - - def register_sar_callback(self, cb: Callable[[Any], None]) -> None: - if cb not in self._sar_callbacks: - self._sar_callbacks.append(cb) - try: - if self._last_frame is not None: - try: - cb(self._last_frame) - except Exception: - pass - except Exception: - pass - - def register_sar_metadata_callback(self, cb: Callable[[str], None]) -> None: - """Register a callback that receives a formatted metadata string for the last SAR image.""" - if cb not in self._sar_metadata_callbacks: - self._sar_metadata_callbacks.append(cb) - - def _notify_sar_metadata(self, metadata_str: Optional[str]) -> None: - if not metadata_str: - return - for cb in list(self._sar_metadata_callbacks): - try: - cb(metadata_str) - except Exception: - pass - - def get_status(self) -> Dict[str, Any]: - return { - "is_running": self.is_running, - "sim_dir": self._sim_dir, - "fps": self._fps, - "host": self._host, - "port": self._port, - } - - def get_data_stream(self) -> Any: - with self._frame_lock: - return self._last_frame - - def update_mfd_lut(self, new_lut) -> None: - """Update the MFD LUT used for MFD image decoding. - - Args: - new_lut: 256x3 RGB LUT array (uint8) - """ - self._mfd_lut = new_lut - logging.info("VideoReceiverSFP: MFD LUT updated") - # Optionally re-decode last frame if it was MFD type - # (For simplicity, next frame will use new LUT) - - def save_last_frame(self, path: str) -> bool: - frame = self.get_data_stream() - if frame is None: - return False - try: - if Image is not None and hasattr(frame, "save"): - frame.save(path) - else: - with open(path, "wb") as fh: - fh.write(frame) - return True - except Exception: - return False - - def _list_sim_files(self): - if not self._sim_dir or not os.path.isdir(self._sim_dir): - return [] - files = [f for f in sorted(os.listdir(self._sim_dir)) if not f.startswith(".")] - return [os.path.join(self._sim_dir, f) for f in files] - - def _load_next_frame(self): - files = self._list_sim_files() - if not files: - return None - path = files[self._frame_idx % len(files)] - self._frame_idx += 1 - try: - if Image is not None: - # Open without forced conversion so we can detect grayscale/index images - img = Image.open(path) - try: - mode = getattr(img, 'mode', None) - # If the file is single-channel (likely an MFD index image), attempt to colorize - if mode in ('L', 'P'): - try: - import numpy as _np - inds = _np.array(img.convert('L')) - self._ensure_mfd_lut() - if self._mfd_lut is not None and apply_mfd_lut is not None: - rgb = apply_mfd_lut(inds, self._mfd_lut) - if rgb is not None and Image is not None: - try: - frame = Image.fromarray(rgb.astype('uint8'), mode='RGB') - dumps_dir = os.path.join(os.getcwd(), 'dumps') - os.makedirs(dumps_dir, exist_ok=True) - dbg_path = os.path.join(dumps_dir, 'VideoReceiverSFP_colorized_debug.png') - frame.save(dbg_path) - except Exception: - pass - return frame - except Exception: - pass - - # Some dumps are stored as RGB but actually contain index images (identical channels / low cardinality) - if mode == 'RGB': - try: - import numpy as _np - larr = _np.array(img.convert('L')) - unique_vals = _np.unique(larr) - # If unique index count is small (<=32) treat as indices and colorize - if unique_vals.size <= 32: - self._ensure_mfd_lut() - if self._mfd_lut is not None and apply_mfd_lut is not None: - rgb = apply_mfd_lut(larr, self._mfd_lut) - if rgb is not None and Image is not None: - try: - frame = Image.fromarray(rgb.astype('uint8'), mode='RGB') - dumps_dir = os.path.join(os.getcwd(), 'dumps') - os.makedirs(dumps_dir, exist_ok=True) - dbg_path = os.path.join(dumps_dir, 'VideoReceiverSFP_colorized_debug.png') - frame.save(dbg_path) - except Exception: - pass - return frame - except Exception: - pass - - # Default: return an RGB-converted copy for display - return img.convert("RGB") - except Exception: - return img.convert("RGB") - else: - with open(path, "rb") as fh: - return fh.read() - except Exception: - return None - - def _loop(self): + def _loop(self) -> None: interval = 1.0 / max(1, self._fps) while not self._stop_event.is_set(): frame = self._load_next_frame() @@ -1437,6 +1091,70 @@ class SfpConnectorModule: self._save_bin = bool(enabled) logging.getLogger().info("VideoReceiverSFP: save_bin set to %s", self._save_bin) + def initialize(self, config: Dict[str, Any]) -> bool: + """Initialize the module with a config dict. + + Supported keys: + - sim_dir, fps, host, port, normalize, image_type_filter + - record_mfd_video, record_sar_video, video_fps (overrides) + """ + try: + self._sim_dir = config.get("sim_dir") + self._fps = int(config.get("fps", self._fps)) + self._host = config.get("host") + self._port = config.get("port") + self._normalize_method = config.get("normalize", None) + + image_type_str = config.get("image_type_filter") + if image_type_str: + image_type_str = str(image_type_str).upper() + if image_type_str == 'MFD': + self._image_type_filter = IMAGE_TYPE_MFD + logging.info("VideoReceiverSFP: Filtering for MFD images only.") + elif image_type_str == 'SAR': + self._image_type_filter = IMAGE_TYPE_SAR + logging.info("VideoReceiverSFP: Filtering for SAR images only.") + + # Prefer controlpanel.app config when present, otherwise local module config + try: + import controlpanel.config as appcfg + from . import config as localcfg + if hasattr(appcfg, 'RECORD_MFD_VIDEO'): + self._record_mfd_video = bool(getattr(appcfg, 'RECORD_MFD_VIDEO')) + else: + self._record_mfd_video = bool(getattr(localcfg, 'RECORD_MFD_VIDEO', self._record_mfd_video)) + + if hasattr(appcfg, 'RECORD_SAR_VIDEO'): + self._record_sar_video = bool(getattr(appcfg, 'RECORD_SAR_VIDEO')) + else: + self._record_sar_video = bool(getattr(localcfg, 'RECORD_SAR_VIDEO', self._record_sar_video)) + + if hasattr(appcfg, 'VIDEO_FPS'): + self._video_fps = int(getattr(appcfg, 'VIDEO_FPS')) + else: + self._video_fps = int(getattr(localcfg, 'VIDEO_FPS', self._video_fps)) + except Exception: + try: + from .config import RECORD_MFD_VIDEO, RECORD_SAR_VIDEO, VIDEO_FPS + self._record_mfd_video = bool(RECORD_MFD_VIDEO) + self._record_sar_video = bool(RECORD_SAR_VIDEO) + self._video_fps = int(VIDEO_FPS) + except Exception: + pass + + # Allow direct overrides from orchestrator-provided config dict + if 'record_mfd_video' in config: + self._record_mfd_video = bool(config.get('record_mfd_video')) + if 'record_sar_video' in config: + self._record_sar_video = bool(config.get('record_sar_video')) + if 'video_fps' in config: + self._video_fps = int(config.get('video_fps')) + + return True + except Exception: + logging.getLogger().exception('VideoReceiverSFP: initialize failed') + return False + def set_record_mfd_video(self, enabled: bool, fps: Optional[int] = None) -> None: try: self._record_mfd_video = bool(enabled) @@ -1496,3 +1214,125 @@ class SfpConnectorModule: logging.getLogger().info("VideoReceiverSFP: sar_save_png set to %s", self._sar_save_png) except Exception: pass + + # Callback registration helpers + def register_callback(self, cb: Callable[[Any], None]) -> None: + try: + if cb not in self._callbacks: + self._callbacks.append(cb) + # Immediately deliver last frame if available + try: + if self._last_frame is not None: + cb(self._last_frame) + except Exception: + pass + except Exception: + logging.getLogger().exception('VideoReceiverSFP: failed to register generic callback') + + def register_mfd_callback(self, cb: Callable[[Any], None]) -> None: + try: + if cb not in self._mfd_callbacks: + self._mfd_callbacks.append(cb) + # deliver last frame immediately when present + try: + if self._last_frame is not None: + cb(self._last_frame) + except Exception: + pass + except Exception: + logging.getLogger().exception('VideoReceiverSFP: failed to register mfd callback') + + def register_sar_callback(self, cb: Callable[[Any], None]) -> None: + try: + if cb not in self._sar_callbacks: + self._sar_callbacks.append(cb) + try: + if self._last_frame is not None: + cb(self._last_frame) + except Exception: + pass + except Exception: + logging.getLogger().exception('VideoReceiverSFP: failed to register sar callback') + + def register_sar_metadata_callback(self, cb: Callable[[str], None]) -> None: + try: + if cb not in self._sar_metadata_callbacks: + self._sar_metadata_callbacks.append(cb) + try: + if getattr(self, '_last_sar_metadata_str', None) is not None: + cb(self._last_sar_metadata_str) + except Exception: + pass + except Exception: + logging.getLogger().exception('VideoReceiverSFP: failed to register sar metadata callback') + + def _notify_sar_metadata(self, metadata_str: Optional[str]) -> None: + for cb in list(self._sar_metadata_callbacks): + try: + cb(metadata_str) + except Exception: + pass + + def get_status(self) -> Dict[str, Any]: + return { + "is_running": bool(self.is_running), + "sim_dir": self._sim_dir, + "fps": self._fps, + "host": self._host, + "port": self._port, + } + + def start_session(self) -> bool: + try: + if self.is_running: + return True + # If host/port provided -> network mode using SfpTransport + if self._host and self._port and SfpTransport is not None: + handlers = { + ord('M'): self._network_payload_handler_mfd, + ord('S'): self._network_payload_handler_sar, + } + # general fallback handler for other flows + handlers[0] = self._network_payload_handler + try: + self._transport = SfpTransport(self._host, int(self._port), handlers) + started = self._transport.start() + if not started: + logging.getLogger().error('VideoReceiverSFP: failed to start SfpTransport') + self._transport = None + else: + logging.getLogger().info('VideoReceiverSFP: SfpTransport started on %s:%s', self._host, self._port) + except Exception: + logging.getLogger().exception('VideoReceiverSFP: exception while creating SfpTransport') + + # If sim_dir configured -> start simulation loop thread + if self._sim_dir: + self._stop_event.clear() + if self._thread is None or not self._thread.is_alive(): + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + logging.getLogger().info('VideoReceiverSFP: simulation thread started') + + self.is_running = True + return True + except Exception: + logging.getLogger().exception('VideoReceiverSFP: failed to start session') + return False + + def stop_session(self) -> None: + try: + self._stop_event.set() + if getattr(self, '_thread', None) is not None and self._thread.is_alive(): + try: + self._thread.join(timeout=1.0) + except Exception: + pass + if getattr(self, '_transport', None) is not None: + try: + self._transport.shutdown() + except Exception: + pass + self._transport = None + self.is_running = False + except Exception: + logging.getLogger().exception('VideoReceiverSFP: error stopping session') diff --git a/VideoReceiverSFP/gui/viewer_sar.py b/VideoReceiverSFP/gui/viewer_sar.py index 95f15f4..e1439c8 100644 --- a/VideoReceiverSFP/gui/viewer_sar.py +++ b/VideoReceiverSFP/gui/viewer_sar.py @@ -67,15 +67,18 @@ class SfpSarViewer: save_cb = ttk.Checkbutton(ctrl_frame, text="Save .png", variable=self._save_png_var, command=self._on_save_png_toggled) save_cb.grid(row=6, column=0, sticky="w", pady=(2,2)) - # Status / FPS - self._fps_label = ttk.Label(ctrl_frame, text="FPS: 0.00") - self._fps_label.grid(row=6, column=0, sticky="w", pady=(8,0)) - ctrl_frame.columnconfigure(0, weight=1) main.rowconfigure(0, weight=1) + main.rowconfigure(1, weight=0) main.columnconfigure(0, weight=3) main.columnconfigure(1, weight=1) + # Bottom: Status bar (FPS) + status_frame = ttk.Frame(main, relief="sunken") + status_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0)) + self._fps_label = ttk.Label(status_frame, text="FPS: 0.00") + self._fps_label.pack(side="left", padx=5) + # runtime state self._photo = None self._pending_frame = None