diff --git a/.gitignore b/.gitignore index 2af7fed..e002952 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,9 @@ data/weather_sat/ # Radiosonde runtime data (station config, logs) data/radiosonde/ +# Downloaded TLE files (intercept_agent satellite predictor) +data/tle/ + # SDR capture files (large IQ recordings) data/subghz/captures/ diff --git a/app.py b/app.py index ea4810b..c2975b7 100644 --- a/app.py +++ b/app.py @@ -1293,6 +1293,11 @@ def _init_app() -> None: except Exception as e: logger.warning(f"Ground station scheduler init failed: {e}") + # Skip background init when disabled (set by tests — the deferred thread + # fires mid-session and its subprocess/DB cleanup races with test mocks) + if os.environ.get("INTERCEPT_SKIP_DEFERRED_INIT") == "1": + return + threading.Thread(target=_deferred_init, daemon=True).start() diff --git a/intercept_agent.py b/intercept_agent.py index ca4f7e9..468ed72 100644 --- a/intercept_agent.py +++ b/intercept_agent.py @@ -37,6 +37,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Import dependency checking from Intercept utils try: from utils.dependencies import TOOL_DEPENDENCIES, check_all_dependencies, check_tool + HAS_DEPENDENCIES_MODULE = True except ImportError: HAS_DEPENDENCIES_MODULE = False @@ -45,6 +46,7 @@ except ImportError: try: from utils.tscm.correlation import CorrelationEngine from utils.tscm.detector import ThreatDetector + HAS_TSCM_MODULES = True except ImportError: HAS_TSCM_MODULES = False @@ -54,6 +56,7 @@ except ImportError: # Import database functions for baseline support (same as local mode) try: from utils.database import get_active_tscm_baseline, get_tscm_baseline + HAS_BASELINE_DB = True except ImportError: HAS_BASELINE_DB = False @@ -61,19 +64,17 @@ except ImportError: get_active_tscm_baseline = None # Setup logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s [%(levelname)s] %(name)s: %(message)s' -) -logger = logging.getLogger('intercept.agent') +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") +logger = logging.getLogger("intercept.agent") # Version -AGENT_VERSION = '1.0.0' +AGENT_VERSION = "1.0.0" # ============================================================================= # Configuration # ============================================================================= + class AgentConfig: """Agent configuration loaded from INI file or defaults.""" @@ -85,26 +86,26 @@ class AgentConfig: self.allow_cors: bool = False # Controller settings - self.controller_url: str = '' - self.controller_api_key: str = '' + self.controller_url: str = "" + self.controller_api_key: str = "" self.push_enabled: bool = False self.push_interval: int = 5 # Mode settings (all enabled by default) self.modes_enabled: dict[str, bool] = { - 'pager': True, - 'sensor': True, - 'adsb': True, - 'ais': True, - 'acars': True, - 'aprs': True, - 'wifi': True, - 'bluetooth': True, - 'dsc': True, - 'rtlamr': True, - 'tscm': True, - 'satellite': True, - 'listening_post': True, + "pager": True, + "sensor": True, + "adsb": True, + "ais": True, + "acars": True, + "aprs": True, + "wifi": True, + "bluetooth": True, + "dsc": True, + "rtlamr": True, + "tscm": True, + "satellite": True, + "listening_post": True, } def load_from_file(self, filepath: str) -> bool: @@ -118,34 +119,34 @@ class AgentConfig: parser.read(filepath) # Agent section - if parser.has_section('agent'): - if parser.has_option('agent', 'name'): - self.name = parser.get('agent', 'name') - if parser.has_option('agent', 'port'): - self.port = parser.getint('agent', 'port') - if parser.has_option('agent', 'allowed_ips'): - ips = parser.get('agent', 'allowed_ips') + if parser.has_section("agent"): + if parser.has_option("agent", "name"): + self.name = parser.get("agent", "name") + if parser.has_option("agent", "port"): + self.port = parser.getint("agent", "port") + if parser.has_option("agent", "allowed_ips"): + ips = parser.get("agent", "allowed_ips") if ips.strip(): - self.allowed_ips = [ip.strip() for ip in ips.split(',')] - if parser.has_option('agent', 'allow_cors'): - self.allow_cors = parser.getboolean('agent', 'allow_cors') + self.allowed_ips = [ip.strip() for ip in ips.split(",")] + if parser.has_option("agent", "allow_cors"): + self.allow_cors = parser.getboolean("agent", "allow_cors") # Controller section - if parser.has_section('controller'): - if parser.has_option('controller', 'url'): - self.controller_url = parser.get('controller', 'url').rstrip('/') - if parser.has_option('controller', 'api_key'): - self.controller_api_key = parser.get('controller', 'api_key') - if parser.has_option('controller', 'push_enabled'): - self.push_enabled = parser.getboolean('controller', 'push_enabled') - if parser.has_option('controller', 'push_interval'): - self.push_interval = parser.getint('controller', 'push_interval') + if parser.has_section("controller"): + if parser.has_option("controller", "url"): + self.controller_url = parser.get("controller", "url").rstrip("/") + if parser.has_option("controller", "api_key"): + self.controller_api_key = parser.get("controller", "api_key") + if parser.has_option("controller", "push_enabled"): + self.push_enabled = parser.getboolean("controller", "push_enabled") + if parser.has_option("controller", "push_interval"): + self.push_interval = parser.getint("controller", "push_interval") # Modes section - if parser.has_section('modes'): + if parser.has_section("modes"): for mode in self.modes_enabled: - if parser.has_option('modes', mode): - self.modes_enabled[mode] = parser.getboolean('modes', mode) + if parser.has_option("modes", mode): + self.modes_enabled[mode] = parser.getboolean("modes", mode) logger.info(f"Loaded configuration from {filepath}") return True @@ -157,14 +158,14 @@ class AgentConfig: def to_dict(self) -> dict: """Convert config to dictionary.""" return { - 'name': self.name, - 'port': self.port, - 'allowed_ips': self.allowed_ips, - 'allow_cors': self.allow_cors, - 'controller_url': self.controller_url, - 'push_enabled': self.push_enabled, - 'push_interval': self.push_interval, - 'modes_enabled': self.modes_enabled, + "name": self.name, + "port": self.port, + "allowed_ips": self.allowed_ips, + "allow_cors": self.allow_cors, + "controller_url": self.controller_url, + "push_enabled": self.push_enabled, + "push_interval": self.push_interval, + "modes_enabled": self.modes_enabled, } @@ -176,6 +177,7 @@ config = AgentConfig() # GPS Integration # ============================================================================= + class GPSManager: """Manages GPS position via gpsd.""" @@ -191,19 +193,20 @@ class GPSManager: with self._lock: if self._position: return { - 'lat': self._position.latitude, - 'lon': self._position.longitude, - 'altitude': self._position.altitude, - 'speed': self._position.speed, - 'heading': self._position.heading, - 'fix_quality': self._position.fix_quality, + "lat": self._position.latitude, + "lon": self._position.longitude, + "altitude": self._position.altitude, + "speed": self._position.speed, + "heading": self._position.heading, + "fix_quality": self._position.fix_quality, } return None - def start(self, host: str = 'localhost', port: int = 2947) -> bool: + def start(self, host: str = "localhost", port: int = 2947) -> bool: """Start GPS client connection to gpsd.""" try: from utils.gps import GPSDClient + self._client = GPSDClient(host, port) self._client.add_callback(self._on_position_update) success = self._client.start() @@ -243,6 +246,7 @@ gps_manager = GPSManager() # Controller Push Client # ============================================================================= + class ControllerPushClient(threading.Thread): """Daemon thread that pushes scan data to the controller.""" @@ -260,12 +264,12 @@ class ControllerPushClient(threading.Thread): return item = { - 'agent_name': self.cfg.name, - 'scan_type': scan_type, - 'interface': interface, - 'payload': payload, - 'received_at': datetime.now(timezone.utc).isoformat(), - 'attempts': 0, + "agent_name": self.cfg.name, + "scan_type": scan_type, + "interface": interface, + "payload": payload, + "received_at": datetime.now(timezone.utc).isoformat(), + "attempts": 0, } try: @@ -290,16 +294,16 @@ class ControllerPushClient(threading.Thread): continue endpoint = f"{self.cfg.controller_url}/controller/api/ingest" - headers = {'Content-Type': 'application/json'} + headers = {"Content-Type": "application/json"} if self.cfg.controller_api_key: - headers['X-API-Key'] = self.cfg.controller_api_key + headers["X-API-Key"] = self.cfg.controller_api_key body = { - 'agent_name': item['agent_name'], - 'scan_type': item['scan_type'], - 'interface': item['interface'], - 'payload': item['payload'], - 'received_at': item['received_at'], + "agent_name": item["agent_name"], + "scan_type": item["scan_type"], + "interface": item["interface"], + "payload": item["payload"], + "received_at": item["received_at"], } try: @@ -308,8 +312,8 @@ class ControllerPushClient(threading.Thread): raise RuntimeError(f"HTTP {response.status_code}") logger.debug(f"Pushed {item['scan_type']} data to controller") except Exception as e: - item['attempts'] += 1 - if item['attempts'] < 3 and not self.stop_event.is_set(): + item["attempts"] += 1 + if item["attempts"] < 3 and not self.stop_event.is_set(): with contextlib.suppress(queue.Full): self.queue.put_nowait(item) else: @@ -333,6 +337,7 @@ push_client: ControllerPushClient | None = None # Mode Manager - Uses Intercept's existing utilities and tools # ============================================================================= + class ModeManager: """ Manages mode state using Intercept's existing infrastructure. @@ -369,6 +374,7 @@ class ModeManager: if self._sdr_factory is None: try: from utils.sdr import SDRFactory + self._sdr_factory = SDRFactory except ImportError: logger.warning("SDRFactory not available - SDR features disabled") @@ -379,6 +385,7 @@ class ModeManager: if self._dependencies is None: try: from utils import dependencies + self._dependencies = dependencies except ImportError: logger.warning("Dependencies module not available") @@ -387,7 +394,7 @@ class ModeManager: def _check_tool(self, tool_name: str) -> bool: """Check if a tool is available using Intercept's dependency checker.""" deps = self._get_dependencies() - if deps and hasattr(deps, 'check_tool'): + if deps and hasattr(deps, "check_tool"): return deps.check_tool(tool_name) # Fallback to simple which check return shutil.which(tool_name) is not None @@ -395,7 +402,7 @@ class ModeManager: def _get_tool_path(self, tool_name: str) -> str | None: """Get tool path using Intercept's dependency module.""" deps = self._get_dependencies() - if deps and hasattr(deps, 'get_tool_path'): + if deps and hasattr(deps, "get_tool_path"): return deps.get_tool_path(tool_name) return shutil.which(tool_name) @@ -405,17 +412,17 @@ class ModeManager: return self._capabilities capabilities = { - 'modes': {}, - 'devices': [], - 'interfaces': { - 'wifi_interfaces': [], - 'bt_adapters': [], - 'sdr_devices': [], + "modes": {}, + "devices": [], + "interfaces": { + "wifi_interfaces": [], + "bt_adapters": [], + "sdr_devices": [], }, - 'agent_version': AGENT_VERSION, - 'gps': gps_manager.is_running, - 'gps_position': gps_manager.position, - 'tool_details': {}, # Detailed tool status + "agent_version": AGENT_VERSION, + "gps": gps_manager.is_running, + "gps_position": gps_manager.position, + "tool_details": {}, # Detailed tool status } # Detect interfaces using Intercept's TSCM device detection @@ -427,47 +434,45 @@ class ModeManager: dep_status = check_all_dependencies() # Map dependency status to mode availability mode_mapping = { - 'pager': 'pager', - 'sensor': 'sensor', - 'aircraft': 'adsb', - 'ais': 'ais', - 'acars': 'acars', - 'aprs': 'aprs', - 'wifi': 'wifi', - 'bluetooth': 'bluetooth', - 'tscm': 'tscm', - 'satellite': 'satellite', + "pager": "pager", + "sensor": "sensor", + "aircraft": "adsb", + "ais": "ais", + "acars": "acars", + "aprs": "aprs", + "wifi": "wifi", + "bluetooth": "bluetooth", + "tscm": "tscm", + "satellite": "satellite", } for dep_mode, cap_mode in mode_mapping.items(): if dep_mode in dep_status: mode_info = dep_status[dep_mode] # Check if mode is enabled in config if not config.modes_enabled.get(cap_mode, True): - capabilities['modes'][cap_mode] = False + capabilities["modes"][cap_mode] = False else: - capabilities['modes'][cap_mode] = mode_info['ready'] + capabilities["modes"][cap_mode] = mode_info["ready"] # Store detailed tool info - capabilities['tool_details'][cap_mode] = { - 'name': mode_info['name'], - 'ready': mode_info['ready'], - 'missing_required': mode_info['missing_required'], - 'tools': mode_info['tools'], + capabilities["tool_details"][cap_mode] = { + "name": mode_info["name"], + "ready": mode_info["ready"], + "missing_required": mode_info["missing_required"], + "tools": mode_info["tools"], } # Handle modes not in dependencies.py - extra_modes = ['dsc', 'rtlamr', 'listening_post'] + extra_modes = ["dsc", "rtlamr", "listening_post"] extra_tools = { - 'dsc': ['rtl_fm'], - 'rtlamr': ['rtlamr'], - 'listening_post': ['rtl_fm'], + "dsc": ["rtl_fm"], + "rtlamr": ["rtlamr"], + "listening_post": ["rtl_fm"], } for mode in extra_modes: if not config.modes_enabled.get(mode, True): - capabilities['modes'][mode] = False + capabilities["modes"][mode] = False else: tools = extra_tools.get(mode, []) - capabilities['modes'][mode] = all( - check_tool(tool) for tool in tools - ) if tools else True + capabilities["modes"][mode] = all(check_tool(tool) for tool in tools) if tools else True except Exception as e: logger.warning(f"Dependency check failed, using fallback: {e}") self._detect_capabilities_fallback(capabilities) @@ -484,12 +489,12 @@ class ModeManager: sdr_dict = sdr.to_dict() # Create friendly display name display_name = sdr.name - if sdr.serial and sdr.serial not in ('N/A', 'Unknown'): - display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})' - sdr_dict['display_name'] = display_name + if sdr.serial and sdr.serial not in ("N/A", "Unknown"): + display_name = f"{sdr.name} (SN: {sdr.serial[-8:]})" + sdr_dict["display_name"] = display_name sdr_list.append(sdr_dict) - capabilities['devices'] = sdr_list - capabilities['interfaces']['sdr_devices'] = sdr_list + capabilities["devices"] = sdr_list + capabilities["interfaces"]["sdr_devices"] = sdr_list except Exception as e: logger.warning(f"SDR device detection failed: {e}") @@ -500,201 +505,192 @@ class ModeManager: """Detect WiFi interfaces and Bluetooth adapters.""" import platform - interfaces = capabilities.get('interfaces', {}) + interfaces = capabilities.get("interfaces", {}) # Detect WiFi interfaces - if platform.system() == 'Darwin': # macOS + if platform.system() == "Darwin": # macOS try: result = subprocess.run( - ['networksetup', '-listallhardwareports'], - capture_output=True, text=True, timeout=5 + ["networksetup", "-listallhardwareports"], capture_output=True, text=True, timeout=5 ) - lines = result.stdout.split('\n') + lines = result.stdout.split("\n") for i, line in enumerate(lines): - if 'Wi-Fi' in line or 'AirPort' in line: - port_name = line.replace('Hardware Port:', '').strip() + if "Wi-Fi" in line or "AirPort" in line: + port_name = line.replace("Hardware Port:", "").strip() for j in range(i + 1, min(i + 3, len(lines))): - if 'Device:' in lines[j]: - device = lines[j].split('Device:')[1].strip() - interfaces['wifi_interfaces'].append({ - 'name': device, - 'display_name': f'{port_name} ({device})', - 'type': 'internal', - 'monitor_capable': False - }) + if "Device:" in lines[j]: + device = lines[j].split("Device:")[1].strip() + interfaces["wifi_interfaces"].append( + { + "name": device, + "display_name": f"{port_name} ({device})", + "type": "internal", + "monitor_capable": False, + } + ) break except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): pass else: # Linux try: - result = subprocess.run( - ['iw', 'dev'], - capture_output=True, text=True, timeout=5 - ) + result = subprocess.run(["iw", "dev"], capture_output=True, text=True, timeout=5) current_iface = None - for line in result.stdout.split('\n'): + for line in result.stdout.split("\n"): line = line.strip() - if line.startswith('Interface'): + if line.startswith("Interface"): current_iface = line.split()[1] - elif current_iface and 'type' in line: + elif current_iface and "type" in line: iface_type = line.split()[-1] - interfaces['wifi_interfaces'].append({ - 'name': current_iface, - 'display_name': f'Wireless ({current_iface}) - {iface_type}', - 'type': iface_type, - 'monitor_capable': True - }) + interfaces["wifi_interfaces"].append( + { + "name": current_iface, + "display_name": f"Wireless ({current_iface}) - {iface_type}", + "type": iface_type, + "monitor_capable": True, + } + ) current_iface = None except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): # Fall back to iwconfig try: - result = subprocess.run( - ['iwconfig'], - capture_output=True, text=True, timeout=5 - ) - for line in result.stdout.split('\n'): - if 'IEEE 802.11' in line: + result = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=5) + for line in result.stdout.split("\n"): + if "IEEE 802.11" in line: iface = line.split()[0] - interfaces['wifi_interfaces'].append({ - 'name': iface, - 'display_name': f'Wireless ({iface})', - 'type': 'managed', - 'monitor_capable': True - }) + interfaces["wifi_interfaces"].append( + { + "name": iface, + "display_name": f"Wireless ({iface})", + "type": "managed", + "monitor_capable": True, + } + ) except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): pass # Detect Bluetooth adapters - if platform.system() == 'Linux': + if platform.system() == "Linux": try: - result = subprocess.run( - ['hciconfig'], - capture_output=True, text=True, timeout=5 - ) - blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE) + result = subprocess.run(["hciconfig"], capture_output=True, text=True, timeout=5) + blocks = re.split(r"(?=^hci\d+:)", result.stdout, flags=re.MULTILINE) for block in blocks: if block.strip(): - first_line = block.split('\n')[0] - match = re.match(r'(hci\d+):', first_line) + first_line = block.split("\n")[0] + match = re.match(r"(hci\d+):", first_line) if match: iface_name = match.group(1) - is_up = 'UP RUNNING' in block or '\tUP ' in block - interfaces['bt_adapters'].append({ - 'name': iface_name, - 'display_name': f'Bluetooth Adapter ({iface_name})', - 'type': 'hci', - 'status': 'up' if is_up else 'down' - }) + is_up = "UP RUNNING" in block or "\tUP " in block + interfaces["bt_adapters"].append( + { + "name": iface_name, + "display_name": f"Bluetooth Adapter ({iface_name})", + "type": "hci", + "status": "up" if is_up else "down", + } + ) except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): # Try bluetoothctl as fallback try: - result = subprocess.run( - ['bluetoothctl', 'list'], - capture_output=True, text=True, timeout=5 - ) - for line in result.stdout.split('\n'): - if 'Controller' in line: + result = subprocess.run(["bluetoothctl", "list"], capture_output=True, text=True, timeout=5) + for line in result.stdout.split("\n"): + if "Controller" in line: parts = line.split() if len(parts) >= 3: addr = parts[1] - name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth' - interfaces['bt_adapters'].append({ - 'name': addr, - 'display_name': f'{name} ({addr[-8:]})', - 'type': 'controller', - 'status': 'available' - }) + name = " ".join(parts[2:]) if len(parts) > 2 else "Bluetooth" + interfaces["bt_adapters"].append( + { + "name": addr, + "display_name": f"{name} ({addr[-8:]})", + "type": "controller", + "status": "available", + } + ) except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): pass - elif platform.system() == 'Darwin': + elif platform.system() == "Darwin": try: result = subprocess.run( - ['system_profiler', 'SPBluetoothDataType'], - capture_output=True, text=True, timeout=10 + ["system_profiler", "SPBluetoothDataType"], capture_output=True, text=True, timeout=10 ) - bt_name = 'Built-in Bluetooth' - bt_addr = '' - for line in result.stdout.split('\n'): - if 'Address:' in line: - bt_addr = line.split('Address:')[1].strip() + bt_name = "Built-in Bluetooth" + bt_addr = "" + for line in result.stdout.split("\n"): + if "Address:" in line: + bt_addr = line.split("Address:")[1].strip() break - interfaces['bt_adapters'].append({ - 'name': 'default', - 'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''), - 'type': 'macos', - 'status': 'available' - }) + interfaces["bt_adapters"].append( + { + "name": "default", + "display_name": f"{bt_name}" + (f" ({bt_addr[-8:]})" if bt_addr else ""), + "type": "macos", + "status": "available", + } + ) except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): - interfaces['bt_adapters'].append({ - 'name': 'default', - 'display_name': 'Built-in Bluetooth', - 'type': 'macos', - 'status': 'available' - }) + interfaces["bt_adapters"].append( + {"name": "default", "display_name": "Built-in Bluetooth", "type": "macos", "status": "available"} + ) def _detect_capabilities_fallback(self, capabilities: dict): """Fallback capability detection when dependencies module unavailable.""" tool_checks = { - 'pager': ['rtl_fm', 'multimon-ng'], - 'sensor': ['rtl_433'], - 'adsb': ['dump1090'], - 'ais': ['AIS-catcher'], - 'acars': ['acarsdec'], - 'aprs': ['rtl_fm', 'direwolf'], - 'wifi': ['airmon-ng', 'airodump-ng'], - 'bluetooth': ['bluetoothctl'], - 'dsc': ['rtl_fm'], - 'rtlamr': ['rtlamr'], - 'satellite': [], - 'listening_post': ['rtl_fm'], - 'tscm': ['rtl_fm'], + "pager": ["rtl_fm", "multimon-ng"], + "sensor": ["rtl_433"], + "adsb": ["dump1090"], + "ais": ["AIS-catcher"], + "acars": ["acarsdec"], + "aprs": ["rtl_fm", "direwolf"], + "wifi": ["airmon-ng", "airodump-ng"], + "bluetooth": ["bluetoothctl"], + "dsc": ["rtl_fm"], + "rtlamr": ["rtlamr"], + "satellite": [], + "listening_post": ["rtl_fm"], + "tscm": ["rtl_fm"], } for mode, tools in tool_checks.items(): if not config.modes_enabled.get(mode, True): - capabilities['modes'][mode] = False + capabilities["modes"][mode] = False continue if not tools: - capabilities['modes'][mode] = True + capabilities["modes"][mode] = True continue - if mode == 'adsb': - capabilities['modes'][mode] = ( - self._check_tool('dump1090') or - self._check_tool('dump1090-fa') or - self._check_tool('readsb') + if mode == "adsb": + capabilities["modes"][mode] = ( + self._check_tool("dump1090") or self._check_tool("dump1090-fa") or self._check_tool("readsb") ) else: - capabilities['modes'][mode] = all( - self._check_tool(tool) for tool in tools - ) + capabilities["modes"][mode] = all(self._check_tool(tool) for tool in tools) def get_status(self) -> dict: """Get overall agent status.""" # Build running modes with device info for multi-SDR tracking running_modes_detail = {} for mode, info in self.running_modes.items(): - params = info.get('params', {}) + params = info.get("params", {}) running_modes_detail[mode] = { - 'started_at': info.get('started_at'), - 'device': params.get('device', params.get('device_index', 0)), + "started_at": info.get("started_at"), + "device": params.get("device", params.get("device_index", 0)), } status = { - 'running_modes': list(self.running_modes.keys()), - 'running_modes_detail': running_modes_detail, # Include device info per mode - 'uptime': time.time() - _start_time, - 'push_enabled': config.push_enabled, - 'push_connected': push_client is not None and push_client.running, - 'gps': gps_manager.is_running, + "running_modes": list(self.running_modes.keys()), + "running_modes_detail": running_modes_detail, # Include device info per mode + "uptime": time.time() - _start_time, + "push_enabled": config.push_enabled, + "push_connected": push_client is not None and push_client.running, + "gps": gps_manager.is_running, } # Include GPS position if available gps_pos = gps_manager.position if gps_pos: - status['gps_position'] = gps_pos + status["gps_position"] = gps_pos return status # Modes that use RTL-SDR devices - SDR_MODES = {'adsb', 'sensor', 'pager', 'ais', 'acars', 'dsc', 'rtlamr', 'listening_post'} + SDR_MODES = {"adsb", "sensor", "pager", "ais", "acars", "dsc", "rtlamr", "listening_post"} def get_sdr_in_use(self, device: int = 0) -> str | None: """Check if an SDR device is in use by another mode. @@ -703,7 +699,7 @@ class ModeManager: """ for mode, info in self.running_modes.items(): if mode in self.SDR_MODES: - mode_device = info.get('params', {}).get('device', 0) + mode_device = info.get("params", {}).get("device", 0) # Normalize to int for comparison try: mode_device = int(mode_device) @@ -716,15 +712,15 @@ class ModeManager: def start_mode(self, mode: str, params: dict) -> dict: """Start a mode with given parameters.""" if mode in self.running_modes: - return {'status': 'error', 'message': f'{mode} already running'} + return {"status": "error", "message": f"{mode} already running"} caps = self.detect_capabilities() - if not caps['modes'].get(mode, False): - return {'status': 'error', 'message': f'{mode} not available (missing tools)'} + if not caps["modes"].get(mode, False): + return {"status": "error", "message": f"{mode} not available (missing tools)"} # Check SDR device conflicts for SDR-based modes if mode in self.SDR_MODES: - device = params.get('device', 0) + device = params.get("device", 0) try: device = int(device) except (ValueError, TypeError): @@ -732,8 +728,8 @@ class ModeManager: in_use_by = self.get_sdr_in_use(device) if in_use_by: return { - 'status': 'error', - 'message': f'SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' + "status": "error", + "message": f"SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.", } # Initialize lock if needed @@ -744,20 +740,20 @@ class ModeManager: try: # Mode-specific start logic result = self._start_mode_internal(mode, params) - if result.get('status') == 'started': + if result.get("status") == "started": self.running_modes[mode] = { - 'started_at': datetime.now(timezone.utc).isoformat(), - 'params': params, + "started_at": datetime.now(timezone.utc).isoformat(), + "params": params, } return result except Exception as e: logger.exception(f"Error starting {mode}") - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def stop_mode(self, mode: str) -> dict: """Stop a running mode.""" if mode not in self.running_modes: - return {'status': 'not_running'} + return {"status": "not_running"} if mode not in self.locks: self.locks[mode] = threading.Lock() @@ -770,102 +766,99 @@ class ModeManager: return result except Exception as e: logger.exception(f"Error stopping {mode}") - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def get_mode_status(self, mode: str) -> dict: """Get status of a specific mode.""" if mode in self.running_modes: - info = { - 'running': True, - **self.running_modes[mode] - } + info = {"running": True, **self.running_modes[mode]} # Add mode-specific stats - if mode == 'adsb': - info['aircraft_count'] = len(self.adsb_aircraft) - elif mode == 'wifi': - info['network_count'] = len(self.wifi_networks) - info['client_count'] = len(self.wifi_clients) - elif mode == 'bluetooth': - info['device_count'] = len(self.bluetooth_devices) - elif mode == 'sensor': - info['reading_count'] = len(self.data_snapshots.get(mode, [])) - elif mode == 'ais': - info['vessel_count'] = len(getattr(self, 'ais_vessels', {})) - elif mode == 'aprs': - info['station_count'] = len(getattr(self, 'aprs_stations', {})) - elif mode == 'pager' or mode == 'acars': - info['message_count'] = len(self.data_snapshots.get(mode, [])) - elif mode == 'rtlamr': - info['reading_count'] = len(self.data_snapshots.get(mode, [])) - elif mode == 'tscm': - info['anomaly_count'] = len(getattr(self, 'tscm_anomalies', [])) - elif mode == 'satellite': - info['pass_count'] = len(self.data_snapshots.get(mode, [])) - elif mode == 'listening_post': - info['signal_count'] = len(getattr(self, 'listening_post_activity', [])) - info['current_freq'] = getattr(self, 'listening_post_current_freq', 0) - info['freqs_scanned'] = getattr(self, 'listening_post_freqs_scanned', 0) + if mode == "adsb": + info["aircraft_count"] = len(self.adsb_aircraft) + elif mode == "wifi": + info["network_count"] = len(self.wifi_networks) + info["client_count"] = len(self.wifi_clients) + elif mode == "bluetooth": + info["device_count"] = len(self.bluetooth_devices) + elif mode == "sensor": + info["reading_count"] = len(self.data_snapshots.get(mode, [])) + elif mode == "ais": + info["vessel_count"] = len(getattr(self, "ais_vessels", {})) + elif mode == "aprs": + info["station_count"] = len(getattr(self, "aprs_stations", {})) + elif mode == "pager" or mode == "acars": + info["message_count"] = len(self.data_snapshots.get(mode, [])) + elif mode == "rtlamr": + info["reading_count"] = len(self.data_snapshots.get(mode, [])) + elif mode == "tscm": + info["anomaly_count"] = len(getattr(self, "tscm_anomalies", [])) + elif mode == "satellite": + info["pass_count"] = len(self.data_snapshots.get(mode, [])) + elif mode == "listening_post": + info["signal_count"] = len(getattr(self, "listening_post_activity", [])) + info["current_freq"] = getattr(self, "listening_post_current_freq", 0) + info["freqs_scanned"] = getattr(self, "listening_post_freqs_scanned", 0) return info - return {'running': False} + return {"running": False} def get_mode_data(self, mode: str) -> dict: """Get current data snapshot for a mode.""" data = { - 'mode': mode, - 'timestamp': datetime.now(timezone.utc).isoformat(), + "mode": mode, + "timestamp": datetime.now(timezone.utc).isoformat(), } # Add GPS position gps_pos = gps_manager.position if gps_pos: - data['agent_gps'] = gps_pos + data["agent_gps"] = gps_pos # Mode-specific data - if mode == 'adsb': - data['data'] = list(self.adsb_aircraft.values()) - elif mode == 'wifi': - data['data'] = { - 'networks': list(self.wifi_networks.values()), - 'clients': list(self.wifi_clients.values()), + if mode == "adsb": + data["data"] = list(self.adsb_aircraft.values()) + elif mode == "wifi": + data["data"] = { + "networks": list(self.wifi_networks.values()), + "clients": list(self.wifi_clients.values()), } - elif mode == 'bluetooth': - data['data'] = list(self.bluetooth_devices.values()) - elif mode == 'ais': - data['data'] = list(getattr(self, 'ais_vessels', {}).values()) - elif mode == 'aprs': - data['data'] = list(getattr(self, 'aprs_stations', {}).values()) - elif mode == 'tscm': - data['data'] = { - 'anomalies': getattr(self, 'tscm_anomalies', []), - 'baseline': getattr(self, 'tscm_baseline', {}), - 'wifi_devices': list(self.wifi_networks.values()), - 'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()), - 'bt_devices': list(self.bluetooth_devices.values()), - 'rf_signals': getattr(self, 'tscm_rf_signals', []), + elif mode == "bluetooth": + data["data"] = list(self.bluetooth_devices.values()) + elif mode == "ais": + data["data"] = list(getattr(self, "ais_vessels", {}).values()) + elif mode == "aprs": + data["data"] = list(getattr(self, "aprs_stations", {}).values()) + elif mode == "tscm": + data["data"] = { + "anomalies": getattr(self, "tscm_anomalies", []), + "baseline": getattr(self, "tscm_baseline", {}), + "wifi_devices": list(self.wifi_networks.values()), + "wifi_clients": list(getattr(self, "tscm_wifi_clients", {}).values()), + "bt_devices": list(self.bluetooth_devices.values()), + "rf_signals": getattr(self, "tscm_rf_signals", []), } - elif mode == 'listening_post': - data['data'] = { - 'activity': getattr(self, 'listening_post_activity', []), - 'current_freq': getattr(self, 'listening_post_current_freq', 0), - 'freqs_scanned': getattr(self, 'listening_post_freqs_scanned', 0), - 'signal_count': len(getattr(self, 'listening_post_activity', [])), + elif mode == "listening_post": + data["data"] = { + "activity": getattr(self, "listening_post_activity", []), + "current_freq": getattr(self, "listening_post_current_freq", 0), + "freqs_scanned": getattr(self, "listening_post_freqs_scanned", 0), + "signal_count": len(getattr(self, "listening_post_activity", [])), } - elif mode == 'pager': + elif mode == "pager": # Return recent pager messages messages = self.data_snapshots.get(mode, []) - data['data'] = { - 'messages': messages[-50:] if len(messages) > 50 else messages, - 'total_count': len(messages), + data["data"] = { + "messages": messages[-50:] if len(messages) > 50 else messages, + "total_count": len(messages), } - elif mode == 'dsc': + elif mode == "dsc": # Return DSC messages - messages = getattr(self, 'dsc_messages', []) - data['data'] = { - 'messages': messages[-50:] if len(messages) > 50 else messages, - 'total_count': len(messages), + messages = getattr(self, "dsc_messages", []) + data["data"] = { + "messages": messages[-50:] if len(messages) > 50 else messages, + "total_count": len(messages), } else: - data['data'] = self.data_snapshots.get(mode, []) + data["data"] = self.data_snapshots.get(mode, []) return data @@ -877,26 +870,26 @@ class ModeManager: """Enable or disable monitor mode on a WiFi interface.""" import re - action = params.get('action', 'start') - interface = params.get('interface', '') - kill_processes = params.get('kill_processes', False) + action = params.get("action", "start") + interface = params.get("interface", "") + kill_processes = params.get("kill_processes", False) # Validate interface name (alphanumeric, underscore, dash only) - if not interface or not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', interface): - return {'status': 'error', 'message': 'Invalid interface name'} + if not interface or not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", interface): + return {"status": "error", "message": "Invalid interface name"} - airmon_path = self._get_tool_path('airmon-ng') - iw_path = self._get_tool_path('iw') + airmon_path = self._get_tool_path("airmon-ng") + iw_path = self._get_tool_path("iw") - if action == 'start': + if action == "start": if airmon_path: try: # Get interfaces before def get_wireless_interfaces(): interfaces = set() try: - for iface in os.listdir('/sys/class/net'): - if os.path.exists(f'/sys/class/net/{iface}/wireless') or 'mon' in iface: + for iface in os.listdir("/sys/class/net"): + if os.path.exists(f"/sys/class/net/{iface}/wireless") or "mon" in iface: interfaces.add(iface) except OSError: pass @@ -906,12 +899,12 @@ class ModeManager: # Kill interfering processes if requested if kill_processes: - subprocess.run([airmon_path, 'check', 'kill'], - capture_output=True, timeout=10) + subprocess.run([airmon_path, "check", "kill"], capture_output=True, timeout=10) # Start monitor mode - result = subprocess.run([airmon_path, 'start', interface], - capture_output=True, text=True, timeout=15) + result = subprocess.run( + [airmon_path, "start", interface], capture_output=True, text=True, timeout=15 + ) output = result.stdout + result.stderr time.sleep(1) @@ -923,7 +916,7 @@ class ModeManager: if new_interfaces: for iface in new_interfaces: - if 'mon' in iface: + if "mon" in iface: monitor_iface = iface break if not monitor_iface: @@ -932,9 +925,9 @@ class ModeManager: # Try to parse from airmon-ng output if not monitor_iface: patterns = [ - r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b', - r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)', - r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)', + r"\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b", + r"\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)", + r"enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)", ] for pattern in patterns: match = re.search(pattern, output, re.IGNORECASE) @@ -947,71 +940,69 @@ class ModeManager: # Fallback: check if original interface is in monitor mode if not monitor_iface: try: - result = subprocess.run(['iwconfig', interface], - capture_output=True, text=True, timeout=5) - if 'Mode:Monitor' in result.stdout: + result = subprocess.run(["iwconfig", interface], capture_output=True, text=True, timeout=5) + if "Mode:Monitor" in result.stdout: monitor_iface = interface except (subprocess.SubprocessError, OSError): pass # Last resort: try common naming if not monitor_iface: - potential = interface + 'mon' - if os.path.exists(f'/sys/class/net/{potential}'): + potential = interface + "mon" + if os.path.exists(f"/sys/class/net/{potential}"): monitor_iface = potential - if not monitor_iface or not os.path.exists(f'/sys/class/net/{monitor_iface}'): + if not monitor_iface or not os.path.exists(f"/sys/class/net/{monitor_iface}"): all_wireless = list(get_wireless_interfaces()) return { - 'status': 'error', - 'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}' + "status": "error", + "message": f"Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}", } self.wifi_monitor_interface = monitor_iface self._capabilities = None # Invalidate cache so interfaces refresh logger.info(f"Monitor mode enabled on {monitor_iface}") - return {'status': 'success', 'monitor_interface': monitor_iface} + return {"status": "success", "monitor_interface": monitor_iface} except Exception as e: logger.error(f"Error enabling monitor mode: {e}") - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} elif iw_path: try: - subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True) - subprocess.run([iw_path, interface, 'set', 'monitor', 'control'], capture_output=True) - subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True) + subprocess.run(["ip", "link", "set", interface, "down"], capture_output=True) + subprocess.run([iw_path, interface, "set", "monitor", "control"], capture_output=True) + subprocess.run(["ip", "link", "set", interface, "up"], capture_output=True) self.wifi_monitor_interface = interface self._capabilities = None # Invalidate cache - return {'status': 'success', 'monitor_interface': interface} + return {"status": "success", "monitor_interface": interface} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} else: - return {'status': 'error', 'message': 'No monitor mode tools available (airmon-ng or iw)'} + return {"status": "error", "message": "No monitor mode tools available (airmon-ng or iw)"} else: # stop - current_iface = getattr(self, 'wifi_monitor_interface', None) or interface + current_iface = getattr(self, "wifi_monitor_interface", None) or interface if airmon_path: try: - subprocess.run([airmon_path, 'stop', current_iface], - capture_output=True, text=True, timeout=15) + subprocess.run([airmon_path, "stop", current_iface], capture_output=True, text=True, timeout=15) self.wifi_monitor_interface = None self._capabilities = None # Invalidate cache - return {'status': 'success', 'message': 'Monitor mode disabled'} + return {"status": "success", "message": "Monitor mode disabled"} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} elif iw_path: try: - subprocess.run(['ip', 'link', 'set', current_iface, 'down'], capture_output=True) - subprocess.run([iw_path, current_iface, 'set', 'type', 'managed'], capture_output=True) - subprocess.run(['ip', 'link', 'set', current_iface, 'up'], capture_output=True) + subprocess.run(["ip", "link", "set", current_iface, "down"], capture_output=True) + subprocess.run([iw_path, current_iface, "set", "type", "managed"], capture_output=True) + subprocess.run(["ip", "link", "set", current_iface, "up"], capture_output=True) self.wifi_monitor_interface = None self._capabilities = None # Invalidate cache - return {'status': 'success', 'message': 'Monitor mode disabled'} + return {"status": "success", "message": "Monitor mode disabled"} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} - return {'status': 'error', 'message': 'Unknown action'} + return {"status": "error", "message": "Unknown action"} # ========================================================================= # Mode-specific implementations @@ -1028,19 +1019,19 @@ class ModeManager: # Dispatch to mode-specific handler handlers = { - 'sensor': self._start_sensor, - 'adsb': self._start_adsb, - 'wifi': self._start_wifi, - 'bluetooth': self._start_bluetooth, - 'pager': self._start_pager, - 'ais': self._start_ais, - 'acars': self._start_acars, - 'aprs': self._start_aprs, - 'rtlamr': self._start_rtlamr, - 'dsc': self._start_dsc, - 'tscm': self._start_tscm, - 'satellite': self._start_satellite, - 'listening_post': self._start_listening_post, + "sensor": self._start_sensor, + "adsb": self._start_adsb, + "wifi": self._start_wifi, + "bluetooth": self._start_bluetooth, + "pager": self._start_pager, + "ais": self._start_ais, + "acars": self._start_acars, + "aprs": self._start_aprs, + "rtlamr": self._start_rtlamr, + "dsc": self._start_dsc, + "tscm": self._start_tscm, + "satellite": self._start_satellite, + "listening_post": self._start_listening_post, } handler = handlers.get(mode) @@ -1049,7 +1040,7 @@ class ModeManager: # Unknown mode logger.warning(f"Unknown mode: {mode}") - return {'status': 'error', 'message': f'Unknown mode: {mode}'} + return {"status": "error", "message": f"Unknown mode: {mode}"} def _stop_mode_internal(self, mode: str) -> dict: """Internal mode stop - terminates processes and cleans up.""" @@ -1092,16 +1083,16 @@ class ModeManager: del self.data_snapshots[mode] # Mode-specific cleanup - if mode == 'adsb': + if mode == "adsb": self.adsb_aircraft.clear() - elif mode == 'wifi': + elif mode == "wifi": self.wifi_networks.clear() self.wifi_clients.clear() - elif mode == 'bluetooth': + elif mode == "bluetooth": self.bluetooth_devices.clear() - elif mode == 'tscm': + elif mode == "tscm": # Clean up TSCM sub-threads - for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']: + for sub_thread_name in ["tscm_wifi", "tscm_bt", "tscm_rf"]: if sub_thread_name in self.output_threads: thread = self.output_threads[sub_thread_name] if thread and thread.is_alive(): @@ -1113,53 +1104,53 @@ class ModeManager: self.tscm_rf_signals = [] self.tscm_wifi_clients = {} # Clear reported threat tracking sets - if hasattr(self, '_tscm_reported_wifi'): + if hasattr(self, "_tscm_reported_wifi"): self._tscm_reported_wifi.clear() - if hasattr(self, '_tscm_reported_bt'): + if hasattr(self, "_tscm_reported_bt"): self._tscm_reported_bt.clear() - elif mode == 'dsc': + elif mode == "dsc": # Clear DSC data - if hasattr(self, 'dsc_messages'): + if hasattr(self, "dsc_messages"): self.dsc_messages = [] - elif mode == 'pager': + elif mode == "pager": # Pager uses two processes: multimon-ng (pager) and rtl_fm (pager_rtl) # Kill the rtl_fm process as well - if 'pager_rtl' in self.processes: - rtl_proc = self.processes['pager_rtl'] + if "pager_rtl" in self.processes: + rtl_proc = self.processes["pager_rtl"] if rtl_proc and rtl_proc.poll() is None: rtl_proc.terminate() try: rtl_proc.wait(timeout=3) except subprocess.TimeoutExpired: rtl_proc.kill() - del self.processes['pager_rtl'] + del self.processes["pager_rtl"] # Clear pager data - if hasattr(self, 'pager_messages'): + if hasattr(self, "pager_messages"): self.pager_messages = [] - elif mode == 'aprs': + elif mode == "aprs": # APRS uses two processes: decoder (aprs) and rtl_fm (aprs_rtl) - if 'aprs_rtl' in self.processes: - rtl_proc = self.processes['aprs_rtl'] + if "aprs_rtl" in self.processes: + rtl_proc = self.processes["aprs_rtl"] if rtl_proc and rtl_proc.poll() is None: rtl_proc.terminate() try: rtl_proc.wait(timeout=3) except subprocess.TimeoutExpired: rtl_proc.kill() - del self.processes['aprs_rtl'] - elif mode == 'rtlamr': + del self.processes["aprs_rtl"] + elif mode == "rtlamr": # RTLAMR uses two processes: rtlamr and rtl_tcp (rtlamr_tcp) - if 'rtlamr_tcp' in self.processes: - tcp_proc = self.processes['rtlamr_tcp'] + if "rtlamr_tcp" in self.processes: + tcp_proc = self.processes["rtlamr_tcp"] if tcp_proc and tcp_proc.poll() is None: tcp_proc.terminate() try: tcp_proc.wait(timeout=3) except subprocess.TimeoutExpired: tcp_proc.kill() - del self.processes['rtlamr_tcp'] + del self.processes["rtlamr_tcp"] - return {'status': 'stopped', 'mode': mode} + return {"status": "stopped", "mode": mode} # ------------------------------------------------------------------------- # SENSOR MODE (rtl_433) - Uses Intercept's SDR abstraction @@ -1167,18 +1158,19 @@ class ModeManager: def _start_sensor(self, params: dict) -> dict: """Start rtl_433 sensor mode using Intercept's SDR utilities.""" - freq = params.get('frequency', '433.92') - gain = params.get('gain') - device = params.get('device', '0') - ppm = params.get('ppm') - bias_t = params.get('bias_t', False) - sdr_type_str = params.get('sdr_type', 'rtlsdr') + freq = params.get("frequency", "433.92") + gain = params.get("gain") + device = params.get("device", "0") + ppm = params.get("ppm") + bias_t = params.get("bias_t", False) + sdr_type_str = params.get("sdr_type", "rtlsdr") # Try to use Intercept's SDR abstraction layer sdr_factory = self._get_sdr_factory() if sdr_factory: try: from utils.sdr import SDRType + sdr_type = SDRType(sdr_type_str) sdr_device = sdr_factory.create_default_device(sdr_type, index=int(device)) builder = sdr_factory.get_builder(sdr_type) @@ -1189,7 +1181,7 @@ class ModeManager: frequency_mhz=float(freq), gain=float(gain) if gain else None, ppm=int(ppm) if ppm else None, - bias_t=bias_t + bias_t=bias_t, ) logger.info(f"Starting sensor (via SDR abstraction): {' '.join(cmd)}") @@ -1206,72 +1198,68 @@ class ModeManager: stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - self.processes['sensor'] = proc + self.processes["sensor"] = proc # Wait briefly to verify process started successfully time.sleep(0.5) if proc.poll() is not None: - stderr_output = proc.stderr.read().decode('utf-8', errors='replace') - del self.processes['sensor'] - return {'status': 'error', 'message': f'rtl_433 failed to start: {stderr_output[:200]}'} + stderr_output = proc.stderr.read().decode("utf-8", errors="replace") + del self.processes["sensor"] + return {"status": "error", "message": f"rtl_433 failed to start: {stderr_output[:200]}"} # Start output reader thread - thread = threading.Thread( - target=self._sensor_output_reader, - args=(proc,), - daemon=True - ) + thread = threading.Thread(target=self._sensor_output_reader, args=(proc,), daemon=True) thread.start() - self.output_threads['sensor'] = thread + self.output_threads["sensor"] = thread return { - 'status': 'started', - 'mode': 'sensor', - 'command': ' '.join(cmd), - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "sensor", + "command": " ".join(cmd), + "gps_enabled": gps_manager.is_running, } except FileNotFoundError: - return {'status': 'error', 'message': 'rtl_433 not found. Install via: apt install rtl-433'} + return {"status": "error", "message": "rtl_433 not found. Install via: apt install rtl-433"} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _build_sensor_command_fallback(self, freq, gain, device, ppm) -> list: """Build rtl_433 command without SDR abstraction.""" - cmd = ['rtl_433', '-F', 'json'] + cmd = ["rtl_433", "-F", "json"] if freq: - cmd.extend(['-f', f'{freq}M']) - if gain and str(gain) != '0': - cmd.extend(['-g', str(gain)]) - if device and str(device) != '0': - cmd.extend(['-d', str(device)]) - if ppm and str(ppm) != '0': - cmd.extend(['-p', str(ppm)]) + cmd.extend(["-f", f"{freq}M"]) + if gain and str(gain) != "0": + cmd.extend(["-g", str(gain)]) + if device and str(device) != "0": + cmd.extend(["-d", str(device)]) + if ppm and str(ppm) != "0": + cmd.extend(["-p", str(ppm)]) return cmd def _sensor_output_reader(self, proc: subprocess.Popen): """Read rtl_433 JSON output and collect data.""" - mode = 'sensor' + mode = "sensor" stop_event = self.stop_events.get(mode) try: - for line in iter(proc.stdout.readline, b''): + for line in iter(proc.stdout.readline, b""): if stop_event and stop_event.is_set(): break - line = line.decode('utf-8', errors='replace').strip() + line = line.decode("utf-8", errors="replace").strip() if not line: continue try: data = json.loads(line) - data['type'] = 'sensor' - data['received_at'] = datetime.now(timezone.utc).isoformat() + data["type"] = "sensor" + data["received_at"] = datetime.now(timezone.utc).isoformat() # Add GPS if available gps_pos = gps_manager.position if gps_pos: - data['agent_gps'] = gps_pos + data["agent_gps"] = gps_pos # Store in snapshot (keep last 100) snapshots = self.data_snapshots.get(mode, []) @@ -1301,12 +1289,12 @@ class ModeManager: def _start_adsb(self, params: dict) -> dict: """Start dump1090 ADS-B mode using Intercept's utilities.""" - gain = params.get('gain', '40') - device = params.get('device', '0') - bias_t = params.get('bias_t', False) - sdr_type_str = params.get('sdr_type', 'rtlsdr') - remote_sbs_host = params.get('remote_sbs_host') - remote_sbs_port = params.get('remote_sbs_port', 30003) + gain = params.get("gain", "40") + device = params.get("device", "0") + bias_t = params.get("bias_t", False) + sdr_type_str = params.get("sdr_type", "rtlsdr") + remote_sbs_host = params.get("remote_sbs_host") + remote_sbs_port = params.get("remote_sbs_port", 30003) # If remote SBS host provided, just connect to it if remote_sbs_host: @@ -1316,11 +1304,11 @@ class ModeManager: try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(1.0) - result = sock.connect_ex(('localhost', 30003)) + result = sock.connect_ex(("localhost", 30003)) sock.close() if result == 0: logger.info("dump1090 already running, connecting to SBS port") - return self._start_adsb_sbs_connection('localhost', 30003) + return self._start_adsb_sbs_connection("localhost", 30003) except Exception: pass @@ -1331,16 +1319,13 @@ class ModeManager: if sdr_factory: try: from utils.sdr import SDRType + sdr_type = SDRType(sdr_type_str) sdr_device = sdr_factory.create_default_device(sdr_type, index=int(device)) builder = sdr_factory.get_builder(sdr_type) # Use the builder to construct dump1090 command - cmd = builder.build_adsb_command( - device=sdr_device, - gain=float(gain) if gain else None, - bias_t=bias_t - ) + cmd = builder.build_adsb_command(device=sdr_device, gain=float(gain) if gain else None, bias_t=bias_t) logger.info(f"Starting ADS-B (via SDR abstraction): {' '.join(cmd)}") except Exception as e: @@ -1350,56 +1335,51 @@ class ModeManager: # Fallback: find dump1090 manually and build command dump1090_path = self._find_dump1090() if not dump1090_path: - return {'status': 'error', 'message': 'dump1090 not found. Install via: apt install dump1090-fa'} + return {"status": "error", "message": "dump1090 not found. Install via: apt install dump1090-fa"} - cmd = [dump1090_path, '--net', '--quiet'] + cmd = [dump1090_path, "--net", "--quiet"] if gain: - cmd.extend(['--gain', str(gain)]) - if device and str(device) != '0': - cmd.extend(['--device-index', str(device)]) + cmd.extend(["--gain", str(gain)]) + if device and str(device) != "0": + cmd.extend(["--device-index", str(device)]) logger.info(f"Starting dump1090: {' '.join(cmd)}") try: - proc = subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - start_new_session=True - ) - self.processes['adsb'] = proc + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, start_new_session=True) + self.processes["adsb"] = proc # Wait for dump1090 to start time.sleep(2) if proc.poll() is not None: - stderr = proc.stderr.read().decode('utf-8', errors='ignore') - return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'} + stderr = proc.stderr.read().decode("utf-8", errors="ignore") + return {"status": "error", "message": f"dump1090 failed to start: {stderr[:200]}"} # Connect to SBS port - return self._start_adsb_sbs_connection('localhost', 30003) + return self._start_adsb_sbs_connection("localhost", 30003) except FileNotFoundError: - return {'status': 'error', 'message': 'dump1090 not found'} + return {"status": "error", "message": "dump1090 not found"} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _find_dump1090(self) -> str | None: """Find dump1090 binary using Intercept's dependency module or fallback.""" # Try Intercept's tool path finder first - for name in ['dump1090', 'dump1090-fa', 'dump1090-mutability', 'readsb']: + for name in ["dump1090", "dump1090-fa", "dump1090-mutability", "readsb"]: path = self._get_tool_path(name) if path: return path # Fallback: check common installation paths common_paths = [ - '/opt/homebrew/bin/dump1090', - '/opt/homebrew/bin/dump1090-fa', - '/usr/local/bin/dump1090', - '/usr/local/bin/dump1090-fa', - '/usr/bin/dump1090', - '/usr/bin/dump1090-fa', + "/opt/homebrew/bin/dump1090", + "/opt/homebrew/bin/dump1090-fa", + "/usr/local/bin/dump1090", + "/usr/local/bin/dump1090-fa", + "/usr/bin/dump1090", + "/usr/bin/dump1090-fa", ] for path in common_paths: if os.path.isfile(path) and os.access(path, os.X_OK): @@ -1408,24 +1388,20 @@ class ModeManager: def _start_adsb_sbs_connection(self, host: str, port: int) -> dict: """Connect to SBS port and start parsing.""" - thread = threading.Thread( - target=self._adsb_sbs_reader, - args=(host, port), - daemon=True - ) + thread = threading.Thread(target=self._adsb_sbs_reader, args=(host, port), daemon=True) thread.start() - self.output_threads['adsb'] = thread + self.output_threads["adsb"] = thread return { - 'status': 'started', - 'mode': 'adsb', - 'sbs_source': f'{host}:{port}', - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "adsb", + "sbs_source": f"{host}:{port}", + "gps_enabled": gps_manager.is_running, } def _adsb_sbs_reader(self, host: str, port: int): """Read and parse SBS data from dump1090.""" - mode = 'adsb' + mode = "adsb" stop_event = self.stop_events.get(mode) retry_count = 0 max_retries = 5 @@ -1443,13 +1419,13 @@ class ModeManager: while not (stop_event and stop_event.is_set()): try: - data = sock.recv(4096).decode('utf-8', errors='ignore') + data = sock.recv(4096).decode("utf-8", errors="ignore") if not data: break buffer += data - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) self._parse_sbs_line(line.strip()) except socket.timeout: @@ -1472,8 +1448,8 @@ class ModeManager: if not line: return - parts = line.split(',') - if len(parts) < 11 or parts[0] != 'MSG': + parts = line.split(",") + if len(parts) < 11 or parts[0] != "MSG": return msg_type = parts[1] @@ -1481,46 +1457,46 @@ class ModeManager: if not icao: return - aircraft = self.adsb_aircraft.get(icao) or {'icao': icao} - aircraft['last_seen'] = datetime.now(timezone.utc).isoformat() + aircraft = self.adsb_aircraft.get(icao) or {"icao": icao} + aircraft["last_seen"] = datetime.now(timezone.utc).isoformat() # Add GPS gps_pos = gps_manager.position if gps_pos: - aircraft['agent_gps'] = gps_pos + aircraft["agent_gps"] = gps_pos try: - if msg_type == '1' and len(parts) > 10: + if msg_type == "1" and len(parts) > 10: callsign = parts[10].strip() if callsign: - aircraft['callsign'] = callsign + aircraft["callsign"] = callsign - elif msg_type == '3' and len(parts) > 15: + elif msg_type == "3" and len(parts) > 15: if parts[11]: - aircraft['altitude'] = int(float(parts[11])) + aircraft["altitude"] = int(float(parts[11])) if parts[14] and parts[15]: - aircraft['lat'] = float(parts[14]) - aircraft['lon'] = float(parts[15]) + aircraft["lat"] = float(parts[14]) + aircraft["lon"] = float(parts[15]) - elif msg_type == '4' and len(parts) > 16: + elif msg_type == "4" and len(parts) > 16: if parts[12]: - aircraft['speed'] = int(float(parts[12])) + aircraft["speed"] = int(float(parts[12])) if parts[13]: - aircraft['heading'] = int(float(parts[13])) + aircraft["heading"] = int(float(parts[13])) if parts[16]: - aircraft['vertical_rate'] = int(float(parts[16])) + aircraft["vertical_rate"] = int(float(parts[16])) - elif msg_type == '5' and len(parts) > 11: + elif msg_type == "5" and len(parts) > 11: if parts[10]: callsign = parts[10].strip() if callsign: - aircraft['callsign'] = callsign + aircraft["callsign"] = callsign if parts[11]: - aircraft['altitude'] = int(float(parts[11])) + aircraft["altitude"] = int(float(parts[11])) - elif msg_type == '6' and len(parts) > 17: + elif msg_type == "6" and len(parts) > 17: if parts[17]: - aircraft['squawk'] = parts[17] + aircraft["squawk"] = parts[17] except (ValueError, IndexError): pass @@ -1533,19 +1509,20 @@ class ModeManager: def _start_wifi(self, params: dict) -> dict: """Start WiFi scanning using Intercept's UnifiedWiFiScanner.""" - interface = params.get('interface') - channel = params.get('channel') - channels = params.get('channels') - band = params.get('band', 'abg') - scan_type = params.get('scan_type', 'deep') + interface = params.get("interface") + channel = params.get("channel") + channels = params.get("channels") + band = params.get("band", "abg") + scan_type = params.get("scan_type", "deep") # Handle quick scan - returns results synchronously - if scan_type == 'quick': + if scan_type == "quick": return self._wifi_quick_scan(interface) # Deep scan - use Intercept's UnifiedWiFiScanner try: from utils.wifi.scanner import get_wifi_scanner + scanner = get_wifi_scanner(interface) # Store scanner reference @@ -1554,22 +1531,22 @@ class ModeManager: # Check capabilities caps = scanner.check_capabilities() if not caps.can_deep_scan: - return {'status': 'error', 'message': f'Deep scan not available: {", ".join(caps.issues)}'} + return {"status": "error", "message": f"Deep scan not available: {', '.join(caps.issues)}"} # Convert band parameter - if band == 'abg': - scan_band = 'all' - elif band == 'bg': - scan_band = '2.4' - elif band == 'a': - scan_band = '5' + if band == "abg": + scan_band = "all" + elif band == "bg": + scan_band = "2.4" + elif band == "a": + scan_band = "5" else: - scan_band = 'all' + scan_band = "all" channel_list = None if channels: if isinstance(channels, str): - channel_list = [c.strip() for c in channels.split(',') if c.strip()] + channel_list = [c.strip() for c in channels.split(",") if c.strip()] elif isinstance(channels, (list, tuple, set)): channel_list = list(channels) else: @@ -1577,38 +1554,34 @@ class ModeManager: try: channel_list = [int(c) for c in channel_list] except (TypeError, ValueError): - return {'status': 'error', 'message': 'Invalid channels'} + return {"status": "error", "message": "Invalid channels"} # Start deep scan if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list): # Start thread to sync data to agent's dictionaries - thread = threading.Thread( - target=self._wifi_data_sync, - args=(scanner,), - daemon=True - ) + thread = threading.Thread(target=self._wifi_data_sync, args=(scanner,), daemon=True) thread.start() - self.output_threads['wifi'] = thread + self.output_threads["wifi"] = thread return { - 'status': 'started', - 'mode': 'wifi', - 'interface': interface, - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "wifi", + "interface": interface, + "gps_enabled": gps_manager.is_running, } else: - return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'} + return {"status": "error", "message": scanner.get_status().error or "Failed to start deep scan"} except ImportError: # Fallback to direct airodump-ng return self._start_wifi_fallback(interface, channel, band, channels) except Exception as e: logger.error(f"WiFi scanner error: {e}") - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _wifi_data_sync(self, scanner): """Sync WiFi scanner data to agent's data structures.""" - mode = 'wifi' + mode = "wifi" stop_event = self.stop_events.get(mode) while not (stop_event and stop_event.is_set()): @@ -1619,14 +1592,14 @@ class ModeManager: for ap in scanner.access_points: net = ap.to_dict() if gps_position: - net['agent_gps'] = gps_position + net["agent_gps"] = gps_position self.wifi_networks[ap.bssid.upper()] = net # Sync clients for client in scanner.clients: client_data = client.to_dict() if gps_position: - client_data['agent_gps'] = gps_position + client_data["agent_gps"] = gps_position self.wifi_clients[client.mac.upper()] = client_data time.sleep(2) @@ -1635,7 +1608,7 @@ class ModeManager: time.sleep(2) # Stop scanner when done - if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance: + if hasattr(self, "_wifi_scanner_instance") and self._wifi_scanner_instance: self._wifi_scanner_instance.stop_deep_scan() def _start_wifi_fallback( @@ -1647,33 +1620,34 @@ class ModeManager: ) -> dict: """Fallback WiFi deep scan using airodump-ng directly.""" if not interface: - return {'status': 'error', 'message': 'WiFi interface required'} + return {"status": "error", "message": "WiFi interface required"} # Validate interface try: from utils.validation import validate_network_interface + interface = validate_network_interface(interface) except (ImportError, ValueError): - if not os.path.exists(f'/sys/class/net/{interface}'): - return {'status': 'error', 'message': f'Interface {interface} not found'} + if not os.path.exists(f"/sys/class/net/{interface}"): + return {"status": "error", "message": f"Interface {interface} not found"} - csv_path = '/tmp/intercept_agent_wifi' - for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']: + csv_path = "/tmp/intercept_agent_wifi" + for f in [f"{csv_path}-01.csv", f"{csv_path}-01.cap", f"{csv_path}-01.gps"]: with contextlib.suppress(OSError): os.remove(f) - airodump_path = self._get_tool_path('airodump-ng') + airodump_path = self._get_tool_path("airodump-ng") if not airodump_path: - return {'status': 'error', 'message': 'airodump-ng not found'} + return {"status": "error", "message": "airodump-ng not found"} - output_formats = 'csv,gps' if gps_manager.is_running else 'csv' - cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band] + output_formats = "csv,gps" if gps_manager.is_running else "csv" + cmd = [airodump_path, "-w", csv_path, "--output-format", output_formats, "--band", band] if gps_manager.is_running: - cmd.append('--gpsd') + cmd.append("--gpsd") channel_list = None if channels: if isinstance(channels, str): - channel_list = [c.strip() for c in channels.split(',') if c.strip()] + channel_list = [c.strip() for c in channels.split(",") if c.strip()] elif isinstance(channels, (list, tuple, set)): channel_list = list(channels) else: @@ -1681,30 +1655,30 @@ class ModeManager: try: channel_list = [int(c) for c in channel_list] except (TypeError, ValueError): - return {'status': 'error', 'message': 'Invalid channels'} + return {"status": "error", "message": "Invalid channels"} if channel_list: - cmd.extend(['-c', ','.join(str(c) for c in channel_list)]) + cmd.extend(["-c", ",".join(str(c) for c in channel_list)]) elif channel: - cmd.extend(['-c', str(channel)]) + cmd.extend(["-c", str(channel)]) cmd.append(interface) try: proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - self.processes['wifi'] = proc + self.processes["wifi"] = proc time.sleep(0.5) if proc.poll() is not None: - stderr = proc.stderr.read().decode('utf-8', errors='ignore') - return {'status': 'error', 'message': f'airodump-ng failed: {stderr[:200]}'} + stderr = proc.stderr.read().decode("utf-8", errors="ignore") + return {"status": "error", "message": f"airodump-ng failed: {stderr[:200]}"} thread = threading.Thread(target=self._wifi_csv_reader, args=(csv_path,), daemon=True) thread.start() - self.output_threads['wifi'] = thread + self.output_threads["wifi"] = thread - return {'status': 'started', 'mode': 'wifi', 'interface': interface, 'gps_enabled': gps_manager.is_running} + return {"status": "started", "mode": "wifi", "interface": interface, "gps_enabled": gps_manager.is_running} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _wifi_quick_scan(self, interface: str | None) -> dict: """ @@ -1715,15 +1689,12 @@ class ModeManager: """ try: from utils.wifi.scanner import get_wifi_scanner + scanner = get_wifi_scanner() result = scanner.quick_scan(interface=interface, timeout=15.0) if result.error: - return { - 'status': 'error', - 'message': result.error, - 'warnings': result.warnings - } + return {"status": "error", "message": result.error, "warnings": result.warnings} # Convert access points to dict format networks = [] @@ -1732,18 +1703,18 @@ class ModeManager: net = ap.to_dict() # Add agent GPS if available if gps_position: - net['agent_gps'] = gps_position + net["agent_gps"] = gps_position networks.append(net) return { - 'status': 'success', - 'scan_type': 'quick', - 'access_points': networks, - 'networks': networks, # Alias for compatibility - 'network_count': len(networks), - 'warnings': result.warnings, - 'gps_enabled': gps_manager.is_running, - 'agent_gps': gps_position + "status": "success", + "scan_type": "quick", + "access_points": networks, + "networks": networks, # Alias for compatibility + "network_count": len(networks), + "warnings": result.warnings, + "gps_enabled": gps_manager.is_running, + "agent_gps": gps_position, } except ImportError: @@ -1751,73 +1722,69 @@ class ModeManager: return self._wifi_quick_scan_fallback(interface) except Exception as e: logger.exception("Quick WiFi scan failed") - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _wifi_quick_scan_fallback(self, interface: str | None) -> dict: """Fallback quick scan using nmcli directly.""" - nmcli_path = shutil.which('nmcli') + nmcli_path = shutil.which("nmcli") if not nmcli_path: - return {'status': 'error', 'message': 'nmcli not found. Install NetworkManager.'} + return {"status": "error", "message": "nmcli not found. Install NetworkManager."} try: # Trigger rescan - subprocess.run( - [nmcli_path, 'device', 'wifi', 'rescan'], - capture_output=True, - timeout=5 - ) + subprocess.run([nmcli_path, "device", "wifi", "rescan"], capture_output=True, timeout=5) # Get results - cmd = [nmcli_path, '-t', '-f', 'BSSID,SSID,CHAN,SIGNAL,SECURITY', 'device', 'wifi', 'list'] + cmd = [nmcli_path, "-t", "-f", "BSSID,SSID,CHAN,SIGNAL,SECURITY", "device", "wifi", "list"] if interface: - cmd.extend(['ifname', interface]) + cmd.extend(["ifname", interface]) result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) if result.returncode != 0: - return {'status': 'error', 'message': f'nmcli failed: {result.stderr}'} + return {"status": "error", "message": f"nmcli failed: {result.stderr}"} networks = [] gps_position = gps_manager.position - for line in result.stdout.strip().split('\n'): + for line in result.stdout.strip().split("\n"): if not line.strip(): continue - parts = line.split(':') + parts = line.split(":") if len(parts) >= 5: net = { - 'bssid': parts[0], - 'essid': parts[1], - 'channel': int(parts[2]) if parts[2].isdigit() else 0, - 'signal': int(parts[3]) if parts[3].isdigit() else 0, - 'rssi_current': int(parts[3]) - 100 if parts[3].isdigit() else -100, # Convert % to dBm approx - 'security': parts[4], + "bssid": parts[0], + "essid": parts[1], + "channel": int(parts[2]) if parts[2].isdigit() else 0, + "signal": int(parts[3]) if parts[3].isdigit() else 0, + "rssi_current": int(parts[3]) - 100 if parts[3].isdigit() else -100, # Convert % to dBm approx + "security": parts[4], } if gps_position: - net['agent_gps'] = gps_position + net["agent_gps"] = gps_position networks.append(net) return { - 'status': 'success', - 'scan_type': 'quick', - 'access_points': networks, - 'networks': networks, - 'network_count': len(networks), - 'warnings': ['Using fallback nmcli scanner'], - 'gps_enabled': gps_manager.is_running, - 'agent_gps': gps_position + "status": "success", + "scan_type": "quick", + "access_points": networks, + "networks": networks, + "network_count": len(networks), + "warnings": ["Using fallback nmcli scanner"], + "gps_enabled": gps_manager.is_running, + "agent_gps": gps_position, } except subprocess.TimeoutExpired: - return {'status': 'error', 'message': 'nmcli scan timed out'} + return {"status": "error", "message": "nmcli scan timed out"} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _wifi_csv_reader(self, csv_path: str): """Periodically parse airodump-ng CSV and GPS output.""" - mode = 'wifi' + mode = "wifi" stop_event = self.stop_events.get(mode) - csv_file = csv_path + '-01.csv' - gps_file = csv_path + '-01.gps' + csv_file = csv_path + "-01.csv" + gps_file = csv_path + "-01.gps" while not (stop_event and stop_event.is_set()): if os.path.exists(csv_file): @@ -1851,23 +1818,24 @@ class ModeManager: """ try: import xml.etree.ElementTree as ET + tree = ET.parse(gps_path) root = tree.getroot() # Get the last (most recent) GPS point - gps_points = root.findall('.//gps-point') + gps_points = root.findall(".//gps-point") if gps_points: last_point = gps_points[-1] - lat = last_point.get('lat') - lon = last_point.get('lon') - alt = last_point.get('alt') + lat = last_point.get("lat") + lon = last_point.get("lon") + alt = last_point.get("alt") if lat and lon: return { - 'lat': float(lat), - 'lon': float(lon), - 'altitude': float(alt) if alt else None, - 'source': 'airodump_gps' + "lat": float(lat), + "lon": float(lon), + "altitude": float(alt) if alt else None, + "source": "airodump_gps", } except Exception as e: logger.debug(f"GPS file parse error: {e}") @@ -1882,37 +1850,38 @@ class ModeManager: try: # Use Intercept's robust airodump parser (handles edge cases, proper CSV parsing) from utils.wifi.parsers.airodump import parse_airodump_csv + network_obs, client_list = parse_airodump_csv(csv_path) # Convert WiFiObservation objects to dicts for agent format for obs in network_obs: networks[obs.bssid] = { - 'bssid': obs.bssid, - 'essid': obs.essid or 'Hidden', - 'channel': obs.channel, - 'frequency_mhz': obs.frequency_mhz, - 'signal': obs.rssi, - 'security': obs.security, - 'cipher': obs.cipher, - 'auth': obs.auth, - 'vendor': obs.vendor, - 'beacon_count': obs.beacon_count, - 'data_count': obs.data_count, - 'band': obs.band, - 'last_seen': datetime.now(timezone.utc).isoformat(), + "bssid": obs.bssid, + "essid": obs.essid or "Hidden", + "channel": obs.channel, + "frequency_mhz": obs.frequency_mhz, + "signal": obs.rssi, + "security": obs.security, + "cipher": obs.cipher, + "auth": obs.auth, + "vendor": obs.vendor, + "beacon_count": obs.beacon_count, + "data_count": obs.data_count, + "band": obs.band, + "last_seen": datetime.now(timezone.utc).isoformat(), } # Convert client dicts (already in dict format from parser) for client in client_list: - mac = client.get('mac') + mac = client.get("mac") if mac: clients[mac] = { - 'mac': mac, - 'signal': client.get('rssi'), - 'bssid': client.get('bssid'), - 'probes': ','.join(client.get('probed_essids', [])), - 'packets': client.get('packets', 0), - 'last_seen': datetime.now(timezone.utc).isoformat(), + "mac": mac, + "signal": client.get("rssi"), + "bssid": client.get("bssid"), + "probes": ",".join(client.get("probed_essids", [])), + "packets": client.get("packets", 0), + "last_seen": datetime.now(timezone.utc).isoformat(), } logger.debug(f"Parsed {len(networks)} networks, {len(clients)} clients") @@ -1921,35 +1890,35 @@ class ModeManager: logger.warning("Intercept WiFi parser not available, using fallback") # Fallback: simple parsing if running standalone try: - with open(csv_path, errors='replace') as f: + with open(csv_path, errors="replace") as f: content = f.read() - for section in content.split('\n\n'): - lines = section.strip().split('\n') + for section in content.split("\n\n"): + lines = section.strip().split("\n") if not lines: continue header = lines[0] - if 'BSSID' in header and 'ESSID' in header: + if "BSSID" in header and "ESSID" in header: for line in lines[1:]: - parts = [p.strip() for p in line.split(',')] - if len(parts) >= 14 and ':' in parts[0]: + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 14 and ":" in parts[0]: networks[parts[0]] = { - 'bssid': parts[0], - 'channel': int(parts[3]) if parts[3].lstrip('-').isdigit() else None, - 'signal': int(parts[8]) if parts[8].lstrip('-').isdigit() else None, - 'security': parts[5], - 'essid': parts[13] or 'Hidden', - 'last_seen': datetime.now(timezone.utc).isoformat(), + "bssid": parts[0], + "channel": int(parts[3]) if parts[3].lstrip("-").isdigit() else None, + "signal": int(parts[8]) if parts[8].lstrip("-").isdigit() else None, + "security": parts[5], + "essid": parts[13] or "Hidden", + "last_seen": datetime.now(timezone.utc).isoformat(), } - elif 'Station MAC' in header: + elif "Station MAC" in header: for line in lines[1:]: - parts = [p.strip() for p in line.split(',')] - if len(parts) >= 6 and ':' in parts[0]: + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 6 and ":" in parts[0]: clients[parts[0]] = { - 'mac': parts[0], - 'signal': int(parts[3]) if parts[3].lstrip('-').isdigit() else None, - 'bssid': parts[5] if ':' in parts[5] else None, - 'probes': parts[6] if len(parts) > 6 else '', - 'last_seen': datetime.now(timezone.utc).isoformat(), + "mac": parts[0], + "signal": int(parts[3]) if parts[3].lstrip("-").isdigit() else None, + "bssid": parts[5] if ":" in parts[5] else None, + "probes": parts[6] if len(parts) > 6 else "", + "last_seen": datetime.now(timezone.utc).isoformat(), } except Exception as e: logger.error(f"Fallback CSV parse error: {e}") @@ -1963,10 +1932,10 @@ class ModeManager: if gps_data: # Use GPS coordinates from airodump's GPS file gps_pos = { - 'lat': gps_data['lat'], - 'lon': gps_data['lon'], - 'altitude': gps_data.get('altitude'), - 'source': 'airodump_gps', # Mark as from airodump GPS file + "lat": gps_data["lat"], + "lon": gps_data["lon"], + "altitude": gps_data.get("altitude"), + "source": "airodump_gps", # Mark as from airodump GPS file } logger.debug(f"Using airodump GPS: {gps_data['lat']:.6f}, {gps_data['lon']:.6f}") else: @@ -1975,9 +1944,9 @@ class ModeManager: if gps_pos: for net in networks.values(): - net['agent_gps'] = gps_pos + net["agent_gps"] = gps_pos for client in clients.values(): - client['agent_gps'] = gps_pos + client["agent_gps"] = gps_pos return networks, clients @@ -1987,13 +1956,14 @@ class ModeManager: def _start_bluetooth(self, params: dict) -> dict: """Start Bluetooth scanning using Intercept's BluetoothScanner.""" - adapter = params.get('adapter', 'hci0') - mode_param = params.get('mode', 'auto') - duration = params.get('duration') + adapter = params.get("adapter", "hci0") + mode_param = params.get("mode", "auto") + duration = params.get("duration") try: # Use Intercept's BluetoothScanner from utils.bluetooth.scanner import BluetoothScanner + scanner = BluetoothScanner(adapter_id=adapter) # Store scanner reference @@ -2003,13 +1973,13 @@ class ModeManager: def on_device_updated(device): # Convert to agent's format and store self.bluetooth_devices[device.address.upper()] = { - 'mac': device.address.upper(), - 'name': device.name, - 'rssi': device.rssi_current, - 'protocol': device.protocol, - 'last_seen': device.last_seen.isoformat() if device.last_seen else None, - 'first_seen': device.first_seen.isoformat() if device.first_seen else None, - 'agent_gps': gps_manager.position + "mac": device.address.upper(), + "name": device.name, + "rssi": device.rssi_current, + "protocol": device.protocol, + "last_seen": device.last_seen.isoformat() if device.last_seen else None, + "first_seen": device.first_seen.isoformat() if device.first_seen else None, + "agent_gps": gps_manager.position, } scanner.add_device_callback(on_device_updated) @@ -2017,34 +1987,30 @@ class ModeManager: # Start scanning if scanner.start_scan(mode=mode_param, duration_s=duration): # Start thread to sync device data - thread = threading.Thread( - target=self._bluetooth_data_sync, - args=(scanner,), - daemon=True - ) + thread = threading.Thread(target=self._bluetooth_data_sync, args=(scanner,), daemon=True) thread.start() - self.output_threads['bluetooth'] = thread + self.output_threads["bluetooth"] = thread return { - 'status': 'started', - 'mode': 'bluetooth', - 'adapter': adapter, - 'backend': scanner.get_status().backend, - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "bluetooth", + "adapter": adapter, + "backend": scanner.get_status().backend, + "gps_enabled": gps_manager.is_running, } else: - return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start scan'} + return {"status": "error", "message": scanner.get_status().error or "Failed to start scan"} except ImportError: # Fallback to direct bluetoothctl if scanner not available return self._start_bluetooth_fallback(adapter) except Exception as e: logger.error(f"Bluetooth scanner error: {e}") - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _bluetooth_data_sync(self, scanner): """Sync Bluetooth scanner data to agent's data structures.""" - mode = 'bluetooth' + mode = "bluetooth" stop_event = self.stop_events.get(mode) while not (stop_event and stop_event.is_set()): @@ -2053,12 +2019,12 @@ class ModeManager: devices = scanner.get_devices() for device in devices: self.bluetooth_devices[device.address.upper()] = { - 'mac': device.address.upper(), - 'name': device.name, - 'rssi': device.rssi_current, - 'protocol': device.protocol, - 'last_seen': device.last_seen.isoformat() if device.last_seen else None, - 'agent_gps': gps_manager.position + "mac": device.address.upper(), + "name": device.name, + "rssi": device.rssi_current, + "protocol": device.protocol, + "last_seen": device.last_seen.isoformat() if device.last_seen else None, + "agent_gps": gps_manager.position, } time.sleep(1) except Exception as e: @@ -2066,45 +2032,41 @@ class ModeManager: time.sleep(1) # Stop scanner when done - if hasattr(self, '_bluetooth_scanner_instance') and self._bluetooth_scanner_instance: + if hasattr(self, "_bluetooth_scanner_instance") and self._bluetooth_scanner_instance: self._bluetooth_scanner_instance.stop_scan() def _start_bluetooth_fallback(self, adapter: str) -> dict: """Fallback Bluetooth scanning using bluetoothctl directly.""" - if not shutil.which('bluetoothctl'): - return {'status': 'error', 'message': 'bluetoothctl not found'} + if not shutil.which("bluetoothctl"): + return {"status": "error", "message": "bluetoothctl not found"} - thread = threading.Thread( - target=self._bluetooth_scanner_fallback, - args=(adapter,), - daemon=True - ) + thread = threading.Thread(target=self._bluetooth_scanner_fallback, args=(adapter,), daemon=True) thread.start() - self.output_threads['bluetooth'] = thread + self.output_threads["bluetooth"] = thread return { - 'status': 'started', - 'mode': 'bluetooth', - 'adapter': adapter, - 'backend': 'bluetoothctl', - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "bluetooth", + "adapter": adapter, + "backend": "bluetoothctl", + "gps_enabled": gps_manager.is_running, } def _bluetooth_scanner_fallback(self, adapter: str): """Fallback scan using bluetoothctl directly.""" - mode = 'bluetooth' + mode = "bluetooth" stop_event = self.stop_events.get(mode) try: proc = subprocess.Popen( - ['bluetoothctl'], + ["bluetoothctl"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - self.processes['bluetooth'] = proc + self.processes["bluetooth"] = proc - proc.stdin.write(b'scan on\n') + proc.stdin.write(b"scan on\n") proc.stdin.flush() while not (stop_event and stop_event.is_set()): @@ -2112,14 +2074,14 @@ class ModeManager: if not line: break - line = line.decode('utf-8', errors='replace').strip() - if 'Device' in line: + line = line.decode("utf-8", errors="replace").strip() + if "Device" in line: self._parse_bluetooth_line(line) time.sleep(0.1) - proc.stdin.write(b'scan off\n') - proc.stdin.write(b'exit\n') + proc.stdin.write(b"scan off\n") + proc.stdin.write(b"exit\n") proc.stdin.flush() proc.wait(timeout=2) @@ -2133,32 +2095,32 @@ class ModeManager: import re # Match device address (MAC) - mac_match = re.search(r'([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})', line) + mac_match = re.search(r"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})", line) if not mac_match: return mac = mac_match.group(1).upper() - device = self.bluetooth_devices.get(mac) or {'mac': mac} - device['last_seen'] = datetime.now(timezone.utc).isoformat() + device = self.bluetooth_devices.get(mac) or {"mac": mac} + device["last_seen"] = datetime.now(timezone.utc).isoformat() # Extract name - if '[NEW]' in line or '[CHG]' in line and 'Name:' not in line: + if "[NEW]" in line or "[CHG]" in line and "Name:" not in line: # Try to get name after MAC parts = line.split(mac) if len(parts) > 1: name = parts[1].strip() - if name and not name.startswith('RSSI') and not name.startswith('ManufacturerData'): - device['name'] = name + if name and not name.startswith("RSSI") and not name.startswith("ManufacturerData"): + device["name"] = name # Extract RSSI - rssi_match = re.search(r'RSSI:\s*(-?\d+)', line) + rssi_match = re.search(r"RSSI:\s*(-?\d+)", line) if rssi_match: - device['rssi'] = int(rssi_match.group(1)) + device["rssi"] = int(rssi_match.group(1)) # Add GPS gps_pos = gps_manager.position if gps_pos: - device['agent_gps'] = gps_pos + device["agent_gps"] = gps_pos self.bluetooth_devices[mac] = device @@ -2168,40 +2130,44 @@ class ModeManager: def _start_pager(self, params: dict) -> dict: """Start POCSAG/FLEX pager decoding using rtl_fm | multimon-ng.""" - freq = params.get('frequency', '929.6125') - gain = params.get('gain', '0') - device = params.get('device', '0') - ppm = params.get('ppm', '0') - squelch = params.get('squelch', '0') - protocols = params.get('protocols', ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']) + freq = params.get("frequency", "929.6125") + gain = params.get("gain", "0") + device = params.get("device", "0") + ppm = params.get("ppm", "0") + squelch = params.get("squelch", "0") + protocols = params.get("protocols", ["POCSAG512", "POCSAG1200", "POCSAG2400", "FLEX"]) # Validate tools - rtl_fm_path = self._get_tool_path('rtl_fm') - multimon_path = self._get_tool_path('multimon-ng') + rtl_fm_path = self._get_tool_path("rtl_fm") + multimon_path = self._get_tool_path("multimon-ng") if not rtl_fm_path: - return {'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr.'} + return {"status": "error", "message": "rtl_fm not found. Install rtl-sdr."} if not multimon_path: - return {'status': 'error', 'message': 'multimon-ng not found. Install multimon-ng.'} + return {"status": "error", "message": "multimon-ng not found. Install multimon-ng."} # Build rtl_fm command for FM demodulation at 22050 Hz rtl_fm_cmd = [ rtl_fm_path, - '-f', f'{freq}M', - '-s', '22050', - '-g', str(gain), - '-d', str(device), + "-f", + f"{freq}M", + "-s", + "22050", + "-g", + str(gain), + "-d", + str(device), ] - if ppm and str(ppm) != '0': - rtl_fm_cmd.extend(['-p', str(ppm)]) - if squelch and str(squelch) != '0': - rtl_fm_cmd.extend(['-l', str(squelch)]) + if ppm and str(ppm) != "0": + rtl_fm_cmd.extend(["-p", str(ppm)]) + if squelch and str(squelch) != "0": + rtl_fm_cmd.extend(["-l", str(squelch)]) # Build multimon-ng command - multimon_cmd = [multimon_path, '-t', 'raw', '-a'] + multimon_cmd = [multimon_path, "-t", "raw", "-a"] for proto in protocols: - if proto in ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']: - multimon_cmd.extend(['-a', proto]) - multimon_cmd.append('-') + if proto in ["POCSAG512", "POCSAG1200", "POCSAG2400", "FLEX"]: + multimon_cmd.extend(["-a", proto]) + multimon_cmd.append("-") logger.info(f"Starting pager: {' '.join(rtl_fm_cmd)} | {' '.join(multimon_cmd)}") @@ -2223,61 +2189,57 @@ class ModeManager: rtl_fm_proc.stdout.close() # Allow SIGPIPE # Store both processes - self.processes['pager'] = multimon_proc - self.processes['pager_rtl'] = rtl_fm_proc + self.processes["pager"] = multimon_proc + self.processes["pager_rtl"] = rtl_fm_proc # Wait briefly to verify processes started successfully time.sleep(0.5) if rtl_fm_proc.poll() is not None: - stderr_output = rtl_fm_proc.stderr.read().decode('utf-8', errors='replace') + stderr_output = rtl_fm_proc.stderr.read().decode("utf-8", errors="replace") multimon_proc.terminate() - del self.processes['pager'] - del self.processes['pager_rtl'] - return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'} + del self.processes["pager"] + del self.processes["pager_rtl"] + return {"status": "error", "message": f"rtl_fm failed to start: {stderr_output[:200]}"} # Start output reader - thread = threading.Thread( - target=self._pager_output_reader, - args=(multimon_proc,), - daemon=True - ) + thread = threading.Thread(target=self._pager_output_reader, args=(multimon_proc,), daemon=True) thread.start() - self.output_threads['pager'] = thread + self.output_threads["pager"] = thread return { - 'status': 'started', - 'mode': 'pager', - 'frequency': freq, - 'protocols': protocols, - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "pager", + "frequency": freq, + "protocols": protocols, + "gps_enabled": gps_manager.is_running, } except FileNotFoundError as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _pager_output_reader(self, proc: subprocess.Popen): """Read and parse multimon-ng output for pager messages.""" - mode = 'pager' + mode = "pager" stop_event = self.stop_events.get(mode) try: - for line in iter(proc.stdout.readline, b''): + for line in iter(proc.stdout.readline, b""): if stop_event and stop_event.is_set(): break - line = line.decode('utf-8', errors='replace').strip() + line = line.decode("utf-8", errors="replace").strip() if not line: continue parsed = self._parse_pager_message(line) if parsed: - parsed['received_at'] = datetime.now(timezone.utc).isoformat() + parsed["received_at"] = datetime.now(timezone.utc).isoformat() gps_pos = gps_manager.position if gps_pos: - parsed['agent_gps'] = gps_pos + parsed["agent_gps"] = gps_pos snapshots = self.data_snapshots.get(mode, []) snapshots.append(parsed) @@ -2295,12 +2257,12 @@ class ModeManager: finally: with contextlib.suppress(Exception): proc.wait(timeout=1) - if 'pager_rtl' in self.processes: + if "pager_rtl" in self.processes: try: - rtl_proc = self.processes['pager_rtl'] + rtl_proc = self.processes["pager_rtl"] if rtl_proc.poll() is None: rtl_proc.terminate() - del self.processes['pager_rtl'] + del self.processes["pager_rtl"] except Exception: pass logger.info("Pager reader stopped") @@ -2310,54 +2272,50 @@ class ModeManager: try: # Use Intercept's existing pager parser from routes.pager import parse_multimon_output + parsed = parse_multimon_output(line) if parsed: - parsed['type'] = 'pager' + parsed["type"] = "pager" return parsed return None except ImportError: # Fallback to inline parsing if import fails import re + # POCSAG with message - match = re.match( - r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)', - line - ) + match = re.match(r"(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)", line) if match: return { - 'type': 'pager', - 'protocol': match.group(1), - 'address': match.group(2), - 'function': match.group(3), - 'msg_type': match.group(4), - 'message': match.group(5).strip() or '[No Message]' + "type": "pager", + "protocol": match.group(1), + "address": match.group(2), + "function": match.group(3), + "msg_type": match.group(4), + "message": match.group(5).strip() or "[No Message]", } # POCSAG address only (tone) - match = re.match( - r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$', - line - ) + match = re.match(r"(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$", line) if match: return { - 'type': 'pager', - 'protocol': match.group(1), - 'address': match.group(2), - 'function': match.group(3), - 'msg_type': 'Tone', - 'message': '[Tone Only]' + "type": "pager", + "protocol": match.group(1), + "address": match.group(2), + "function": match.group(3), + "msg_type": "Tone", + "message": "[Tone Only]", } # FLEX format - match = re.match(r'FLEX[:\|]\s*(.+)', line) + match = re.match(r"FLEX[:\|]\s*(.+)", line) if match: return { - 'type': 'pager', - 'protocol': 'FLEX', - 'address': 'Unknown', - 'function': '', - 'msg_type': 'Unknown', - 'message': match.group(1).strip() + "type": "pager", + "protocol": "FLEX", + "address": "Unknown", + "function": "", + "msg_type": "Unknown", + "message": match.group(1).strip(), } return None @@ -2368,95 +2326,88 @@ class ModeManager: def _start_ais(self, params: dict) -> dict: """Start AIS vessel tracking using AIS-catcher.""" - gain = params.get('gain', '33') - device = params.get('device', '0') - bias_t = params.get('bias_t', False) + gain = params.get("gain", "33") + device = params.get("device", "0") + bias_t = params.get("bias_t", False) # Find AIS-catcher ais_catcher = self._find_ais_catcher() if not ais_catcher: - return {'status': 'error', 'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher'} + return { + "status": "error", + "message": "AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher", + } # Initialize vessel dict - if not hasattr(self, 'ais_vessels'): + if not hasattr(self, "ais_vessels"): self.ais_vessels = {} self.ais_vessels.clear() # Build command - output JSON on TCP port 1234 cmd = [ ais_catcher, - '-d', str(device), - '-gr', f'TUNER={gain}', - '-o', '4', # JSON format - '-N', '1234', # TCP output on port 1234 + "-d", + str(device), + "-gr", + f"TUNER={gain}", + "-o", + "4", # JSON format + "-N", + "1234", # TCP output on port 1234 ] if bias_t: - cmd.extend(['-gr', 'BIASTEE=on']) + cmd.extend(["-gr", "BIASTEE=on"]) logger.info(f"Starting AIS-catcher: {' '.join(cmd)}") try: - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - start_new_session=True - ) - self.processes['ais'] = proc + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) + self.processes["ais"] = proc time.sleep(2) if proc.poll() is not None: - stderr = proc.stderr.read().decode('utf-8', errors='ignore') - return {'status': 'error', 'message': f'AIS-catcher failed: {stderr[:200]}'} + stderr = proc.stderr.read().decode("utf-8", errors="ignore") + return {"status": "error", "message": f"AIS-catcher failed: {stderr[:200]}"} # Start TCP reader thread - thread = threading.Thread( - target=self._ais_tcp_reader, - args=(1234,), - daemon=True - ) + thread = threading.Thread(target=self._ais_tcp_reader, args=(1234,), daemon=True) thread.start() - self.output_threads['ais'] = thread + self.output_threads["ais"] = thread - return { - 'status': 'started', - 'mode': 'ais', - 'tcp_port': 1234, - 'gps_enabled': gps_manager.is_running - } + return {"status": "started", "mode": "ais", "tcp_port": 1234, "gps_enabled": gps_manager.is_running} except FileNotFoundError: - return {'status': 'error', 'message': 'AIS-catcher not found'} + return {"status": "error", "message": "AIS-catcher not found"} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _find_ais_catcher(self) -> str | None: """Find AIS-catcher binary.""" - for name in ['AIS-catcher', 'aiscatcher']: + for name in ["AIS-catcher", "aiscatcher"]: path = self._get_tool_path(name) if path: return path - for path in ['/usr/local/bin/AIS-catcher', '/usr/bin/AIS-catcher', '/opt/homebrew/bin/AIS-catcher']: + for path in ["/usr/local/bin/AIS-catcher", "/usr/bin/AIS-catcher", "/opt/homebrew/bin/AIS-catcher"]: if os.path.isfile(path) and os.access(path, os.X_OK): return path return None def _ais_tcp_reader(self, port: int): """Read JSON vessel data from AIS-catcher TCP port.""" - mode = 'ais' + mode = "ais" stop_event = self.stop_events.get(mode) retry_count = 0 # Initialize vessel dict - if not hasattr(self, 'ais_vessels'): + if not hasattr(self, "ais_vessels"): self.ais_vessels = {} while not (stop_event and stop_event.is_set()): try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5.0) - sock.connect(('localhost', port)) + sock.connect(("localhost", port)) logger.info(f"Connected to AIS-catcher on port {port}") retry_count = 0 @@ -2465,13 +2416,13 @@ class ModeManager: while not (stop_event and stop_event.is_set()): try: - data = sock.recv(4096).decode('utf-8', errors='ignore') + data = sock.recv(4096).decode("utf-8", errors="ignore") if not data: break buffer += data - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) self._parse_ais_json(line.strip()) except socket.timeout: @@ -2498,28 +2449,28 @@ class ModeManager: except json.JSONDecodeError: return - mmsi = msg.get('mmsi') + mmsi = msg.get("mmsi") if not mmsi: return mmsi = str(mmsi) - vessel = self.ais_vessels.get(mmsi) or {'mmsi': mmsi} - vessel['last_seen'] = datetime.now(timezone.utc).isoformat() + vessel = self.ais_vessels.get(mmsi) or {"mmsi": mmsi} + vessel["last_seen"] = datetime.now(timezone.utc).isoformat() # Position - lat = msg.get('latitude') or msg.get('lat') - lon = msg.get('longitude') or msg.get('lon') + lat = msg.get("latitude") or msg.get("lat") + lon = msg.get("longitude") or msg.get("lon") if lat is not None and lon is not None: try: lat, lon = float(lat), float(lon) if -90 <= lat <= 90 and -180 <= lon <= 180: - vessel['lat'] = lat - vessel['lon'] = lon + vessel["lat"] = lat + vessel["lon"] = lon except (ValueError, TypeError): pass # Speed and course - for field, max_val in [('speed', 102.3), ('course', 360)]: + for field, max_val in [("speed", 102.3), ("course", 360)]: if field in msg: try: val = float(msg[field]) @@ -2528,23 +2479,23 @@ class ModeManager: except (ValueError, TypeError): pass - if 'heading' in msg: + if "heading" in msg: try: - heading = int(msg['heading']) + heading = int(msg["heading"]) if heading < 360: - vessel['heading'] = heading + vessel["heading"] = heading except (ValueError, TypeError): pass # Static data - for field in ['name', 'callsign', 'destination', 'shiptype', 'ship_type']: + for field in ["name", "callsign", "destination", "shiptype", "ship_type"]: if field in msg and msg[field]: - key = 'ship_type' if field == 'shiptype' else field + key = "ship_type" if field == "shiptype" else field vessel[key] = str(msg[field]).strip() gps_pos = gps_manager.position if gps_pos: - vessel['agent_gps'] = gps_pos + vessel["agent_gps"] = gps_pos self.ais_vessels[mmsi] = vessel @@ -2561,54 +2512,50 @@ class ModeManager: '-o' for TLeconte v3.x """ try: - result = subprocess.run( - [acarsdec_path], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run([acarsdec_path], capture_output=True, text=True, timeout=5) output = result.stdout + result.stderr # f00b4r0 fork uses --output instead of -j/-o - if '--output' in output: - return '--output' + if "--output" in output: + return "--output" # Parse version for TLeconte import re - version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE) + + version_match = re.search(r"acarsdec[^\d]*v?(\d+)\.(\d+)", output, re.IGNORECASE) if version_match: major = int(version_match.group(1)) - return '-j' if major >= 4 else '-o' + return "-j" if major >= 4 else "-o" except Exception: pass - return '-j' # Default to TLeconte v4+ + return "-j" # Default to TLeconte v4+ def _start_acars(self, params: dict) -> dict: """Start ACARS decoding using acarsdec.""" - gain = params.get('gain', '40') - device = params.get('device', '0') - frequencies = params.get('frequencies', ['131.550', '130.025', '129.125', '131.525', '131.725']) + gain = params.get("gain", "40") + device = params.get("device", "0") + frequencies = params.get("frequencies", ["131.550", "130.025", "129.125", "131.525", "131.725"]) - acarsdec_path = self._get_tool_path('acarsdec') + acarsdec_path = self._get_tool_path("acarsdec") if not acarsdec_path: - return {'status': 'error', 'message': 'acarsdec not found. Install acarsdec.'} + return {"status": "error", "message": "acarsdec not found. Install acarsdec."} # Detect fork and build appropriate command fork_type = self._detect_acarsdec_fork(acarsdec_path) cmd = [acarsdec_path] - if fork_type == '--output': + if fork_type == "--output": # f00b4r0 fork (DragonOS): different syntax - cmd.extend(['--output', 'json:file']) # stdout - cmd.extend(['-g', str(gain)]) - cmd.extend(['-m', '256']) # 3.2 MS/s for wider bandwidth - cmd.extend(['--rtlsdr', str(device)]) - elif fork_type == '-j': + cmd.extend(["--output", "json:file"]) # stdout + cmd.extend(["-g", str(gain)]) + cmd.extend(["-m", "256"]) # 3.2 MS/s for wider bandwidth + cmd.extend(["--rtlsdr", str(device)]) + elif fork_type == "-j": # TLeconte v4+ - cmd.extend(['-j', '-g', str(gain), '-r', str(device)]) + cmd.extend(["-j", "-g", str(gain), "-r", str(device)]) else: # TLeconte v3.x - cmd.extend(['-o', '4', '-g', str(gain), '-r', str(device)]) + cmd.extend(["-o", "4", "-g", str(gain), "-r", str(device)]) cmd.extend(frequencies) @@ -2620,58 +2567,54 @@ class ModeManager: stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - self.processes['acars'] = proc + self.processes["acars"] = proc - thread = threading.Thread( - target=self._acars_output_reader, - args=(proc,), - daemon=True - ) + thread = threading.Thread(target=self._acars_output_reader, args=(proc,), daemon=True) thread.start() - self.output_threads['acars'] = thread + self.output_threads["acars"] = thread # Wait briefly to verify process started successfully time.sleep(0.5) if proc.poll() is not None: # Process already exited - likely SDR busy or other error - stderr_output = proc.stderr.read().decode('utf-8', errors='replace') - del self.processes['acars'] - return {'status': 'error', 'message': f'acarsdec failed to start: {stderr_output[:200]}'} + stderr_output = proc.stderr.read().decode("utf-8", errors="replace") + del self.processes["acars"] + return {"status": "error", "message": f"acarsdec failed to start: {stderr_output[:200]}"} return { - 'status': 'started', - 'mode': 'acars', - 'frequencies': frequencies, - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "acars", + "frequencies": frequencies, + "gps_enabled": gps_manager.is_running, } except FileNotFoundError: - return {'status': 'error', 'message': 'acarsdec not found'} + return {"status": "error", "message": "acarsdec not found"} except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _acars_output_reader(self, proc: subprocess.Popen): """Read acarsdec JSON output.""" - mode = 'acars' + mode = "acars" stop_event = self.stop_events.get(mode) try: - for line in iter(proc.stdout.readline, b''): + for line in iter(proc.stdout.readline, b""): if stop_event and stop_event.is_set(): break - line = line.decode('utf-8', errors='replace').strip() + line = line.decode("utf-8", errors="replace").strip() if not line: continue try: msg = json.loads(line) - msg['type'] = 'acars' - msg['received_at'] = datetime.now(timezone.utc).isoformat() + msg["type"] = "acars" + msg["received_at"] = datetime.now(timezone.utc).isoformat() gps_pos = gps_manager.position if gps_pos: - msg['agent_gps'] = gps_pos + msg["agent_gps"] = gps_pos snapshots = self.data_snapshots.get(mode, []) snapshots.append(msg) @@ -2699,51 +2642,57 @@ class ModeManager: def _start_aprs(self, params: dict) -> dict: """Start APRS decoding using rtl_fm | direwolf.""" - freq = params.get('frequency', '144.390') # North America APRS - gain = params.get('gain', '40') - device = params.get('device', '0') - ppm = params.get('ppm', '0') + freq = params.get("frequency", "144.390") # North America APRS + gain = params.get("gain", "40") + device = params.get("device", "0") + ppm = params.get("ppm", "0") - rtl_fm_path = self._get_tool_path('rtl_fm') + rtl_fm_path = self._get_tool_path("rtl_fm") if not rtl_fm_path: - return {'status': 'error', 'message': 'rtl_fm not found'} + return {"status": "error", "message": "rtl_fm not found"} - direwolf_path = self._get_tool_path('direwolf') - multimon_path = self._get_tool_path('multimon-ng') + direwolf_path = self._get_tool_path("direwolf") + multimon_path = self._get_tool_path("multimon-ng") decoder_path = direwolf_path or multimon_path if not decoder_path: - return {'status': 'error', 'message': 'direwolf or multimon-ng not found'} + return {"status": "error", "message": "direwolf or multimon-ng not found"} # Initialize state - if not hasattr(self, 'aprs_stations'): + if not hasattr(self, "aprs_stations"): self.aprs_stations = {} self.aprs_stations.clear() # Build rtl_fm command for APRS (22050 Hz for AFSK 1200 baud) rtl_fm_cmd = [ rtl_fm_path, - '-f', f'{freq}M', - '-s', '22050', - '-g', str(gain), - '-d', str(device), - '-E', 'dc', - '-A', 'fast', + "-f", + f"{freq}M", + "-s", + "22050", + "-g", + str(gain), + "-d", + str(device), + "-E", + "dc", + "-A", + "fast", ] - if ppm and str(ppm) != '0': - rtl_fm_cmd.extend(['-p', str(ppm)]) + if ppm and str(ppm) != "0": + rtl_fm_cmd.extend(["-p", str(ppm)]) # Build decoder command if direwolf_path: - dw_config = '/tmp/intercept_direwolf.conf' + dw_config = "/tmp/intercept_direwolf.conf" try: - with open(dw_config, 'w') as f: + with open(dw_config, "w") as f: f.write("ADEVICE stdin null\nARATE 22050\nMODEM 1200\n") except Exception as e: - return {'status': 'error', 'message': f'Failed to create direwolf config: {e}'} - decoder_cmd = [direwolf_path, '-c', dw_config, '-r', '22050', '-t', '0', '-'] + return {"status": "error", "message": f"Failed to create direwolf config: {e}"} + decoder_cmd = [direwolf_path, "-c", dw_config, "-r", "22050", "-t", "0", "-"] else: - decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-'] + decoder_cmd = [multimon_path, "-t", "raw", "-a", "AFSK1200", "-"] logger.info(f"Starting APRS: {' '.join(rtl_fm_cmd)} | {' '.join(decoder_cmd)}") @@ -2762,60 +2711,58 @@ class ModeManager: ) rtl_fm_proc.stdout.close() - self.processes['aprs'] = decoder_proc - self.processes['aprs_rtl'] = rtl_fm_proc + self.processes["aprs"] = decoder_proc + self.processes["aprs_rtl"] = rtl_fm_proc # Wait briefly to verify processes started successfully time.sleep(0.5) if rtl_fm_proc.poll() is not None: - stderr_output = rtl_fm_proc.stderr.read().decode('utf-8', errors='replace') + stderr_output = rtl_fm_proc.stderr.read().decode("utf-8", errors="replace") decoder_proc.terminate() - del self.processes['aprs'] - del self.processes['aprs_rtl'] - return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'} + del self.processes["aprs"] + del self.processes["aprs_rtl"] + return {"status": "error", "message": f"rtl_fm failed to start: {stderr_output[:200]}"} thread = threading.Thread( - target=self._aprs_output_reader, - args=(decoder_proc, direwolf_path is not None), - daemon=True + target=self._aprs_output_reader, args=(decoder_proc, direwolf_path is not None), daemon=True ) thread.start() - self.output_threads['aprs'] = thread + self.output_threads["aprs"] = thread return { - 'status': 'started', - 'mode': 'aprs', - 'frequency': freq, - 'decoder': 'direwolf' if direwolf_path else 'multimon-ng', - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "aprs", + "frequency": freq, + "decoder": "direwolf" if direwolf_path else "multimon-ng", + "gps_enabled": gps_manager.is_running, } except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _aprs_output_reader(self, proc: subprocess.Popen, is_direwolf: bool): """Read and parse APRS packets.""" - mode = 'aprs' + mode = "aprs" stop_event = self.stop_events.get(mode) try: - for line in iter(proc.stdout.readline, b''): + for line in iter(proc.stdout.readline, b""): if stop_event and stop_event.is_set(): break - line = line.decode('utf-8', errors='replace').strip() + line = line.decode("utf-8", errors="replace").strip() if not line: continue parsed = self._parse_aprs_packet(line) if parsed: - parsed['received_at'] = datetime.now(timezone.utc).isoformat() + parsed["received_at"] = datetime.now(timezone.utc).isoformat() gps_pos = gps_manager.position if gps_pos: - parsed['agent_gps'] = gps_pos + parsed["agent_gps"] = gps_pos - callsign = parsed.get('callsign') + callsign = parsed.get("callsign") if callsign: self.aprs_stations[callsign] = parsed @@ -2834,55 +2781,55 @@ class ModeManager: finally: with contextlib.suppress(Exception): proc.wait(timeout=1) - if 'aprs_rtl' in self.processes: + if "aprs_rtl" in self.processes: try: - rtl_proc = self.processes['aprs_rtl'] + rtl_proc = self.processes["aprs_rtl"] if rtl_proc.poll() is None: rtl_proc.terminate() - del self.processes['aprs_rtl'] + del self.processes["aprs_rtl"] except Exception: pass logger.info("APRS reader stopped") - def _parse_aprs_packet(self, line: str) -> dict | None: - """Parse APRS packet from direwolf or multimon-ng.""" - if not line: - return None - - # Normalize common decoder prefixes before parsing. - # multimon-ng: "AFSK1200: ..." - # direwolf: "[0.4] ...", "[0L] ..." - line = line.strip() - if line.startswith('AFSK1200:'): - line = line[9:].strip() - line = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', line) - - match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line) - if not match: - return None + def _parse_aprs_packet(self, line: str) -> dict | None: + """Parse APRS packet from direwolf or multimon-ng.""" + if not line: + return None + + # Normalize common decoder prefixes before parsing. + # multimon-ng: "AFSK1200: ..." + # direwolf: "[0.4] ...", "[0L] ..." + line = line.strip() + if line.startswith("AFSK1200:"): + line = line[9:].strip() + line = re.sub(r"^(?:\[[^\]]+\]\s*)+", "", line) + + match = re.match(r"([A-Z0-9-]+)>([^:]+):(.+)", line) + if not match: + return None callsign = match.group(1) path = match.group(2) data = match.group(3) packet = { - 'type': 'aprs', - 'callsign': callsign, - 'path': path, - 'raw': data, + "type": "aprs", + "callsign": callsign, + "path": path, + "raw": data, } # Try to extract position - pos_match = re.search(r'[!=/@](\d{4}\.\d{2})([NS])[/\\](\d{5}\.\d{2})([EW])', data) + pos_match = re.search(r"[!=/@](\d{4}\.\d{2})([NS])[/\\](\d{5}\.\d{2})([EW])", data) if pos_match: lat = float(pos_match.group(1)[:2]) + float(pos_match.group(1)[2:]) / 60 - if pos_match.group(2) == 'S': + if pos_match.group(2) == "S": lat = -lat lon = float(pos_match.group(3)[:3]) + float(pos_match.group(3)[3:]) / 60 - if pos_match.group(4) == 'W': + if pos_match.group(4) == "W": lon = -lon - packet['lat'] = round(lat, 6) - packet['lon'] = round(lon, 6) + packet["lat"] = round(lat, 6) + packet["lon"] = round(lon, 6) return packet @@ -2892,24 +2839,24 @@ class ModeManager: def _start_rtlamr(self, params: dict) -> dict: """Start utility meter reading using rtl_tcp + rtlamr.""" - freq = params.get('frequency', '912.0') - device = params.get('device', '0') - gain = params.get('gain', '40') - msg_type = params.get('msgtype', 'scm') - filter_id = params.get('filterid') + freq = params.get("frequency", "912.0") + device = params.get("device", "0") + gain = params.get("gain", "40") + msg_type = params.get("msgtype", "scm") + filter_id = params.get("filterid") - rtl_tcp_path = self._get_tool_path('rtl_tcp') - rtlamr_path = self._get_tool_path('rtlamr') + rtl_tcp_path = self._get_tool_path("rtl_tcp") + rtlamr_path = self._get_tool_path("rtlamr") if not rtl_tcp_path: - return {'status': 'error', 'message': 'rtl_tcp not found. Install rtl-sdr.'} + return {"status": "error", "message": "rtl_tcp not found. Install rtl-sdr."} if not rtlamr_path: - return {'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'} + return {"status": "error", "message": "rtlamr not found. Install from https://github.com/bemasher/rtlamr"} # Start rtl_tcp server - rtl_tcp_cmd = [rtl_tcp_path, '-a', '127.0.0.1', '-p', '1234', '-d', str(device)] + rtl_tcp_cmd = [rtl_tcp_path, "-a", "127.0.0.1", "-p", "1234", "-d", str(device)] if gain: - rtl_tcp_cmd.extend(['-g', str(gain)]) + rtl_tcp_cmd.extend(["-g", str(gain)]) logger.info(f"Starting rtl_tcp: {' '.join(rtl_tcp_cmd)}") @@ -2919,24 +2866,24 @@ class ModeManager: stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - self.processes['rtlamr_tcp'] = rtl_tcp_proc + self.processes["rtlamr_tcp"] = rtl_tcp_proc time.sleep(2) if rtl_tcp_proc.poll() is not None: - stderr = rtl_tcp_proc.stderr.read().decode('utf-8', errors='ignore') - return {'status': 'error', 'message': f'rtl_tcp failed: {stderr[:200]}'} + stderr = rtl_tcp_proc.stderr.read().decode("utf-8", errors="ignore") + return {"status": "error", "message": f"rtl_tcp failed: {stderr[:200]}"} # Build rtlamr command rtlamr_cmd = [ rtlamr_path, - '-server=127.0.0.1:1234', - f'-msgtype={msg_type}', - '-format=json', - f'-centerfreq={int(float(freq) * 1e6)}', - '-unique=true', + "-server=127.0.0.1:1234", + f"-msgtype={msg_type}", + "-format=json", + f"-centerfreq={int(float(freq) * 1e6)}", + "-unique=true", ] if filter_id: - rtlamr_cmd.append(f'-filterid={filter_id}') + rtlamr_cmd.append(f"-filterid={filter_id}") logger.info(f"Starting rtlamr: {' '.join(rtlamr_cmd)}") @@ -2945,49 +2892,45 @@ class ModeManager: stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - self.processes['rtlamr'] = rtlamr_proc + self.processes["rtlamr"] = rtlamr_proc - thread = threading.Thread( - target=self._rtlamr_output_reader, - args=(rtlamr_proc,), - daemon=True - ) + thread = threading.Thread(target=self._rtlamr_output_reader, args=(rtlamr_proc,), daemon=True) thread.start() - self.output_threads['rtlamr'] = thread + self.output_threads["rtlamr"] = thread return { - 'status': 'started', - 'mode': 'rtlamr', - 'frequency': freq, - 'msgtype': msg_type, - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "rtlamr", + "frequency": freq, + "msgtype": msg_type, + "gps_enabled": gps_manager.is_running, } except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _rtlamr_output_reader(self, proc: subprocess.Popen): """Read rtlamr JSON output.""" - mode = 'rtlamr' + mode = "rtlamr" stop_event = self.stop_events.get(mode) try: - for line in iter(proc.stdout.readline, b''): + for line in iter(proc.stdout.readline, b""): if stop_event and stop_event.is_set(): break - line = line.decode('utf-8', errors='replace').strip() + line = line.decode("utf-8", errors="replace").strip() if not line: continue try: msg = json.loads(line) - msg['type'] = 'rtlamr' - msg['received_at'] = datetime.now(timezone.utc).isoformat() + msg["type"] = "rtlamr" + msg["received_at"] = datetime.now(timezone.utc).isoformat() gps_pos = gps_manager.position if gps_pos: - msg['agent_gps'] = gps_pos + msg["agent_gps"] = gps_pos snapshots = self.data_snapshots.get(mode, []) snapshots.append(msg) @@ -3007,12 +2950,12 @@ class ModeManager: finally: with contextlib.suppress(Exception): proc.wait(timeout=1) - if 'rtlamr_tcp' in self.processes: + if "rtlamr_tcp" in self.processes: try: - tcp_proc = self.processes['rtlamr_tcp'] + tcp_proc = self.processes["rtlamr_tcp"] if tcp_proc.poll() is None: tcp_proc.terminate() - del self.processes['rtlamr_tcp'] + del self.processes["rtlamr_tcp"] except Exception: pass logger.info("RTLAMR reader stopped") @@ -3023,29 +2966,33 @@ class ModeManager: def _start_dsc(self, params: dict) -> dict: """Start DSC (VHF Channel 70) decoding using Intercept's DSCDecoder.""" - device = params.get('device', '0') - gain = params.get('gain', '40') - ppm = params.get('ppm', '0') - freq = '156.525' # DSC Channel 70 + device = params.get("device", "0") + gain = params.get("gain", "40") + ppm = params.get("ppm", "0") + freq = "156.525" # DSC Channel 70 - rtl_fm_path = self._get_tool_path('rtl_fm') + rtl_fm_path = self._get_tool_path("rtl_fm") if not rtl_fm_path: - return {'status': 'error', 'message': 'rtl_fm not found'} + return {"status": "error", "message": "rtl_fm not found"} # Initialize DSC messages list - if not hasattr(self, 'dsc_messages'): + if not hasattr(self, "dsc_messages"): self.dsc_messages = [] # Build rtl_fm command for DSC (48kHz sample rate) rtl_fm_cmd = [ rtl_fm_path, - '-f', f'{freq}M', - '-s', '48000', - '-g', str(gain), - '-d', str(device), + "-f", + f"{freq}M", + "-s", + "48000", + "-g", + str(gain), + "-d", + str(device), ] - if ppm and str(ppm) != '0': - rtl_fm_cmd.extend(['-p', str(ppm)]) + if ppm and str(ppm) != "0": + rtl_fm_cmd.extend(["-p", str(ppm)]) logger.info(f"Starting DSC: {' '.join(rtl_fm_cmd)}") @@ -3055,43 +3002,40 @@ class ModeManager: stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - self.processes['dsc'] = rtl_fm_proc + self.processes["dsc"] = rtl_fm_proc # Wait briefly to verify process started successfully time.sleep(0.5) if rtl_fm_proc.poll() is not None: - stderr_output = rtl_fm_proc.stderr.read().decode('utf-8', errors='replace') - del self.processes['dsc'] - return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'} + stderr_output = rtl_fm_proc.stderr.read().decode("utf-8", errors="replace") + del self.processes["dsc"] + return {"status": "error", "message": f"rtl_fm failed to start: {stderr_output[:200]}"} # Start output reader thread using Intercept's DSCDecoder - thread = threading.Thread( - target=self._dsc_output_reader, - args=(rtl_fm_proc,), - daemon=True - ) + thread = threading.Thread(target=self._dsc_output_reader, args=(rtl_fm_proc,), daemon=True) thread.start() - self.output_threads['dsc'] = thread + self.output_threads["dsc"] = thread return { - 'status': 'started', - 'mode': 'dsc', - 'frequency': freq, - 'channel': 70, - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "dsc", + "frequency": freq, + "channel": 70, + "gps_enabled": gps_manager.is_running, } except Exception as e: - return {'status': 'error', 'message': str(e)} + return {"status": "error", "message": str(e)} def _dsc_output_reader(self, proc: subprocess.Popen): """Read rtl_fm audio and decode DSC using Intercept's DSCDecoder.""" - mode = 'dsc' + mode = "dsc" stop_event = self.stop_events.get(mode) try: # Use Intercept's DSC decoder from utils.dsc.decoder import DSCDecoder + decoder = DSCDecoder(sample_rate=48000) logger.info("Using Intercept's DSCDecoder") @@ -3103,11 +3047,11 @@ class ModeManager: break for message in decoder.process_audio(audio_data): - message['received_at'] = datetime.now(timezone.utc).isoformat() + message["received_at"] = datetime.now(timezone.utc).isoformat() gps_pos = gps_manager.position if gps_pos: - message['agent_gps'] = gps_pos + message["agent_gps"] = gps_pos # Store message self.dsc_messages.append(message) @@ -3135,28 +3079,28 @@ class ModeManager: def _start_tscm(self, params: dict) -> dict: """Start TSCM scanning - uses existing Intercept scanning functions.""" # Initialize state - if not hasattr(self, 'tscm_baseline'): + if not hasattr(self, "tscm_baseline"): self.tscm_baseline = {} - if not hasattr(self, 'tscm_anomalies'): + if not hasattr(self, "tscm_anomalies"): self.tscm_anomalies = [] - if not hasattr(self, 'tscm_rf_signals'): + if not hasattr(self, "tscm_rf_signals"): self.tscm_rf_signals = [] - if not hasattr(self, 'tscm_wifi_clients'): + if not hasattr(self, "tscm_wifi_clients"): self.tscm_wifi_clients = {} self.tscm_anomalies.clear() self.tscm_wifi_clients.clear() # Get params for what to scan - scan_wifi = params.get('wifi', True) - scan_bt = params.get('bluetooth', True) - scan_rf = params.get('rf', True) - wifi_interface = params.get('wifi_interface') or params.get('interface') - bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0') - sdr_device = params.get('sdr_device', params.get('device', 0)) - sweep_type = params.get('sweep_type') + scan_wifi = params.get("wifi", True) + scan_bt = params.get("bluetooth", True) + scan_rf = params.get("rf", True) + wifi_interface = params.get("wifi_interface") or params.get("interface") + bt_adapter = params.get("bt_interface") or params.get("adapter", "hci0") + sdr_device = params.get("sdr_device", params.get("device", 0)) + sweep_type = params.get("sweep_type") # Get baseline_id for comparison (same as local mode) - baseline_id = params.get('baseline_id') + baseline_id = params.get("baseline_id") started_scans = [] @@ -3164,29 +3108,37 @@ class ModeManager: thread = threading.Thread( target=self._tscm_scanner_thread, args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id, sweep_type), - daemon=True + daemon=True, ) thread.start() - self.output_threads['tscm'] = thread + self.output_threads["tscm"] = thread if scan_wifi: - started_scans.append('wifi') + started_scans.append("wifi") if scan_bt: - started_scans.append('bluetooth') + started_scans.append("bluetooth") if scan_rf: - started_scans.append('rf') + started_scans.append("rf") return { - 'status': 'started', - 'mode': 'tscm', - 'note': f'TSCM scanning {", ".join(started_scans) if started_scans else "using existing data"}', - 'gps_enabled': gps_manager.is_running, - 'scanning': started_scans + "status": "started", + "mode": "tscm", + "note": f"TSCM scanning {', '.join(started_scans) if started_scans else 'using existing data'}", + "gps_enabled": gps_manager.is_running, + "scanning": started_scans, } - def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool, - wifi_interface: str | None, bt_adapter: str, sdr_device: int, - baseline_id: int | None = None, sweep_type: str | None = None): + def _tscm_scanner_thread( + self, + scan_wifi: bool, + scan_bt: bool, + scan_rf: bool, + wifi_interface: str | None, + bt_adapter: str, + sdr_device: int, + baseline_id: int | None = None, + sweep_type: str | None = None, + ): """Combined TSCM scanner using existing Intercept functions. NOTE: This matches local mode behavior exactly: @@ -3195,19 +3147,21 @@ class ModeManager: - Each new device seen during sweep is analyzed once """ logger.info("TSCM thread starting...") - mode = 'tscm' + mode = "tscm" stop_event = self.stop_events.get(mode) # Import existing Intercept TSCM functions from routes.tscm import _scan_bluetooth_devices, _scan_rf_signals, _scan_wifi_clients, _scan_wifi_networks + logger.info("TSCM imports successful") sweep_ranges = None if sweep_type: try: from data.tscm_frequencies import SWEEP_PRESETS, get_sweep_preset - preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard') - sweep_ranges = preset.get('ranges') if preset else None + + preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get("standard") + sweep_ranges = preset.get("ranges") if preset else None except Exception: sweep_ranges = None @@ -3247,9 +3201,9 @@ class ModeManager: # WiFi scan using Intercept's function (same as local mode) if scan_wifi: try: - wifi_networks = _scan_wifi_networks(wifi_interface or '') + wifi_networks = _scan_wifi_networks(wifi_interface or "") for net in wifi_networks: - bssid = net.get('bssid', '').upper() + bssid = net.get("bssid", "").upper() if bssid and bssid not in seen_wifi: # First time seeing this device during sweep seen_wifi[bssid] = net @@ -3257,16 +3211,16 @@ class ModeManager: # Enrich with classification/scoring enriched = dict(net) # Ensure power/signal is numeric (scanner may return string) - if 'power' in enriched: + if "power" in enriched: try: - enriched['power'] = int(enriched['power']) + enriched["power"] = int(enriched["power"]) except (ValueError, TypeError): - enriched['power'] = -100 - if 'signal' in enriched and enriched['signal'] is not None: + enriched["power"] = -100 + if "signal" in enriched and enriched["signal"] is not None: try: - enriched['signal'] = int(enriched['signal']) + enriched["signal"] = int(enriched["signal"]) except (ValueError, TypeError): - enriched['signal'] = -100 + enriched["signal"] = -100 # Analyze for threats (same as local mode) if self._tscm_detector: @@ -3275,63 +3229,64 @@ class ModeManager: self.tscm_anomalies.append(threat) if len(self.tscm_anomalies) > 100: self.tscm_anomalies = self.tscm_anomalies[-100:] - print(f"[TSCM] WiFi threat: {threat.get('threat_type')} - {threat.get('name')}", flush=True) + print( + f"[TSCM] WiFi threat: {threat.get('threat_type')} - {threat.get('name')}", + flush=True, + ) classification = self._tscm_detector.classify_wifi_device(enriched) - enriched['is_new'] = not classification.get('in_baseline', False) - enriched['reasons'] = classification.get('reasons', []) + enriched["is_new"] = not classification.get("in_baseline", False) + enriched["reasons"] = classification.get("reasons", []) if self._tscm_correlation: profile = self._tscm_correlation.analyze_wifi_device(enriched) - enriched['classification'] = profile.risk_level.value - enriched['score'] = profile.total_score - enriched['score_modifier'] = profile.score_modifier - enriched['known_device'] = profile.known_device - enriched['known_device_name'] = profile.known_device_name - enriched['indicators'] = [ - {'type': i.type.value, 'desc': i.description} - for i in profile.indicators + enriched["classification"] = profile.risk_level.value + enriched["score"] = profile.total_score + enriched["score_modifier"] = profile.score_modifier + enriched["known_device"] = profile.known_device + enriched["known_device_name"] = profile.known_device_name + enriched["indicators"] = [ + {"type": i.type.value, "desc": i.description} for i in profile.indicators ] - enriched['recommended_action'] = profile.recommended_action + enriched["recommended_action"] = profile.recommended_action self.wifi_networks[bssid] = enriched # WiFi clients (monitor mode only) try: - wifi_clients = _scan_wifi_clients(wifi_interface or '') + wifi_clients = _scan_wifi_clients(wifi_interface or "") for client in wifi_clients: - mac = (client.get('mac') or '').upper() + mac = (client.get("mac") or "").upper() if not mac or mac in seen_wifi_clients: continue seen_wifi_clients[mac] = client - rssi_val = client.get('rssi_current') + rssi_val = client.get("rssi_current") if rssi_val is None: - rssi_val = client.get('rssi_median') or client.get('rssi_ema') + rssi_val = client.get("rssi_median") or client.get("rssi_ema") client_device = { - 'mac': mac, - 'vendor': client.get('vendor'), - 'name': client.get('vendor') or 'WiFi Client', - 'rssi': rssi_val, - 'associated_bssid': client.get('associated_bssid'), - 'probed_ssids': client.get('probed_ssids', []), - 'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))), - 'is_client': True, + "mac": mac, + "vendor": client.get("vendor"), + "name": client.get("vendor") or "WiFi Client", + "rssi": rssi_val, + "associated_bssid": client.get("associated_bssid"), + "probed_ssids": client.get("probed_ssids", []), + "probe_count": client.get("probe_count", len(client.get("probed_ssids", []))), + "is_client": True, } if self._tscm_correlation: profile = self._tscm_correlation.analyze_wifi_device(client_device) - client_device['classification'] = profile.risk_level.value - client_device['score'] = profile.total_score - client_device['score_modifier'] = profile.score_modifier - client_device['known_device'] = profile.known_device - client_device['known_device_name'] = profile.known_device_name - client_device['indicators'] = [ - {'type': i.type.value, 'desc': i.description} - for i in profile.indicators + client_device["classification"] = profile.risk_level.value + client_device["score"] = profile.total_score + client_device["score_modifier"] = profile.score_modifier + client_device["known_device"] = profile.known_device + client_device["known_device_name"] = profile.known_device_name + client_device["indicators"] = [ + {"type": i.type.value, "desc": i.description} for i in profile.indicators ] - client_device['recommended_action'] = profile.recommended_action + client_device["recommended_action"] = profile.recommended_action self.tscm_wifi_clients[mac] = client_device except Exception as e: @@ -3344,7 +3299,7 @@ class ModeManager: try: bt_devices = _scan_bluetooth_devices(bt_adapter, duration=5) for dev in bt_devices: - mac = dev.get('mac', '').upper() + mac = dev.get("mac", "").upper() if mac and mac not in seen_bt: # First time seeing this device during sweep seen_bt[mac] = dev @@ -3352,11 +3307,11 @@ class ModeManager: # Enrich with classification/scoring enriched = dict(dev) # Ensure rssi/signal is numeric (scanner may return string) - if 'rssi' in enriched and enriched['rssi'] is not None: + if "rssi" in enriched and enriched["rssi"] is not None: try: - enriched['rssi'] = int(enriched['rssi']) + enriched["rssi"] = int(enriched["rssi"]) except (ValueError, TypeError): - enriched['rssi'] = -100 + enriched["rssi"] = -100 # Analyze for threats (same as local mode) if self._tscm_detector: @@ -3365,24 +3320,25 @@ class ModeManager: self.tscm_anomalies.append(threat) if len(self.tscm_anomalies) > 100: self.tscm_anomalies = self.tscm_anomalies[-100:] - logger.info(f"TSCM BT threat: {threat.get('threat_type')} - {threat.get('name')}") + logger.info( + f"TSCM BT threat: {threat.get('threat_type')} - {threat.get('name')}" + ) classification = self._tscm_detector.classify_bt_device(enriched) - enriched['is_new'] = not classification.get('in_baseline', False) - enriched['reasons'] = classification.get('reasons', []) + enriched["is_new"] = not classification.get("in_baseline", False) + enriched["reasons"] = classification.get("reasons", []) if self._tscm_correlation: profile = self._tscm_correlation.analyze_bluetooth_device(enriched) - enriched['classification'] = profile.risk_level.value - enriched['score'] = profile.total_score - enriched['score_modifier'] = profile.score_modifier - enriched['known_device'] = profile.known_device - enriched['known_device_name'] = profile.known_device_name - enriched['indicators'] = [ - {'type': i.type.value, 'desc': i.description} - for i in profile.indicators + enriched["classification"] = profile.risk_level.value + enriched["score"] = profile.total_score + enriched["score_modifier"] = profile.score_modifier + enriched["known_device"] = profile.known_device + enriched["known_device_name"] = profile.known_device_name + enriched["indicators"] = [ + {"type": i.type.value, "desc": i.description} for i in profile.indicators ] - enriched['recommended_action'] = profile.recommended_action + enriched["recommended_action"] = profile.recommended_action self.bluetooth_devices[mac] = enriched except Exception as e: @@ -3394,10 +3350,9 @@ class ModeManager: # Pass a stop check that uses our stop_event (not the module's _sweep_running) def agent_stop_check(): return stop_event and stop_event.is_set() + rf_signals = _scan_rf_signals( - sdr_device, - stop_check=agent_stop_check, - sweep_ranges=sweep_ranges + sdr_device, stop_check=agent_stop_check, sweep_ranges=sweep_ranges ) # Analyze each RF signal like local mode does @@ -3408,29 +3363,28 @@ class ModeManager: is_threat = False # Use detector to analyze for threats (same as local mode) - if hasattr(self, '_tscm_detector') and self._tscm_detector: + if hasattr(self, "_tscm_detector") and self._tscm_detector: threat = self._tscm_detector.analyze_rf_signal(signal) if threat: rf_threats.append(threat) is_threat = True classification = self._tscm_detector.classify_rf_signal(signal) - analyzed['is_new'] = not classification.get('in_baseline', False) - analyzed['reasons'] = classification.get('reasons', []) + analyzed["is_new"] = not classification.get("in_baseline", False) + analyzed["reasons"] = classification.get("reasons", []) # Use correlation engine for scoring (same as local mode) - if hasattr(self, '_tscm_correlation') and self._tscm_correlation: + if hasattr(self, "_tscm_correlation") and self._tscm_correlation: profile = self._tscm_correlation.analyze_rf_signal(signal) - analyzed['classification'] = profile.risk_level.value - analyzed['score'] = profile.total_score - analyzed['score_modifier'] = profile.score_modifier - analyzed['known_device'] = profile.known_device - analyzed['known_device_name'] = profile.known_device_name - analyzed['indicators'] = [ - {'type': i.type.value, 'desc': i.description} - for i in profile.indicators + analyzed["classification"] = profile.risk_level.value + analyzed["score"] = profile.total_score + analyzed["score_modifier"] = profile.score_modifier + analyzed["known_device"] = profile.known_device + analyzed["known_device_name"] = profile.known_device_name + analyzed["indicators"] = [ + {"type": i.type.value, "desc": i.description} for i in profile.indicators ] - analyzed['is_threat'] = is_threat + analyzed["is_threat"] = is_threat analyzed_signals.append(analyzed) # Add RF threats to anomalies list @@ -3462,47 +3416,51 @@ class ModeManager: def _start_satellite(self, params: dict) -> dict: """Start satellite pass prediction - no SDR needed.""" - lat = params.get('lat', params.get('latitude')) - lon = params.get('lon', params.get('longitude')) - min_elevation = params.get('min_elevation', 10) + lat = params.get("lat", params.get("latitude")) + lon = params.get("lon", params.get("longitude")) + min_elevation = params.get("min_elevation", 10) if lat is None or lon is None: gps_pos = gps_manager.position if gps_pos: - lat = gps_pos.get('lat') - lon = gps_pos.get('lon') + lat = gps_pos.get("lat") + lon = gps_pos.get("lon") if lat is None or lon is None: - return {'status': 'error', 'message': 'Observer location required (lat/lon)'} + return {"status": "error", "message": "Observer location required (lat/lon)"} thread = threading.Thread( - target=self._satellite_predictor, - args=(float(lat), float(lon), int(min_elevation)), - daemon=True + target=self._satellite_predictor, args=(float(lat), float(lon), int(min_elevation)), daemon=True ) thread.start() - self.output_threads['satellite'] = thread + self.output_threads["satellite"] = thread return { - 'status': 'started', - 'mode': 'satellite', - 'observer': {'lat': lat, 'lon': lon}, - 'min_elevation': min_elevation, - 'note': 'Satellite pass prediction - no SDR required' + "status": "started", + "mode": "satellite", + "observer": {"lat": lat, "lon": lon}, + "min_elevation": min_elevation, + "note": "Satellite pass prediction - no SDR required", } def _satellite_predictor(self, lat: float, lon: float, min_elevation: int): """Calculate satellite passes using TLE data.""" - mode = 'satellite' + mode = "satellite" stop_event = self.stop_events.get(mode) try: - from skyfield.api import Topos, load + from skyfield.api import Loader, Topos - stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle' + # Use a dedicated TLE directory — the default loader downloads into + # the current working directory, littering it with 'gp.php' + tle_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "tle") + os.makedirs(tle_dir, exist_ok=True) + load = Loader(tle_dir, verbose=False) + + stations_url = "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle" satellites = load.tle_file(stations_url) - ts = load.timescale(builtin=True) + ts = load.timescale(builtin=True) observer = Topos(latitude_degrees=lat, longitude_degrees=lon) logger.info(f"Satellite predictor: {len(satellites)} satellites loaded") @@ -3510,8 +3468,7 @@ class ModeManager: while not (stop_event and stop_event.is_set()): passes = [] now = ts.now() - end = ts.utc(now.utc_datetime().year, now.utc_datetime().month, - now.utc_datetime().day + 1) + end = ts.utc(now.utc_datetime().year, now.utc_datetime().month, now.utc_datetime().day + 1) for sat in satellites[:20]: try: @@ -3522,12 +3479,14 @@ class ModeManager: difference = sat - observer topocentric = difference.at(ti) alt, az, _ = topocentric.altaz() - passes.append({ - 'satellite': sat.name, - 'rise_time': ti.utc_iso(), - 'rise_azimuth': round(az.degrees, 1), - 'max_elevation': min_elevation, - }) + passes.append( + { + "satellite": sat.name, + "rise_time": ti.utc_iso(), + "rise_azimuth": round(az.degrees, 1), + "max_elevation": min_elevation, + } + ) except Exception: continue @@ -3536,7 +3495,7 @@ class ModeManager: except ImportError: logger.warning("skyfield not installed - satellite prediction unavailable") - self.data_snapshots[mode] = [{'error': 'skyfield not installed'}] + self.data_snapshots[mode] = [{"error": "skyfield not installed"}] except Exception as e: logger.error(f"Satellite predictor error: {e}") @@ -3553,33 +3512,33 @@ class ModeManager: Note: Full FFT streaming isn't practical over HTTP agents. Instead provides signal detection events and activity log. """ - start_freq = params.get('start_freq', 88.0) - end_freq = params.get('end_freq', 108.0) + start_freq = params.get("start_freq", 88.0) + end_freq = params.get("end_freq", 108.0) # Step is sent in kHz from frontend, convert to MHz - step_khz = params.get('step', 100) + step_khz = params.get("step", 100) step = step_khz / 1000.0 # Convert kHz to MHz - modulation = params.get('modulation', 'wfm') - squelch = params.get('squelch', 20) - device = params.get('device', '0') - gain = params.get('gain', '40') - dwell_time = params.get('dwell_time', 1.0) + modulation = params.get("modulation", "wfm") + squelch = params.get("squelch", 20) + device = params.get("device", "0") + gain = params.get("gain", "40") + dwell_time = params.get("dwell_time", 1.0) - rtl_fm_path = self._get_tool_path('rtl_fm') + rtl_fm_path = self._get_tool_path("rtl_fm") if not rtl_fm_path: - return {'status': 'error', 'message': 'rtl_fm not found'} + return {"status": "error", "message": "rtl_fm not found"} # Quick SDR availability check - try to run rtl_fm briefly test_proc = None try: test_proc = subprocess.Popen( - [rtl_fm_path, '-f', f'{start_freq}M', '-d', str(device), '-g', str(gain)], + [rtl_fm_path, "-f", f"{start_freq}M", "-d", str(device), "-g", str(gain)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) time.sleep(0.5) if test_proc.poll() is not None: - stderr = test_proc.stderr.read().decode('utf-8', errors='ignore') - return {'status': 'error', 'message': f'SDR not available: {stderr[:200]}'} + stderr = test_proc.stderr.read().decode("utf-8", errors="ignore") + return {"status": "error", "message": f"SDR not available: {stderr[:200]}"} # SDR is available - terminate test process test_proc.terminate() try: @@ -3593,47 +3552,63 @@ class ModeManager: test_proc.kill() with contextlib.suppress(Exception): test_proc.wait(timeout=1) - return {'status': 'error', 'message': f'SDR check failed: {str(e)}'} + return {"status": "error", "message": f"SDR check failed: {str(e)}"} # Initialize state - if not hasattr(self, 'listening_post_activity'): + if not hasattr(self, "listening_post_activity"): self.listening_post_activity = [] self.listening_post_activity.clear() self.listening_post_current_freq = float(start_freq) thread = threading.Thread( target=self._listening_post_scanner, - args=(float(start_freq), float(end_freq), float(step), - modulation, int(squelch), str(device), str(gain), float(dwell_time)), - daemon=True + args=( + float(start_freq), + float(end_freq), + float(step), + modulation, + int(squelch), + str(device), + str(gain), + float(dwell_time), + ), + daemon=True, ) thread.start() - self.output_threads['listening_post'] = thread + self.output_threads["listening_post"] = thread return { - 'status': 'started', - 'mode': 'listening_post', - 'start_freq': start_freq, - 'end_freq': end_freq, - 'step': step, - 'modulation': modulation, - 'dwell_time': dwell_time, - 'note': 'Provides signal detection events, not full FFT data', - 'gps_enabled': gps_manager.is_running + "status": "started", + "mode": "listening_post", + "start_freq": start_freq, + "end_freq": end_freq, + "step": step, + "modulation": modulation, + "dwell_time": dwell_time, + "note": "Provides signal detection events, not full FFT data", + "gps_enabled": gps_manager.is_running, } - def _listening_post_scanner(self, start_freq: float, end_freq: float, - step: float, modulation: str, squelch: int, - device: str, gain: str, dwell_time: float = 1.0): + def _listening_post_scanner( + self, + start_freq: float, + end_freq: float, + step: float, + modulation: str, + squelch: int, + device: str, + gain: str, + dwell_time: float = 1.0, + ): """Scan frequency range and report signal detections.""" import fcntl import os import select - mode = 'listening_post' + mode = "listening_post" stop_event = self.stop_events.get(mode) - rtl_fm_path = self._get_tool_path('rtl_fm') + rtl_fm_path = self._get_tool_path("rtl_fm") current_freq = start_freq scan_direction = 1 self.listening_post_freqs_scanned = 0 @@ -3645,12 +3620,18 @@ class ModeManager: cmd = [ rtl_fm_path, - '-f', f'{current_freq}M', - '-M', modulation, - '-s', '22050', - '-g', gain, - '-d', device, - '-l', str(squelch), + "-f", + f"{current_freq}M", + "-M", + modulation, + "-s", + "22050", + "-g", + gain, + "-d", + device, + "-l", + str(squelch), ] try: @@ -3680,10 +3661,12 @@ class ModeManager: if data and len(data) > 10: # Simple signal detection via audio level try: - samples = [int.from_bytes(data[i:i+2], 'little', signed=True) - for i in range(0, min(len(data)-1, 1000), 2)] + samples = [ + int.from_bytes(data[i : i + 2], "little", signed=True) + for i in range(0, min(len(data) - 1, 1000), 2) + ] if samples: - rms = (sum(s*s for s in samples) / len(samples)) ** 0.5 + rms = (sum(s * s for s in samples) / len(samples)) ** 0.5 if rms > 500: signal_detected = True except Exception: @@ -3702,15 +3685,15 @@ class ModeManager: if signal_detected: event = { - 'type': 'signal_found', - 'frequency': current_freq, - 'modulation': modulation, - 'detected_at': datetime.now(timezone.utc).isoformat() + "type": "signal_found", + "frequency": current_freq, + "modulation": modulation, + "detected_at": datetime.now(timezone.utc).isoformat(), } gps_pos = gps_manager.position if gps_pos: - event['agent_gps'] = gps_pos + event["agent_gps"] = gps_pos self.listening_post_activity.append(event) if len(self.listening_post_activity) > 500: @@ -3745,6 +3728,7 @@ _start_time = time.time() # Data Push Loop # ============================================================================= + class DataPushLoop(threading.Thread): """Background thread that periodically pushes mode data to controller.""" @@ -3764,12 +3748,8 @@ class DataPushLoop(threading.Thread): for mode in list(mode_manager.running_modes.keys()): try: data = mode_manager.get_mode_data(mode) - if data.get('data'): # Only push if there's data - push_client.enqueue( - scan_type=mode, - payload=data, - interface=None - ) + if data.get("data"): # Only push if there's data + push_client.enqueue(scan_type=mode, payload=data, interface=None) except Exception as e: logger.warning(f"Failed to push {mode} data: {e}") @@ -3791,6 +3771,7 @@ data_push_loop: DataPushLoop | None = None # HTTP Request Handler # ============================================================================= + class InterceptAgentHandler(BaseHTTPRequestHandler): """HTTP request handler for the agent API.""" @@ -3808,36 +3789,36 @@ class InterceptAgentHandler(BaseHTTPRequestHandler): def _send_json(self, data: dict, status: int = 200): """Send JSON response.""" - body = json.dumps(data).encode('utf-8') + body = json.dumps(data).encode("utf-8") self.send_response(status) - self.send_header('Content-Type', 'application/json') - self.send_header('Content-Length', len(body)) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", len(body)) if config.allow_cors: - self.send_header('Access-Control-Allow-Origin', '*') + self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(body) def _send_error(self, message: str, status: int = 400): """Send error response.""" - self._send_json({'error': message}, status) + self._send_json({"error": message}, status) def _read_body(self) -> dict: """Read and parse JSON body.""" - content_length = int(self.headers.get('Content-Length', 0)) + content_length = int(self.headers.get("Content-Length", 0)) if content_length == 0: return {} body = self.rfile.read(content_length) try: - return json.loads(body.decode('utf-8')) + return json.loads(body.decode("utf-8")) except json.JSONDecodeError: return {} def _parse_path(self) -> tuple[str, dict]: """Parse URL path and query parameters.""" parsed = urlparse(self.path) - path = parsed.path.rstrip('/') + path = parsed.path.rstrip("/") query = parse_qs(parsed.query) # Flatten single-value query params params = {k: v[0] if len(v) == 1 else v for k, v in query.items()} @@ -3847,109 +3828,113 @@ class InterceptAgentHandler(BaseHTTPRequestHandler): """Handle CORS preflight.""" self.send_response(204) if config.allow_cors: - self.send_header('Access-Control-Allow-Origin', '*') - self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') - self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key') + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, X-API-Key") self.end_headers() def do_GET(self): """Handle GET requests.""" if not self._check_ip_allowed(): - self._send_error('Forbidden', 403) + self._send_error("Forbidden", 403) return path, params = self._parse_path() # Route handling - if path == '/capabilities': + if path == "/capabilities": self._send_json(mode_manager.detect_capabilities()) - elif path == '/status': + elif path == "/status": self._send_json(mode_manager.get_status()) - elif path == '/health': - self._send_json({'status': 'healthy', 'version': AGENT_VERSION}) + elif path == "/health": + self._send_json({"status": "healthy", "version": AGENT_VERSION}) - elif path == '/gps': + elif path == "/gps": gps_pos = gps_manager.position - self._send_json({ - 'available': gps_manager.is_running, - 'position': gps_pos, - }) + self._send_json( + { + "available": gps_manager.is_running, + "position": gps_pos, + } + ) - elif path == '/config': + elif path == "/config": # Return non-sensitive config cfg = config.to_dict() - if 'controller_api_key' in cfg: - del cfg['controller_api_key'] + if "controller_api_key" in cfg: + del cfg["controller_api_key"] self._send_json(cfg) - elif path.startswith('/') and path.count('/') == 2: + elif path.startswith("/") and path.count("/") == 2: # /{mode}/status or /{mode}/data - parts = path.split('/') + parts = path.split("/") mode = parts[1] action = parts[2] - if action == 'status': + if action == "status": self._send_json(mode_manager.get_mode_status(mode)) - elif action == 'data': + elif action == "data": self._send_json(mode_manager.get_mode_data(mode)) else: - self._send_error('Not found', 404) + self._send_error("Not found", 404) else: - self._send_error('Not found', 404) + self._send_error("Not found", 404) def do_POST(self): """Handle POST requests.""" if not self._check_ip_allowed(): - self._send_error('Forbidden', 403) + self._send_error("Forbidden", 403) return path, _ = self._parse_path() body = self._read_body() - if path == '/config': + if path == "/config": # Update running config (limited fields) - if 'push_enabled' in body: - config.push_enabled = bool(body['push_enabled']) - if 'push_interval' in body: - config.push_interval = int(body['push_interval']) - self._send_json({'status': 'updated', 'config': config.to_dict()}) + if "push_enabled" in body: + config.push_enabled = bool(body["push_enabled"]) + if "push_interval" in body: + config.push_interval = int(body["push_interval"]) + self._send_json({"status": "updated", "config": config.to_dict()}) - elif path == '/wifi/monitor': + elif path == "/wifi/monitor": # Enable/disable monitor mode on WiFi interface result = mode_manager.toggle_monitor_mode(body) - status = 200 if result.get('status') == 'success' else 400 + status = 200 if result.get("status") == "success" else 400 self._send_json(result, status) - elif path.startswith('/') and path.count('/') == 2: + elif path.startswith("/") and path.count("/") == 2: # /{mode}/start or /{mode}/stop - parts = path.split('/') + parts = path.split("/") mode = parts[1] action = parts[2] - if action == 'start': + if action == "start": result = mode_manager.start_mode(mode, body) # Accept both 'started' and 'success' as valid (quick scans return 'success') - status = 200 if result.get('status') in ('started', 'success') else 400 + status = 200 if result.get("status") in ("started", "success") else 400 self._send_json(result, status) - elif action == 'stop': + elif action == "stop": result = mode_manager.stop_mode(mode) self._send_json(result) else: - self._send_error('Not found', 404) + self._send_error("Not found", 404) else: - self._send_error('Not found', 404) + self._send_error("Not found", 404) # ============================================================================= # Threaded HTTP Server # ============================================================================= + class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): """Multi-threaded HTTP server.""" + allow_reuse_address = True daemon_threads = True @@ -3958,49 +3943,21 @@ class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): # Main # ============================================================================= + def main(): global config, push_client, _start_time - parser = argparse.ArgumentParser( - description='INTERCEPT Agent - Remote signal intelligence node' - ) + parser = argparse.ArgumentParser(description="INTERCEPT Agent - Remote signal intelligence node") + parser.add_argument("--port", "-p", type=int, default=8020, help="Port to listen on (default: 8020)") parser.add_argument( - '--port', '-p', - type=int, - default=8020, - help='Port to listen on (default: 8020)' - ) - parser.add_argument( - '--config', '-c', - default='intercept_agent.cfg', - help='Configuration file (default: intercept_agent.cfg)' - ) - parser.add_argument( - '--name', '-n', - help='Agent name (overrides config file)' - ) - parser.add_argument( - '--controller', - help='Controller URL for push mode' - ) - parser.add_argument( - '--api-key', - help='API key for controller authentication' - ) - parser.add_argument( - '--allowed-ips', - help='Comma-separated list of allowed client IPs' - ) - parser.add_argument( - '--cors', - action='store_true', - help='Enable CORS headers' - ) - parser.add_argument( - '--debug', - action='store_true', - help='Enable debug logging' + "--config", "-c", default="intercept_agent.cfg", help="Configuration file (default: intercept_agent.cfg)" ) + parser.add_argument("--name", "-n", help="Agent name (overrides config file)") + parser.add_argument("--controller", help="Controller URL for push mode") + parser.add_argument("--api-key", help="API key for controller authentication") + parser.add_argument("--allowed-ips", help="Comma-separated list of allowed client IPs") + parser.add_argument("--cors", action="store_true", help="Enable CORS headers") + parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() @@ -4019,12 +3976,12 @@ def main(): if args.name: config.name = args.name if args.controller: - config.controller_url = args.controller.rstrip('/') + config.controller_url = args.controller.rstrip("/") config.push_enabled = True if args.api_key: config.controller_api_key = args.api_key if args.allowed_ips: - config.allowed_ips = [ip.strip() for ip in args.allowed_ips.split(',')] + config.allowed_ips = [ip.strip() for ip in args.allowed_ips.split(",")] if args.cors: config.allow_cors = True @@ -4055,14 +4012,14 @@ def main(): # Detect capabilities caps = mode_manager.detect_capabilities() print(" Available Modes:") - for mode, available in caps['modes'].items(): + for mode, available in caps["modes"].items(): status = "OK" if available else "N/A" print(f" - {mode}: {status}") print() - if caps['devices']: + if caps["devices"]: print(" Detected SDR Devices:") - for dev in caps['devices']: + for dev in caps["devices"]: print(f" - [{dev.get('index', '?')}] {dev.get('name', 'Unknown')}") print() @@ -4080,7 +4037,7 @@ def main(): print() # Start HTTP server - server_address = ('', config.port) + server_address = ("", config.port) httpd = ThreadedHTTPServer(server_address, InterceptAgentHandler) print(f" Listening on http://0.0.0.0:{config.port}") @@ -4145,5 +4102,5 @@ def main(): print("Agent stopped.") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index c5535ec..cbd351b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,7 +160,11 @@ exclude = [ testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] -addopts = "-v --tb=short" +# 'live' tests drive real SDR hardware — run explicitly with: pytest -m live +addopts = "-v --tb=short -m 'not live'" +markers = [ + "live: tests that require real SDR hardware and run live decoders", +] [tool.coverage.run] source = ["app", "routes", "utils", "data"] diff --git a/requirements.txt b/requirements.txt index 52deac1..3ba9792 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,8 @@ pyserial>=3.5 # Meshtastic mesh network support (optional - only needed for Meshtastic features) meshtastic>=2.0.0 -meshcore>=1.0.0 +# meshcore 2.3.0+ required for EventType.STATS_CORE; needs Python 3.10+ +meshcore>=2.3.0 # Deauthentication attack detection (optional - for WiFi TSCM) scapy>=2.4.5 diff --git a/routes/controller.py b/routes/controller.py index 500a71b..864e66d 100644 --- a/routes/controller.py +++ b/routes/controller.py @@ -40,16 +40,16 @@ from utils.trilateration import ( estimate_location_from_observations, ) -logger = logging.getLogger('intercept.controller') - -controller_bp = Blueprint('controller', __name__, url_prefix='/controller') -AGENT_HEALTH_TIMEOUT_SECONDS = 2.0 -AGENT_STATUS_TIMEOUT_SECONDS = 2.5 - -# Multi-agent SSE fanout state (per-client queues). -_agent_stream_subscribers: set[queue.Queue] = set() -_agent_stream_subscribers_lock = threading.Lock() -_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500 +logger = logging.getLogger("intercept.controller") + +controller_bp = Blueprint("controller", __name__, url_prefix="/controller") +AGENT_HEALTH_TIMEOUT_SECONDS = 2.0 +AGENT_STATUS_TIMEOUT_SECONDS = 2.5 + +# Multi-agent SSE fanout state (per-client queues). +_agent_stream_subscribers: set[queue.Queue] = set() +_agent_stream_subscribers_lock = threading.Lock() +_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500 def _broadcast_agent_data(payload: dict) -> None: @@ -72,34 +72,31 @@ def _broadcast_agent_data(payload: dict) -> None: # Agent CRUD # ============================================================================= -@controller_bp.route('/agents', methods=['GET']) + +@controller_bp.route("/agents", methods=["GET"]) def get_agents(): """List all registered agents.""" - active_only = request.args.get('active_only', 'true').lower() == 'true' + active_only = request.args.get("active_only", "true").lower() == "true" agents = list_agents(active_only=active_only) # Optionally refresh status for each agent - refresh = request.args.get('refresh', 'false').lower() == 'true' - if refresh: - for agent in agents: - try: - client = AgentClient( - agent['base_url'], - api_key=agent.get('api_key'), - timeout=AGENT_HEALTH_TIMEOUT_SECONDS, - ) - agent['healthy'] = client.health_check() - except Exception: - agent['healthy'] = False + refresh = request.args.get("refresh", "false").lower() == "true" + if refresh: + for agent in agents: + try: + client = AgentClient( + agent["base_url"], + api_key=agent.get("api_key"), + timeout=AGENT_HEALTH_TIMEOUT_SECONDS, + ) + agent["healthy"] = client.health_check() + except Exception: + agent["healthy"] = False - return jsonify({ - 'status': 'success', - 'agents': agents, - 'count': len(agents) - }) + return jsonify({"status": "success", "agents": agents, "count": len(agents)}) -@controller_bp.route('/agents', methods=['POST']) +@controller_bp.route("/agents", methods=["POST"]) def register_agent(): """ Register a new remote agent. @@ -115,24 +112,25 @@ def register_agent(): data = request.json or {} # Validate required fields - name = data.get('name', '').strip() - base_url = data.get('base_url', '').strip() + name = data.get("name", "").strip() + base_url = data.get("base_url", "").strip() if not name: - return api_error('Agent name is required', 400) + return api_error("Agent name is required", 400) if not base_url: - return api_error('Base URL is required', 400) + return api_error("Base URL is required", 400) # Validate URL format from urllib.parse import urlparse + try: parsed = urlparse(base_url) - if parsed.scheme not in ('http', 'https'): - return api_error('URL must start with http:// or https://', 400) + if parsed.scheme not in ("http", "https"): + return api_error("URL must start with http:// or https://", 400) if not parsed.netloc: - return api_error('Invalid URL format', 400) + return api_error("Invalid URL format", 400) except Exception: - return api_error('Invalid URL format', 400) + return api_error("Invalid URL format", 400) # Check if agent already exists existing = get_agent_by_name(name) @@ -140,15 +138,15 @@ def register_agent(): return api_error(f'Agent with name "{name}" already exists', 409) # Try to connect and get capabilities - api_key = data.get('api_key', '').strip() or None + api_key = data.get("api_key", "").strip() or None client = AgentClient(base_url, api_key=api_key) capabilities = None interfaces = None try: caps = client.get_capabilities() - capabilities = caps.get('modes', {}) - interfaces = {'devices': caps.get('devices', [])} + capabilities = caps.get("modes", {}) + interfaces = {"devices": caps.get("devices", [])} except (AgentHTTPError, AgentConnectionError) as e: logger.warning(f"Could not fetch capabilities from {base_url}: {e}") @@ -158,9 +156,9 @@ def register_agent(): name=name, base_url=base_url, api_key=api_key, - description=data.get('description'), + description=data.get("description"), capabilities=capabilities, - interfaces=interfaces + interfaces=interfaces, ) # Update last_seen since we just connected @@ -168,151 +166,131 @@ def register_agent(): update_agent(agent_id, update_last_seen=True) agent = get_agent(agent_id) - message = 'Agent registered successfully' + message = "Agent registered successfully" if capabilities is None: - message += ' (could not connect - agent may be offline)' - return jsonify({ - 'status': 'success', - 'message': message, - 'agent': agent - }), 201 + message += " (could not connect - agent may be offline)" + return jsonify({"status": "success", "message": message, "agent": agent}), 201 except Exception as e: logger.exception("Failed to create agent") return api_error(str(e), 500) -@controller_bp.route('/agents/', methods=['GET']) +@controller_bp.route("/agents/", methods=["GET"]) def get_agent_detail(agent_id: int): """Get details of a specific agent.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) # Optionally refresh from agent - refresh = request.args.get('refresh', 'false').lower() == 'true' + refresh = request.args.get("refresh", "false").lower() == "true" if refresh: try: client = create_client_from_agent(agent) metadata = client.refresh_metadata() - if metadata['healthy']: - caps = metadata['capabilities'] or {} + if metadata["healthy"]: + caps = metadata["capabilities"] or {} # Store full interfaces structure (wifi, bt, sdr) - agent_interfaces = caps.get('interfaces', {}) + agent_interfaces = caps.get("interfaces", {}) # Fallback: also include top-level devices for backwards compatibility - if not agent_interfaces.get('sdr_devices') and caps.get('devices'): - agent_interfaces['sdr_devices'] = caps.get('devices', []) + if not agent_interfaces.get("sdr_devices") and caps.get("devices"): + agent_interfaces["sdr_devices"] = caps.get("devices", []) update_agent( - agent_id, - capabilities=caps.get('modes'), - interfaces=agent_interfaces, - update_last_seen=True + agent_id, capabilities=caps.get("modes"), interfaces=agent_interfaces, update_last_seen=True ) agent = get_agent(agent_id) - agent['healthy'] = True + agent["healthy"] = True else: - agent['healthy'] = False + agent["healthy"] = False except Exception: - agent['healthy'] = False + agent["healthy"] = False - return jsonify({'status': 'success', 'agent': agent}) + return jsonify({"status": "success", "agent": agent}) -@controller_bp.route('/agents/', methods=['PUT', 'PATCH']) +@controller_bp.route("/agents/", methods=["PUT", "PATCH"]) def update_agent_detail(agent_id: int): """Update an agent's details.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) data = request.json or {} # Update allowed fields update_agent( agent_id, - base_url=data.get('base_url'), - description=data.get('description'), - api_key=data.get('api_key'), - is_active=data.get('is_active') + base_url=data.get("base_url"), + description=data.get("description"), + api_key=data.get("api_key"), + is_active=data.get("is_active"), ) agent = get_agent(agent_id) - return jsonify({'status': 'success', 'agent': agent}) + return jsonify({"status": "success", "agent": agent}) -@controller_bp.route('/agents/', methods=['DELETE']) +@controller_bp.route("/agents/", methods=["DELETE"]) def remove_agent(agent_id: int): """Delete an agent.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) delete_agent(agent_id) - return jsonify({'status': 'success', 'message': 'Agent deleted'}) + return jsonify({"status": "success", "message": "Agent deleted"}) -@controller_bp.route('/agents//refresh', methods=['POST']) +@controller_bp.route("/agents//refresh", methods=["POST"]) def refresh_agent_metadata(agent_id: int): """Refresh an agent's capabilities and status.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) try: client = create_client_from_agent(agent) metadata = client.refresh_metadata() - if metadata['healthy']: - caps = metadata['capabilities'] or {} + if metadata["healthy"]: + caps = metadata["capabilities"] or {} # Store full interfaces structure (wifi, bt, sdr) - agent_interfaces = caps.get('interfaces', {}) + agent_interfaces = caps.get("interfaces", {}) # Fallback: also include top-level devices for backwards compatibility - if not agent_interfaces.get('sdr_devices') and caps.get('devices'): - agent_interfaces['sdr_devices'] = caps.get('devices', []) - update_agent( - agent_id, - capabilities=caps.get('modes'), - interfaces=agent_interfaces, - update_last_seen=True - ) + if not agent_interfaces.get("sdr_devices") and caps.get("devices"): + agent_interfaces["sdr_devices"] = caps.get("devices", []) + update_agent(agent_id, capabilities=caps.get("modes"), interfaces=agent_interfaces, update_last_seen=True) agent = get_agent(agent_id) - return jsonify({ - 'status': 'success', - 'agent': agent, - 'metadata': metadata - }) + return jsonify({"status": "success", "agent": agent, "metadata": metadata}) else: - return api_error('Agent is not reachable', 503) + return api_error("Agent is not reachable", 503) except (AgentHTTPError, AgentConnectionError) as e: - return api_error(f'Failed to reach agent: {e}', 503) + return api_error(f"Failed to reach agent: {e}", 503) # ============================================================================= # Agent Status - Get running state # ============================================================================= -@controller_bp.route('/agents//status', methods=['GET']) + +@controller_bp.route("/agents//status", methods=["GET"]) def get_agent_status(agent_id: int): """Get an agent's current status including running modes.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) try: client = create_client_from_agent(agent) status = client.get_status() - return jsonify({ - 'status': 'success', - 'agent_id': agent_id, - 'agent_name': agent['name'], - 'agent_status': status - }) + return jsonify({"status": "success", "agent_id": agent_id, "agent_name": agent["name"], "agent_status": status}) except (AgentHTTPError, AgentConnectionError) as e: - return api_error(f'Failed to reach agent: {e}', 503) + return api_error(f"Failed to reach agent: {e}", 503) -@controller_bp.route('/agents/health', methods=['GET']) +@controller_bp.route("/agents/health", methods=["GET"]) def check_all_agents_health(): """ Check health of all registered agents in one call. @@ -325,74 +303,77 @@ def check_all_agents_health(): for agent in agents_list: result = { - 'id': agent['id'], - 'name': agent['name'], - 'healthy': False, - 'response_time_ms': None, - 'running_modes': [], - 'error': None + "id": agent["id"], + "name": agent["name"], + "healthy": False, + "response_time_ms": None, + "running_modes": [], + "error": None, } - try: - client = AgentClient( - agent['base_url'], - api_key=agent.get('api_key'), - timeout=AGENT_HEALTH_TIMEOUT_SECONDS, - ) - - # Time the health check - start_time = time.time() - is_healthy = client.health_check() - response_time = (time.time() - start_time) * 1000 + try: + client = AgentClient( + agent["base_url"], + api_key=agent.get("api_key"), + timeout=AGENT_HEALTH_TIMEOUT_SECONDS, + ) - result['healthy'] = is_healthy - result['response_time_ms'] = round(response_time, 1) + # Time the health check + start_time = time.time() + is_healthy = client.health_check() + response_time = (time.time() - start_time) * 1000 + + result["healthy"] = is_healthy + result["response_time_ms"] = round(response_time, 1) if is_healthy: - # Update last_seen in database - update_agent(agent['id'], update_last_seen=True) - - # Also fetch running modes - try: - status_client = AgentClient( - agent['base_url'], - api_key=agent.get('api_key'), - timeout=AGENT_STATUS_TIMEOUT_SECONDS, - ) - status = status_client.get_status() - result['running_modes'] = status.get('running_modes', []) - result['running_modes_detail'] = status.get('running_modes_detail', {}) - except Exception: + # Update last_seen in database + update_agent(agent["id"], update_last_seen=True) + + # Also fetch running modes + try: + status_client = AgentClient( + agent["base_url"], + api_key=agent.get("api_key"), + timeout=AGENT_STATUS_TIMEOUT_SECONDS, + ) + status = status_client.get_status() + result["running_modes"] = status.get("running_modes", []) + result["running_modes_detail"] = status.get("running_modes_detail", {}) + except Exception: pass # Status fetch is optional except AgentConnectionError as e: - result['error'] = f'Connection failed: {str(e)}' + result["error"] = f"Connection failed: {str(e)}" except AgentHTTPError as e: - result['error'] = f'HTTP error: {str(e)}' + result["error"] = f"HTTP error: {str(e)}" except Exception as e: - result['error'] = str(e) + result["error"] = str(e) results.append(result) - return jsonify({ - 'status': 'success', - 'timestamp': datetime.now(timezone.utc).isoformat(), - 'agents': results, - 'total': len(results), - 'healthy_count': sum(1 for r in results if r['healthy']) - }) + return jsonify( + { + "status": "success", + "timestamp": datetime.now(timezone.utc).isoformat(), + "agents": results, + "total": len(results), + "healthy_count": sum(1 for r in results if r["healthy"]), + } + ) # ============================================================================= # Proxy Operations - Forward requests to agents # ============================================================================= -@controller_bp.route('/agents///start', methods=['POST']) + +@controller_bp.route("/agents///start", methods=["POST"]) def proxy_start_mode(agent_id: int, mode: str): """Start a mode on a remote agent.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) params = request.json or {} @@ -403,25 +384,20 @@ def proxy_start_mode(agent_id: int, mode: str): # Update last_seen update_agent(agent_id, update_last_seen=True) - return jsonify({ - 'status': 'success', - 'agent_id': agent_id, - 'mode': mode, - 'result': result - }) + return jsonify({"status": "success", "agent_id": agent_id, "mode": mode, "result": result}) except AgentConnectionError as e: - return api_error(f'Cannot connect to agent: {e}', 503) + return api_error(f"Cannot connect to agent: {e}", 503) except AgentHTTPError as e: - return api_error(f'Agent error: {e}', 502) + return api_error(f"Agent error: {e}", 502) -@controller_bp.route('/agents///stop', methods=['POST']) +@controller_bp.route("/agents///stop", methods=["POST"]) def proxy_stop_mode(agent_id: int, mode: str): """Stop a mode on a remote agent.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) try: client = create_client_from_agent(agent) @@ -429,84 +405,70 @@ def proxy_stop_mode(agent_id: int, mode: str): update_agent(agent_id, update_last_seen=True) - return jsonify({ - 'status': 'success', - 'agent_id': agent_id, - 'mode': mode, - 'result': result - }) + return jsonify({"status": "success", "agent_id": agent_id, "mode": mode, "result": result}) except AgentConnectionError as e: - return api_error(f'Cannot connect to agent: {e}', 503) + return api_error(f"Cannot connect to agent: {e}", 503) except AgentHTTPError as e: - return api_error(f'Agent error: {e}', 502) + return api_error(f"Agent error: {e}", 502) -@controller_bp.route('/agents///status', methods=['GET']) +@controller_bp.route("/agents///status", methods=["GET"]) def proxy_mode_status(agent_id: int, mode: str): """Get mode status from a remote agent.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) try: client = create_client_from_agent(agent) result = client.get_mode_status(mode) - return jsonify({ - 'status': 'success', - 'agent_id': agent_id, - 'mode': mode, - 'result': result - }) + return jsonify({"status": "success", "agent_id": agent_id, "mode": mode, "result": result}) except (AgentHTTPError, AgentConnectionError) as e: - return api_error(f'Agent error: {e}', 502) + return api_error(f"Agent error: {e}", 502) -@controller_bp.route('/agents///data', methods=['GET']) +@controller_bp.route("/agents///data", methods=["GET"]) def proxy_mode_data(agent_id: int, mode: str): """Get current data from a remote agent.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) try: client = create_client_from_agent(agent) result = client.get_mode_data(mode) # Tag data with agent info - result['agent_id'] = agent_id - result['agent_name'] = agent['name'] + result["agent_id"] = agent_id + result["agent_name"] = agent["name"] - return jsonify({ - 'status': 'success', - 'agent_id': agent_id, - 'agent_name': agent['name'], - 'mode': mode, - 'data': result - }) + return jsonify( + {"status": "success", "agent_id": agent_id, "agent_name": agent["name"], "mode": mode, "data": result} + ) except (AgentHTTPError, AgentConnectionError) as e: - return api_error(f'Agent error: {e}', 502) + return api_error(f"Agent error: {e}", 502) -@controller_bp.route('/agents///stream') +@controller_bp.route("/agents///stream") def proxy_mode_stream(agent_id: int, mode: str): """Proxy SSE stream from a remote agent.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) client = create_client_from_agent(agent) - query = request.query_string.decode('utf-8') + query = request.query_string.decode("utf-8") url = f"{client.base_url}/{mode}/stream" if query: url = f"{url}?{query}" - headers = {'Accept': 'text/event-stream'} - if agent.get('api_key'): - headers['X-API-Key'] = agent['api_key'] + headers = {"Accept": "text/event-stream"} + if agent.get("api_key"): + headers["X-API-Key"] = agent["api_key"] def generate() -> Generator[str, None, None]: try: @@ -515,73 +477,92 @@ def proxy_mode_stream(agent_id: int, mode: str): for chunk in resp.iter_content(chunk_size=1024): if not chunk: continue - yield chunk.decode('utf-8', errors='ignore') + yield chunk.decode("utf-8", errors="ignore") except Exception as e: logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}") - yield format_sse({ - 'type': 'error', - 'message': str(e), - 'agent_id': agent_id, - 'mode': mode, - }) + yield format_sse( + { + "type": "error", + "message": str(e), + "agent_id": agent_id, + "mode": mode, + } + ) - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response = Response(generate(), mimetype="text/event-stream") + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response -@controller_bp.route('/agents//wifi/monitor', methods=['POST']) +@controller_bp.route("/agents//wifi/v2/clients", methods=["GET"]) +def proxy_wifi_clients(agent_id: int): + """Get the WiFi client list from a remote agent.""" + agent = get_agent(agent_id) + if not agent: + return api_error("Agent not found", 404) + + try: + client = create_client_from_agent(agent) + result = client.get("/wifi/v2/clients", params=request.args.to_dict()) + + return jsonify({"status": "success", "agent_id": agent_id, "result": result}) + + except (AgentHTTPError, AgentConnectionError) as e: + return api_error(f"Agent error: {e}", 502) + + +@controller_bp.route("/agents//wifi/monitor", methods=["POST"]) def proxy_wifi_monitor(agent_id: int): """Toggle monitor mode on a remote agent's WiFi interface.""" agent = get_agent(agent_id) if not agent: - return api_error('Agent not found', 404) + return api_error("Agent not found", 404) data = request.json or {} try: client = create_client_from_agent(agent) - result = client.post('/wifi/monitor', data) + result = client.post("/wifi/monitor", data) # Refresh agent capabilities after monitor mode toggle so UI stays in sync - if result.get('status') == 'success': + if result.get("status") == "success": try: metadata = client.refresh_metadata() - if metadata.get('healthy'): - caps = metadata.get('capabilities') or {} - agent_interfaces = caps.get('interfaces', {}) - if not agent_interfaces.get('sdr_devices') and caps.get('devices'): - agent_interfaces['sdr_devices'] = caps.get('devices', []) + if metadata.get("healthy"): + caps = metadata.get("capabilities") or {} + agent_interfaces = caps.get("interfaces", {}) + if not agent_interfaces.get("sdr_devices") and caps.get("devices"): + agent_interfaces["sdr_devices"] = caps.get("devices", []) update_agent( - agent_id, - capabilities=caps.get('modes'), - interfaces=agent_interfaces, - update_last_seen=True + agent_id, capabilities=caps.get("modes"), interfaces=agent_interfaces, update_last_seen=True ) except Exception: pass # Non-fatal if refresh fails - return jsonify({ - 'status': result.get('status', 'error'), - 'agent_id': agent_id, - 'agent_name': agent['name'], - 'monitor_interface': result.get('monitor_interface'), - 'message': result.get('message') - }) + return jsonify( + { + "status": result.get("status", "error"), + "agent_id": agent_id, + "agent_name": agent["name"], + "monitor_interface": result.get("monitor_interface"), + "message": result.get("message"), + } + ) except AgentConnectionError as e: - return api_error(f'Cannot connect to agent: {e}', 503) + return api_error(f"Cannot connect to agent: {e}", 503) except AgentHTTPError as e: - return api_error(f'Agent error: {e}', 502) + return api_error(f"Agent error: {e}", 502) # ============================================================================= # Push Data Ingestion # ============================================================================= -@controller_bp.route('/api/ingest', methods=['POST']) + +@controller_bp.route("/api/ingest", methods=["POST"]) def ingest_push_data(): """ Receive pushed data from remote agents. @@ -600,80 +581,72 @@ def ingest_push_data(): """ data = request.json if not data: - return api_error('No data provided', 400) + return api_error("No data provided", 400) - agent_name = data.get('agent_name') + agent_name = data.get("agent_name") if not agent_name: - return api_error('agent_name required', 400) + return api_error("agent_name required", 400) # Find agent agent = get_agent_by_name(agent_name) if not agent: - return api_error('Unknown agent', 401) + return api_error("Unknown agent", 401) # Validate API key if configured - if agent.get('api_key'): - provided_key = request.headers.get('X-API-Key', '') - if provided_key != agent['api_key']: + if agent.get("api_key"): + provided_key = request.headers.get("X-API-Key", "") + if provided_key != agent["api_key"]: logger.warning(f"Invalid API key from agent {agent_name}") - return api_error('Invalid API key', 401) + return api_error("Invalid API key", 401) # Store payload try: payload_id = store_push_payload( - agent_id=agent['id'], - scan_type=data.get('scan_type', 'unknown'), - payload=data.get('payload', {}), - interface=data.get('interface'), - received_at=data.get('received_at') + agent_id=agent["id"], + scan_type=data.get("scan_type", "unknown"), + payload=data.get("payload", {}), + interface=data.get("interface"), + received_at=data.get("received_at"), ) # Emit to SSE stream (fanout to all connected clients) - _broadcast_agent_data({ - 'type': 'agent_data', - 'agent_id': agent['id'], - 'agent_name': agent_name, - 'scan_type': data.get('scan_type'), - 'interface': data.get('interface'), - 'payload': data.get('payload'), - 'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat() - }) + _broadcast_agent_data( + { + "type": "agent_data", + "agent_id": agent["id"], + "agent_name": agent_name, + "scan_type": data.get("scan_type"), + "interface": data.get("interface"), + "payload": data.get("payload"), + "received_at": data.get("received_at") or datetime.now(timezone.utc).isoformat(), + } + ) - return jsonify({ - 'status': 'accepted', - 'payload_id': payload_id - }), 202 + return jsonify({"status": "accepted", "payload_id": payload_id}), 202 except Exception as e: logger.exception("Failed to store push payload") return api_error(str(e), 500) -@controller_bp.route('/api/payloads', methods=['GET']) +@controller_bp.route("/api/payloads", methods=["GET"]) def get_payloads(): """Get recent push payloads.""" - agent_id = request.args.get('agent_id', type=int) - scan_type = request.args.get('scan_type') - limit = request.args.get('limit', 100, type=int) + agent_id = request.args.get("agent_id", type=int) + scan_type = request.args.get("scan_type") + limit = request.args.get("limit", 100, type=int) - payloads = get_recent_payloads( - agent_id=agent_id, - scan_type=scan_type, - limit=min(limit, 1000) - ) + payloads = get_recent_payloads(agent_id=agent_id, scan_type=scan_type, limit=min(limit, 1000)) - return jsonify({ - 'status': 'success', - 'payloads': payloads, - 'count': len(payloads) - }) + return jsonify({"status": "success", "payloads": payloads, "count": len(payloads)}) # ============================================================================= # Multi-Agent SSE Stream # ============================================================================= -@controller_bp.route('/stream/all') + +@controller_bp.route("/stream/all") def stream_all_agents(): """ Combined SSE stream for data from all agents. @@ -688,7 +661,7 @@ def stream_all_agents(): def generate() -> Generator[str, None, None]: last_keepalive = time.time() keepalive_interval = 30.0 - yield format_sse({'type': 'keepalive'}) + yield format_sse({"type": "keepalive"}) try: while True: @@ -699,16 +672,16 @@ def stream_all_agents(): except queue.Empty: now = time.time() if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) + yield format_sse({"type": "keepalive"}) last_keepalive = now finally: with _agent_stream_subscribers_lock: _agent_stream_subscribers.discard(client_queue) - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response = Response(generate(), mimetype="text/event-stream") + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response @@ -716,22 +689,25 @@ def stream_all_agents(): # Agent Management Page # ============================================================================= -@controller_bp.route('/manage') + +@controller_bp.route("/manage") def agent_management_page(): """Render the agent management page.""" from flask import render_template from config import VERSION - return render_template('agents.html', version=VERSION) + + return render_template("agents.html", version=VERSION) -@controller_bp.route('/monitor') -def network_monitor_page(): - """Render the network monitor page for multi-agent aggregated view.""" +@controller_bp.route("/monitor") +def network_monitor_page(): + """Render the network monitor page for multi-agent aggregated view.""" from flask import render_template from config import VERSION - return render_template('network_monitor.html', version=VERSION) + + return render_template("network_monitor.html", version=VERSION) # ============================================================================= @@ -740,16 +716,13 @@ def network_monitor_page(): # Global device location tracker device_tracker = DeviceLocationTracker( - trilateration=Trilateration( - path_loss_model=PathLossModel('outdoor'), - min_observations=2 - ), + trilateration=Trilateration(path_loss_model=PathLossModel("outdoor"), min_observations=2), observation_window_seconds=120.0, # 2 minute window - min_observations=2 + min_observations=2, ) -@controller_bp.route('/api/location/observe', methods=['POST']) +@controller_bp.route("/api/location/observe", methods=["POST"]) def add_location_observation(): """ Add an observation for device location estimation. @@ -768,42 +741,40 @@ def add_location_observation(): """ data = request.json or {} - required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi'] + required = ["device_id", "agent_name", "agent_lat", "agent_lon", "rssi"] for field in required: if field not in data: - return api_error(f'Missing required field: {field}', 400) + return api_error(f"Missing required field: {field}", 400) # Look up agent GPS from database if not provided - agent_lat = data.get('agent_lat') - agent_lon = data.get('agent_lon') + agent_lat = data.get("agent_lat") + agent_lon = data.get("agent_lon") if agent_lat is None or agent_lon is None: - agent = get_agent_by_name(data['agent_name']) - if agent and agent.get('gps_coords'): - coords = agent['gps_coords'] - agent_lat = coords.get('lat') or coords.get('latitude') - agent_lon = coords.get('lon') or coords.get('longitude') + agent = get_agent_by_name(data["agent_name"]) + if agent and agent.get("gps_coords"): + coords = agent["gps_coords"] + agent_lat = coords.get("lat") or coords.get("latitude") + agent_lon = coords.get("lon") or coords.get("longitude") if agent_lat is None or agent_lon is None: - return api_error('Agent GPS coordinates required', 400) + return api_error("Agent GPS coordinates required", 400) estimate = device_tracker.add_observation( - device_id=data['device_id'], - agent_name=data['agent_name'], + device_id=data["device_id"], + agent_name=data["agent_name"], agent_lat=float(agent_lat), agent_lon=float(agent_lon), - rssi=float(data['rssi']), - frequency_mhz=data.get('frequency_mhz') + rssi=float(data["rssi"]), + frequency_mhz=data.get("frequency_mhz"), ) - return jsonify({ - 'status': 'success', - 'device_id': data['device_id'], - 'location': estimate.to_dict() if estimate else None - }) + return jsonify( + {"status": "success", "device_id": data["device_id"], "location": estimate.to_dict() if estimate else None} + ) -@controller_bp.route('/api/location/estimate', methods=['POST']) +@controller_bp.route("/api/location/estimate", methods=["POST"]) def estimate_location(): """ Estimate device location from provided observations. @@ -820,58 +791,46 @@ def estimate_location(): """ data = request.json or {} - observations = data.get('observations', []) + observations = data.get("observations", []) if len(observations) < 2: - return api_error('At least 2 observations required', 400) + return api_error("At least 2 observations required", 400) - environment = data.get('environment', 'outdoor') + environment = data.get("environment", "outdoor") try: result = estimate_location_from_observations(observations, environment) - return jsonify({ - 'status': 'success' if result else 'insufficient_data', - 'location': result - }) + return jsonify({"status": "success" if result else "insufficient_data", "location": result}) except Exception as e: logger.exception("Location estimation failed") return api_error(str(e), 500) -@controller_bp.route('/api/location/', methods=['GET']) +@controller_bp.route("/api/location/", methods=["GET"]) def get_device_location(device_id: str): """Get the latest location estimate for a device.""" estimate = device_tracker.get_location(device_id) if not estimate: - return jsonify({ - 'status': 'not_found', - 'device_id': device_id, - 'location': None - }) + return jsonify({"status": "not_found", "device_id": device_id, "location": None}) - return jsonify({ - 'status': 'success', - 'device_id': device_id, - 'location': estimate.to_dict() - }) + return jsonify({"status": "success", "device_id": device_id, "location": estimate.to_dict()}) -@controller_bp.route('/api/location/all', methods=['GET']) +@controller_bp.route("/api/location/all", methods=["GET"]) def get_all_locations(): """Get all current device location estimates.""" locations = device_tracker.get_all_locations() - return jsonify({ - 'status': 'success', - 'count': len(locations), - 'devices': { - device_id: estimate.to_dict() - for device_id, estimate in locations.items() + return jsonify( + { + "status": "success", + "count": len(locations), + "devices": {device_id: estimate.to_dict() for device_id, estimate in locations.items()}, } - }) + ) -@controller_bp.route('/api/location/near', methods=['GET']) +@controller_bp.route("/api/location/near", methods=["GET"]) def get_devices_near(): """ Find devices near a location. @@ -882,21 +841,20 @@ def get_devices_near(): radius: radius in meters (default 100) """ try: - lat = float(request.args.get('lat', 0)) - lon = float(request.args.get('lon', 0)) - radius = float(request.args.get('radius', 100)) + lat = float(request.args.get("lat", 0)) + lon = float(request.args.get("lon", 0)) + radius = float(request.args.get("radius", 100)) except (ValueError, TypeError): - return api_error('Invalid coordinates', 400) + return api_error("Invalid coordinates", 400) results = device_tracker.get_devices_near(lat, lon, radius) - return jsonify({ - 'status': 'success', - 'center': {'lat': lat, 'lon': lon}, - 'radius_meters': radius, - 'count': len(results), - 'devices': [ - {'device_id': device_id, 'location': estimate.to_dict()} - for device_id, estimate in results - ] - }) + return jsonify( + { + "status": "success", + "center": {"lat": lat, "lon": lon}, + "radius_meters": radius, + "count": len(results), + "devices": [{"device_id": device_id, "location": estimate.to_dict()} for device_id, estimate in results], + } + ) diff --git a/setup.sh b/setup.sh index bd16d15..cf28799 100755 --- a/setup.sh +++ b/setup.sh @@ -492,14 +492,24 @@ raise SystemExit(0 if sys.version_info >= (3,9) else 1) PY ok "Python version OK (>= 3.9)" + # meshcore (MeshCore mesh networking) requires Python 3.10+ + if python3 - <<'PY' +import sys +raise SystemExit(0 if sys.version_info < (3,10) else 1) +PY + then + warn "Python 3.9 detected: MeshCore support requires Python 3.10+ and will be unavailable." + fi + # Python 3.13+ warning: some packages (gevent, numpy, scipy) may not have - # pre-built wheels yet and will be skipped to avoid hanging on compilation. + # pre-built wheels yet; prefer wheels over source builds to avoid hanging + # on compilation (--prefer-binary is added to PIP_OPTS in install_python_deps). if python3 - <<'PY' import sys raise SystemExit(0 if sys.version_info >= (3,13) else 1) PY then - warn "Python 3.13+ detected: optional packages without pre-built wheels will be skipped (--prefer-binary)." + warn "Python 3.13+ detected: preferring pre-built wheels over source builds (--prefer-binary)." fi } @@ -537,6 +547,10 @@ install_python_deps() { # --no-cache-dir avoids pip hanging on a corrupt/stale HTTP cache (cachecontrol .pyc issue) # --timeout prevents pip from hanging indefinitely on slow/unresponsive PyPI connections local PIP_OPTS="--no-cache-dir --timeout 120" + # Python 3.13+: prefer wheels over source builds (see check_python_version warning) + if "$PY" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3,13) else 1)'; then + PIP_OPTS="$PIP_OPTS --prefer-binary" + fi if ! $PIP install $PIP_OPTS --upgrade pip setuptools wheel; then warn "pip/setuptools/wheel upgrade failed - continuing with existing versions" @@ -562,27 +576,27 @@ install_python_deps() { ok "Core Python packages installed" info "Installing optional packages..." - # Pure-Python packages: install without --only-binary so they always succeed regardless of platform - for pkg in "flask-sock" "simple-websocket>=0.5.1" "websocket-client>=1.6.0" \ - "skyfield>=1.45" "bleak>=0.21.0" "meshtastic>=2.0.0" \ - "qrcode[pil]>=7.4" "gunicorn>=21.2.0" "psutil>=5.9.0"; do - pkg_name="${pkg%%[><=]*}" + # Optional package specs come from requirements.txt (single source of truth). + # Packages already installed by the core step above are skipped here. + local core_pkgs="flask flask-wtf flask-compress flask-limiter requests Werkzeug pyserial" + # Heavy compiled packages: install with --only-binary :all: to skip slow + # source compilation on RPi. Everything else installs without it (note: + # transitive deps may still be compiled, e.g. meshcore -> pycryptodome; + # failures are tolerated since these features are optional). + local binary_only_pkgs="numpy scipy Pillow psycopg2-binary scapy cryptography gevent" + local pkg pkg_name extra_opts + while IFS= read -r pkg; do + pkg="${pkg%"${pkg##*[![:space:]]}"}" # trim trailing whitespace + [[ -z "$pkg" || "$pkg" == \#* ]] && continue + pkg_name="${pkg%%[><=[]*}" + case " $core_pkgs " in *" $pkg_name "*) continue ;; esac + extra_opts="" + case " $binary_only_pkgs " in *" $pkg_name "*) extra_opts="--only-binary :all:" ;; esac info " Installing ${pkg_name}..." - if ! $PIP install $PIP_OPTS "$pkg"; then + if ! $PIP install $PIP_OPTS $extra_opts "$pkg"; then warn "${pkg_name} failed to install (optional - related features may be unavailable)" fi - done - # Compiled packages: use --only-binary :all: to skip slow source compilation on RPi - for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" \ - "psycopg2-binary>=2.9.9" "scapy>=2.4.5" "cryptography>=41.0.0" \ - "gevent>=23.9.0"; do - pkg_name="${pkg%%[><=]*}" - info " Installing ${pkg_name}..." - # --only-binary :all: prevents source compilation hangs for heavy packages - if ! $PIP install $PIP_OPTS --only-binary :all: "$pkg"; then - warn "${pkg_name} failed to install (optional - related features may be unavailable)" - fi - done + done < requirements.txt ok "Optional packages processed" echo } diff --git a/static/css/modes/acars.css b/static/css/modes/acars.css deleted file mode 100644 index 9575f44..0000000 --- a/static/css/modes/acars.css +++ /dev/null @@ -1,115 +0,0 @@ -/* ACARS Sidebar Styles */ -@keyframes pulse { - 0%, 100% { opacity: 0.3; transform: scale(0.8); } - 50% { opacity: 1; transform: scale(1); } -} - -/* Main ACARS Sidebar (Collapsible) */ -.main-acars-sidebar { - display: flex; - flex-direction: row; - background: var(--bg-panel); - border-left: 1px solid var(--border-color); -} -.main-acars-collapse-btn { - width: 24px; - min-width: 24px; - background: rgba(0,0,0,0.4); - border: none; - border-right: 1px solid var(--border-color); - color: var(--accent-cyan); - cursor: pointer; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 5px; - padding: 6px 0; - transition: background 0.2s; -} -.main-acars-collapse-btn:hover { - background: rgba(var(--accent-cyan-rgb), 0.15); -} -.main-acars-collapse-label { - writing-mode: vertical-rl; - text-orientation: mixed; - font-size: 8px; - font-weight: 600; - letter-spacing: 1px; -} -.main-acars-sidebar.collapsed .main-acars-collapse-label { display: block; } -.main-acars-sidebar:not(.collapsed) .main-acars-collapse-label { display: none; } -#mainAcarsCollapseIcon { - font-size: 10px; - transition: transform 0.3s; -} -.main-acars-sidebar.collapsed #mainAcarsCollapseIcon { - transform: rotate(180deg); -} -.main-acars-content { - width: 196px; - display: flex; - flex-direction: column; - overflow: hidden; - transition: width 0.3s ease, opacity 0.2s ease; -} -.main-acars-sidebar.collapsed .main-acars-content { - width: 0; - opacity: 0; - pointer-events: none; -} -.main-acars-messages { - max-height: 350px; -} -.main-acars-msg { - padding: 6px 8px; - border-bottom: 1px solid var(--border-color); - animation: fadeInMsg 0.3s ease; -} -.main-acars-msg:hover { - background: rgba(var(--accent-cyan-rgb), 0.05); -} -@keyframes fadeInMsg { - from { opacity: 0; transform: translateY(-3px); } - to { opacity: 1; transform: translateY(0); } -} - -/* ACARS Status Indicator */ -.acars-status-dot.listening { - background: var(--accent-cyan) !important; - animation: acars-pulse 1.5s ease-in-out infinite; -} -.acars-status-dot.receiving { - background: var(--accent-green) !important; -} -.acars-status-dot.error { - background: var(--accent-red) !important; -} -@keyframes acars-pulse { - 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(var(--accent-cyan-rgb), 0.7); } - 50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(var(--accent-cyan-rgb), 0.3); } -} - -/* ACARS Standalone Message Feed */ -.acars-message-feed { - scrollbar-width: thin; - scrollbar-color: var(--border-color) transparent; -} -.acars-message-feed::-webkit-scrollbar { - width: 4px; -} -.acars-message-feed::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 2px; -} -.acars-feed-card { - transition: background 0.15s; -} -.acars-feed-card:hover { - background: rgba(var(--accent-cyan-rgb), 0.05); -} - -/* Clickable ACARS sidebar messages (linked to tracked aircraft) */ -.acars-message-item[style*="cursor: pointer"]:hover { - background: rgba(var(--accent-cyan-rgb), 0.1); -} diff --git a/static/css/modes/vdl2.css b/static/css/modes/vdl2.css deleted file mode 100644 index 3e81c49..0000000 --- a/static/css/modes/vdl2.css +++ /dev/null @@ -1,31 +0,0 @@ -/* VDL2 Mode Styles */ - -/* VDL2 Status Indicator */ -.vdl2-status-dot.listening { - background: var(--accent-cyan) !important; - animation: vdl2-pulse 1.5s ease-in-out infinite; -} -.vdl2-status-dot.receiving { - background: var(--accent-green) !important; -} -.vdl2-status-dot.error { - background: var(--accent-red) !important; -} -@keyframes vdl2-pulse { - 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(var(--accent-cyan-rgb), 0.7); } - 50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(var(--accent-cyan-rgb), 0.3); } -} - -/* VDL2 message animation */ -.vdl2-msg { - padding: 6px 8px; - border-bottom: 1px solid var(--border-color); - animation: vdl2FadeIn 0.3s ease; -} -.vdl2-msg:hover { - background: rgba(var(--accent-cyan-rgb), 0.05); -} -@keyframes vdl2FadeIn { - from { opacity: 0; transform: translateY(-3px); } - to { opacity: 1; transform: translateY(0); } -} diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index a831879..d1dffd2 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -1371,7 +1371,8 @@ const WiFiMode = (function() { const data = await response.json(); // Handle agent response format (may be nested in 'result') const result = isAgentMode && data.result ? data.result : data; - const clientList = result.clients || []; + // /wifi/v2/clients returns a bare array; tolerate {clients: [...]} too + const clientList = Array.isArray(result) ? result : (result.clients || []); if (clientList.length > 0) { renderClientList(clientList, bssid); diff --git a/templates/index.html b/templates/index.html index cdc9a9d..96d4ce4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -772,8 +772,6 @@ {% include 'partials/modes/tscm.html' %} - {% include 'partials/modes/ais.html' %} - {% include 'partials/modes/drone.html' %} {% include 'partials/modes/radiosonde.html' %} @@ -4462,9 +4460,6 @@ sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(), websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(), spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(), - ais: () => { if (aisEventSource) { aisEventSource.close(); aisEventSource = null; } }, - acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } }, - vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } }, radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } }, aprs: () => { if (typeof destroyAprsMode === 'function') { @@ -4820,7 +4815,6 @@ document.getElementById('wflMode')?.classList.toggle('active', mode === 'wifi_locate'); document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs'); document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm'); - document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais'); document.getElementById('droneMode')?.classList.toggle('active', mode === 'drone'); document.getElementById('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde'); document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations'); @@ -5006,7 +5000,7 @@ // Show agent selector for modes that support remote agents const agentSection = document.getElementById('agentSection'); - const agentModes = ['pager', 'sensor', 'rtlamr', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais']; + const agentModes = ['pager', 'sensor', 'rtlamr', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm']; if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none'; // Show RTL-SDR device section for modes that use it @@ -6505,7 +6499,7 @@ document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`; // Update max attribute on all mode gain inputs so constraints match the SDR const gainMax = caps.gain_max; - ['gain', 'sensorGain', 'aisGainInput', 'acarsGainInput', 'aprsStripGain', 'weatherSatGain'].forEach(id => { + ['gain', 'sensorGain', 'aprsStripGain', 'weatherSatGain'].forEach(id => { const el = document.getElementById(id); if (el) el.max = gainMax; }); @@ -6633,8 +6627,7 @@ // Warn if any SDR mode is currently running — bias-T is applied at // start time and cannot be toggled on a running device. const anyRunning = isRunning || isSensorRunning - || (typeof isAdsbRunning !== 'undefined' && isAdsbRunning) - || (typeof isAisRunning !== 'undefined' && isAisRunning); + || (typeof isAdsbRunning !== 'undefined' && isAdsbRunning); if (anyRunning) { showInfo('Bias-T change will take effect after restarting the active SDR mode'); } diff --git a/templates/partials/modes/acars.html b/templates/partials/modes/acars.html deleted file mode 100644 index a6dc3ad..0000000 --- a/templates/partials/modes/acars.html +++ /dev/null @@ -1,300 +0,0 @@ - - - - diff --git a/templates/partials/modes/ais.html b/templates/partials/modes/ais.html deleted file mode 100644 index dbc6083..0000000 --- a/templates/partials/modes/ais.html +++ /dev/null @@ -1,218 +0,0 @@ - - - - diff --git a/templates/partials/modes/vdl2.html b/templates/partials/modes/vdl2.html deleted file mode 100644 index 8a12eb6..0000000 --- a/templates/partials/modes/vdl2.html +++ /dev/null @@ -1,242 +0,0 @@ - - - - diff --git a/tests/conftest.py b/tests/conftest.py index 3521c96..4c566b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,11 @@ from unittest.mock import MagicMock, patch import pytest +# Must be set before importing app: stops the deferred background-init +# thread, whose subprocess/DB cleanup fires mid-session and races with +# test mocks (e.g. a patched subprocess.Popen catching its pkill call) +os.environ.setdefault("INTERCEPT_SKIP_DEFERRED_INIT", "1") + from app import app as flask_app from routes import register_blueprints diff --git a/tests/test_agent_modes.py b/tests/test_agent_modes.py index 34be018..4a57afa 100644 --- a/tests/test_agent_modes.py +++ b/tests/test_agent_modes.py @@ -25,10 +25,12 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Fixtures # ============================================================================= + @pytest.fixture def mode_manager(): """Create a fresh ModeManager instance for testing.""" from intercept_agent import ModeManager + manager = ModeManager() yield manager # Cleanup: stop all modes @@ -40,15 +42,21 @@ def mode_manager(): @pytest.fixture def mock_subprocess(): """Mock subprocess.Popen for controlled testing.""" - with patch('subprocess.Popen') as mock_popen: + with patch("subprocess.Popen") as mock_popen: mock_proc = MagicMock() mock_proc.poll.return_value = None # Process is running mock_proc.stdout = MagicMock() mock_proc.stderr = MagicMock() - mock_proc.stderr.read.return_value = b'' + mock_proc.stderr.read.return_value = b"" mock_proc.stdin = MagicMock() mock_proc.pid = 12345 mock_proc.wait.return_value = 0 + # subprocess.run() is built on Popen: it enters the context manager + # and unpacks communicate(); detect_capabilities() calls it with + # text=True, so return strings + mock_proc.communicate.return_value = ("", "") + mock_proc.returncode = 0 + mock_proc.__enter__.return_value = mock_proc mock_popen.return_value = mock_proc yield mock_popen, mock_proc @@ -57,19 +65,19 @@ def mock_subprocess(): def mock_tools(): """Mock tool availability checks.""" tools = { - 'rtl_433': '/usr/bin/rtl_433', - 'rtl_fm': '/usr/bin/rtl_fm', - 'dump1090': '/usr/bin/dump1090', - 'multimon-ng': '/usr/bin/multimon-ng', - 'airodump-ng': '/usr/sbin/airodump-ng', - 'acarsdec': '/usr/bin/acarsdec', - 'AIS-catcher': '/usr/bin/AIS-catcher', - 'direwolf': '/usr/bin/direwolf', - 'rtlamr': '/usr/bin/rtlamr', - 'rtl_tcp': '/usr/bin/rtl_tcp', - 'bluetoothctl': '/usr/bin/bluetoothctl', + "rtl_433": "/usr/bin/rtl_433", + "rtl_fm": "/usr/bin/rtl_fm", + "dump1090": "/usr/bin/dump1090", + "multimon-ng": "/usr/bin/multimon-ng", + "airodump-ng": "/usr/sbin/airodump-ng", + "acarsdec": "/usr/bin/acarsdec", + "AIS-catcher": "/usr/bin/AIS-catcher", + "direwolf": "/usr/bin/direwolf", + "rtlamr": "/usr/bin/rtlamr", + "rtl_tcp": "/usr/bin/rtl_tcp", + "bluetoothctl": "/usr/bin/bluetoothctl", } - with patch('shutil.which', side_effect=lambda x: tools.get(x)): + with patch("shutil.which", side_effect=lambda x: tools.get(x)): yield tools @@ -77,8 +85,8 @@ def mock_tools(): # SDR Mode List # ============================================================================= -SDR_MODES = ['sensor', 'adsb', 'pager', 'ais', 'acars', 'aprs', 'rtlamr', 'dsc', 'listening_post'] -NON_SDR_MODES = ['wifi', 'bluetooth', 'tscm', 'satellite'] +SDR_MODES = ["sensor", "adsb", "pager", "ais", "acars", "aprs", "rtlamr", "dsc", "listening_post"] +NON_SDR_MODES = ["wifi", "bluetooth", "tscm", "satellite"] ALL_MODES = SDR_MODES + NON_SDR_MODES @@ -86,6 +94,7 @@ ALL_MODES = SDR_MODES + NON_SDR_MODES # Mode Lifecycle Tests # ============================================================================= + class TestModeLifecycle: """Test start/stop lifecycle for all modes.""" @@ -94,99 +103,88 @@ class TestModeLifecycle: mock_popen, mock_proc = mock_subprocess # Start - result = mode_manager.start_mode('sensor', {'frequency': '433.92', 'device': '0'}) - assert result['status'] == 'started' - assert 'sensor' in mode_manager.running_modes + result = mode_manager.start_mode("sensor", {"frequency": "433.92", "device": "0"}) + assert result["status"] == "started" + assert "sensor" in mode_manager.running_modes # Stop - result = mode_manager.stop_mode('sensor') - assert result['status'] == 'stopped' - assert 'sensor' not in mode_manager.running_modes + result = mode_manager.stop_mode("sensor") + assert result["status"] == "stopped" + assert "sensor" not in mode_manager.running_modes def test_adsb_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools): """ADS-B mode should start and stop cleanly.""" mock_popen, mock_proc = mock_subprocess # Mock socket for SBS connection check - with patch('socket.socket') as mock_socket: + with patch("socket.socket") as mock_socket: mock_sock = MagicMock() mock_sock.connect_ex.return_value = 1 # Port not in use mock_socket.return_value = mock_sock - result = mode_manager.start_mode('adsb', {'device': '0', 'gain': '40'}) + result = mode_manager.start_mode("adsb", {"device": "0", "gain": "40"}) # May fail due to SBS port check, but shouldn't crash - assert result['status'] in ['started', 'error'] + assert result["status"] in ["started", "error"] def test_pager_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools): """Pager mode should start and stop cleanly.""" mock_popen, mock_proc = mock_subprocess - result = mode_manager.start_mode('pager', { - 'frequency': '929.6125', - 'protocols': ['POCSAG512', 'POCSAG1200'] - }) - assert result['status'] == 'started' - assert 'pager' in mode_manager.running_modes + result = mode_manager.start_mode("pager", {"frequency": "929.6125", "protocols": ["POCSAG512", "POCSAG1200"]}) + assert result["status"] == "started" + assert "pager" in mode_manager.running_modes - result = mode_manager.stop_mode('pager') - assert result['status'] == 'stopped' + result = mode_manager.stop_mode("pager") + assert result["status"] == "stopped" def test_wifi_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools): """WiFi mode should start and stop cleanly.""" mock_popen, mock_proc = mock_subprocess # Mock glob for CSV file detection - with patch('glob.glob', return_value=[]), patch('tempfile.mkdtemp', return_value='/tmp/test'): - result = mode_manager.start_mode('wifi', { - 'interface': 'wlan0', - 'scan_type': 'quick' - }) + with patch("glob.glob", return_value=[]), patch("tempfile.mkdtemp", return_value="/tmp/test"): + result = mode_manager.start_mode("wifi", {"interface": "wlan0", "scan_type": "quick"}) # Quick scan returns data directly - assert result['status'] in ['started', 'error', 'success'] + assert result["status"] in ["started", "error", "success"] def test_bluetooth_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools): """Bluetooth mode should start and stop cleanly.""" mock_popen, mock_proc = mock_subprocess - result = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'}) - assert result['status'] == 'started' - assert 'bluetooth' in mode_manager.running_modes + result = mode_manager.start_mode("bluetooth", {"adapter": "hci0"}) + assert result["status"] == "started" + assert "bluetooth" in mode_manager.running_modes # Give thread time to start time.sleep(0.1) - result = mode_manager.stop_mode('bluetooth') - assert result['status'] == 'stopped' + result = mode_manager.stop_mode("bluetooth") + assert result["status"] == "stopped" def test_satellite_mode_lifecycle(self, mode_manager): """Satellite mode should work without SDR.""" - # Satellite mode is computational only - result = mode_manager.start_mode('satellite', { - 'lat': 33.5, - 'lon': -82.1, - 'min_elevation': 10 - }) - assert result['status'] in ['started', 'error'] # May fail if skyfield not installed + # Patch the predictor loop — the real one downloads TLEs from + # CelesTrak and keeps computing passes after the test finishes + with patch.object(type(mode_manager), "_satellite_predictor", MagicMock()): + result = mode_manager.start_mode("satellite", {"lat": 33.5, "lon": -82.1, "min_elevation": 10}) + assert result["status"] in ["started", "error"] # May fail if skyfield not installed def test_tscm_mode_lifecycle(self, mode_manager, mock_subprocess, mock_tools): """TSCM mode should start and stop cleanly.""" mock_popen, mock_proc = mock_subprocess - result = mode_manager.start_mode('tscm', { - 'wifi': True, - 'bluetooth': True, - 'rf': False - }) - assert result['status'] == 'started' + result = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": True, "rf": False}) + assert result["status"] == "started" - result = mode_manager.stop_mode('tscm') - assert result['status'] == 'stopped' + result = mode_manager.stop_mode("tscm") + assert result["status"] == "stopped" # ============================================================================= # SDR Conflict Detection Tests # ============================================================================= + class TestSDRConflictDetection: """Test SDR device conflict detection.""" @@ -195,25 +193,25 @@ class TestSDRConflictDetection: mock_popen, mock_proc = mock_subprocess # Start sensor on device 0 - result1 = mode_manager.start_mode('sensor', {'device': '0'}) - assert result1['status'] == 'started' + result1 = mode_manager.start_mode("sensor", {"device": "0"}) + assert result1["status"] == "started" # Try to start pager on device 0 - should fail - result2 = mode_manager.start_mode('pager', {'device': '0'}) - assert result2['status'] == 'error' - assert 'in use' in result2['message'].lower() + result2 = mode_manager.start_mode("pager", {"device": "0"}) + assert result2["status"] == "error" + assert "in use" in result2["message"].lower() def test_different_device_no_conflict(self, mode_manager, mock_subprocess, mock_tools): """Starting SDR modes on different devices should work.""" mock_popen, mock_proc = mock_subprocess # Start sensor on device 0 - result1 = mode_manager.start_mode('sensor', {'device': '0'}) - assert result1['status'] == 'started' + result1 = mode_manager.start_mode("sensor", {"device": "0"}) + assert result1["status"] == "started" # Start pager on device 1 - should work - result2 = mode_manager.start_mode('pager', {'device': '1'}) - assert result2['status'] == 'started' + result2 = mode_manager.start_mode("pager", {"device": "1"}) + assert result2["status"] == "started" assert len(mode_manager.running_modes) == 2 @@ -222,12 +220,12 @@ class TestSDRConflictDetection: mock_popen, mock_proc = mock_subprocess # Start sensor (SDR) - result1 = mode_manager.start_mode('sensor', {'device': '0'}) - assert result1['status'] == 'started' + result1 = mode_manager.start_mode("sensor", {"device": "0"}) + assert result1["status"] == "started" # Start bluetooth (non-SDR) - should work - result2 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'}) - assert result2['status'] == 'started' + result2 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"}) + assert result2["status"] == "started" assert len(mode_manager.running_modes) == 2 @@ -239,10 +237,10 @@ class TestSDRConflictDetection: assert mode_manager.get_sdr_in_use(0) is None # Start sensor - mode_manager.start_mode('sensor', {'device': '0'}) + mode_manager.start_mode("sensor", {"device": "0"}) # Device 0 now in use by sensor - assert mode_manager.get_sdr_in_use(0) == 'sensor' + assert mode_manager.get_sdr_in_use(0) == "sensor" assert mode_manager.get_sdr_in_use(1) is None @@ -250,67 +248,73 @@ class TestSDRConflictDetection: # Process Verification Tests # ============================================================================= + class TestProcessVerification: """Test process startup verification.""" def test_immediate_process_exit_detected(self, mode_manager, mock_tools): """Process that exits immediately should return error.""" - with patch('subprocess.Popen') as mock_popen: + with patch("subprocess.Popen") as mock_popen: mock_proc = MagicMock() mock_proc.poll.return_value = 1 # Process exited - mock_proc.stderr.read.return_value = b'device busy' + mock_proc.stderr.read.return_value = b"device busy" + mock_proc.communicate.return_value = ("", "") + mock_proc.__enter__.return_value = mock_proc mock_popen.return_value = mock_proc - result = mode_manager.start_mode('sensor', {'device': '0'}) - assert result['status'] == 'error' - assert 'sensor' not in mode_manager.running_modes + result = mode_manager.start_mode("sensor", {"device": "0"}) + assert result["status"] == "error" + assert "sensor" not in mode_manager.running_modes def test_running_process_accepted(self, mode_manager, mock_subprocess, mock_tools): """Process that stays running should be accepted.""" mock_popen, mock_proc = mock_subprocess mock_proc.poll.return_value = None # Still running - result = mode_manager.start_mode('sensor', {'device': '0'}) - assert result['status'] == 'started' - assert 'sensor' in mode_manager.running_modes + result = mode_manager.start_mode("sensor", {"device": "0"}) + assert result["status"] == "started" + assert "sensor" in mode_manager.running_modes def test_error_message_from_stderr(self, mode_manager, mock_tools): """Error message should include stderr output.""" - with patch('subprocess.Popen') as mock_popen: + with patch("subprocess.Popen") as mock_popen: mock_proc = MagicMock() mock_proc.poll.return_value = 1 - mock_proc.stderr.read.return_value = b'usb_claim_interface error -6' + mock_proc.stderr.read.return_value = b"usb_claim_interface error -6" + mock_proc.communicate.return_value = ("", "") + mock_proc.__enter__.return_value = mock_proc mock_popen.return_value = mock_proc - result = mode_manager.start_mode('sensor', {'device': '0'}) - assert result['status'] == 'error' - assert 'usb_claim_interface' in result['message'] or 'failed' in result['message'].lower() + result = mode_manager.start_mode("sensor", {"device": "0"}) + assert result["status"] == "error" + assert "usb_claim_interface" in result["message"] or "failed" in result["message"].lower() # ============================================================================= # Data Snapshot Tests # ============================================================================= + class TestDataSnapshots: """Test data snapshot operations.""" def test_get_mode_data_empty(self, mode_manager): """get_mode_data for non-running mode should return empty.""" - result = mode_manager.get_mode_data('sensor') - assert result['mode'] == 'sensor' + result = mode_manager.get_mode_data("sensor") + assert result["mode"] == "sensor" # Mode not running - should have empty data or 'running' field - assert result.get('running') is False or result.get('data') == [] or 'status' in result + assert result.get("running") is False or result.get("data") == [] or "status" in result def test_get_mode_data_running(self, mode_manager, mock_subprocess, mock_tools): """get_mode_data for running mode should return status.""" mock_popen, mock_proc = mock_subprocess - mode_manager.start_mode('sensor', {'device': '0'}) - result = mode_manager.get_mode_data('sensor') + mode_manager.start_mode("sensor", {"device": "0"}) + result = mode_manager.get_mode_data("sensor") - assert result['mode'] == 'sensor' + assert result["mode"] == "sensor" # Mode is running - should indicate running status - assert result.get('running') is True or 'data' in result or 'status' in result + assert result.get("running") is True or "data" in result or "status" in result def test_data_queue_limit(self, mode_manager): """Data queues should respect max size limits.""" @@ -321,7 +325,7 @@ class TestDataSnapshots: for i in range(150): if test_queue.full(): test_queue.get_nowait() # Remove old item - test_queue.put_nowait({'index': i}) + test_queue.put_nowait({"index": i}) assert test_queue.qsize() <= 100 @@ -330,68 +334,73 @@ class TestDataSnapshots: # Mode Status Tests # ============================================================================= + class TestModeStatus: """Test mode status reporting.""" def test_status_includes_all_modes(self, mode_manager): """Status should include all running modes.""" status = mode_manager.get_status() - assert 'running_modes' in status - assert 'running_modes_detail' in status - assert isinstance(status['running_modes'], list) + assert "running_modes" in status + assert "running_modes_detail" in status + assert isinstance(status["running_modes"], list) def test_running_modes_detail_includes_device(self, mode_manager, mock_subprocess, mock_tools): """Running modes detail should include device info.""" mock_popen, mock_proc = mock_subprocess - mode_manager.start_mode('sensor', {'device': '0'}) + mode_manager.start_mode("sensor", {"device": "0"}) status = mode_manager.get_status() - assert 'sensor' in status['running_modes_detail'] - detail = status['running_modes_detail']['sensor'] - assert 'device' in detail or 'params' in detail + assert "sensor" in status["running_modes_detail"] + detail = status["running_modes_detail"]["sensor"] + assert "device" in detail or "params" in detail # ============================================================================= # Error Handling Tests # ============================================================================= + class TestErrorHandling: """Test error handling scenarios.""" def test_missing_tool_returns_error(self, mode_manager): """Mode should fail gracefully if required tool is missing.""" - with patch('shutil.which', return_value=None): - result = mode_manager.start_mode('sensor', {'device': '0'}) - assert result['status'] == 'error' + # get_tool_path checks Homebrew paths via os.path.isfile before + # shutil.which, so patch it too or installed tools are still found + with patch("utils.dependencies.get_tool_path", return_value=None), patch("shutil.which", return_value=None): + result = mode_manager.start_mode("sensor", {"device": "0"}) + assert result["status"] == "error" # Error message may vary - check for common patterns - msg = result['message'].lower() - assert 'not found' in msg or 'not available' in msg or 'missing' in msg + msg = result["message"].lower() + assert "not found" in msg or "not available" in msg or "missing" in msg def test_invalid_mode_returns_error(self, mode_manager): """Invalid mode name should return error.""" - result = mode_manager.start_mode('invalid_mode', {}) - assert result['status'] == 'error' + result = mode_manager.start_mode("invalid_mode", {}) + assert result["status"] == "error" def test_double_start_returns_already_running(self, mode_manager, mock_subprocess, mock_tools): """Starting already-running mode should return appropriate status.""" mock_popen, mock_proc = mock_subprocess - mode_manager.start_mode('sensor', {'device': '0'}) - result = mode_manager.start_mode('sensor', {'device': '0'}) + mode_manager.start_mode("sensor", {"device": "0"}) + result = mode_manager.start_mode("sensor", {"device": "0"}) - assert result['status'] in ['already_running', 'error'] + assert result["status"] in ["already_running", "error"] def test_stop_non_running_mode(self, mode_manager): """Stopping non-running mode should handle gracefully.""" - result = mode_manager.stop_mode('sensor') - assert result['status'] in ['stopped', 'not_running'] + result = mode_manager.stop_mode("sensor") + assert result["status"] in ["stopped", "not_running"] # ============================================================================= # Cleanup Tests # ============================================================================= + class TestCleanup: """Test mode cleanup on stop.""" @@ -399,8 +408,8 @@ class TestCleanup: """Processes should be terminated when mode is stopped.""" mock_popen, mock_proc = mock_subprocess - mode_manager.start_mode('sensor', {'device': '0'}) - mode_manager.stop_mode('sensor') + mode_manager.start_mode("sensor", {"device": "0"}) + mode_manager.stop_mode("sensor") # Verify terminate was called mock_proc.terminate.assert_called() @@ -409,20 +418,20 @@ class TestCleanup: """Output threads should be stopped when mode is stopped.""" mock_popen, mock_proc = mock_subprocess - mode_manager.start_mode('bluetooth', {'adapter': 'hci0'}) + mode_manager.start_mode("bluetooth", {"adapter": "hci0"}) time.sleep(0.1) # Let thread start - mode_manager.stop_mode('bluetooth') + mode_manager.stop_mode("bluetooth") # Thread should no longer be in output_threads or should be stopped - assert 'bluetooth' not in mode_manager.output_threads or \ - not mode_manager.output_threads['bluetooth'].is_alive() + assert "bluetooth" not in mode_manager.output_threads or not mode_manager.output_threads["bluetooth"].is_alive() # ============================================================================= # Multi-Mode Tests # ============================================================================= + class TestMultiMode: """Test multiple modes running simultaneously.""" @@ -430,19 +439,19 @@ class TestMultiMode: """Multiple non-SDR modes should run simultaneously.""" mock_popen, mock_proc = mock_subprocess - result1 = mode_manager.start_mode('bluetooth', {'adapter': 'hci0'}) - result2 = mode_manager.start_mode('tscm', {'wifi': True, 'bluetooth': False}) + result1 = mode_manager.start_mode("bluetooth", {"adapter": "hci0"}) + result2 = mode_manager.start_mode("tscm", {"wifi": True, "bluetooth": False}) - assert result1['status'] == 'started' - assert result2['status'] == 'started' + assert result1["status"] == "started" + assert result2["status"] == "started" assert len(mode_manager.running_modes) == 2 def test_stop_all_modes(self, mode_manager, mock_subprocess, mock_tools): """All modes should stop cleanly.""" mock_popen, mock_proc = mock_subprocess - mode_manager.start_mode('sensor', {'device': '0'}) - mode_manager.start_mode('bluetooth', {'adapter': 'hci0'}) + mode_manager.start_mode("sensor", {"device": "0"}) + mode_manager.start_mode("bluetooth", {"adapter": "hci0"}) # Stop all for mode in list(mode_manager.running_modes.keys()): @@ -455,26 +464,27 @@ class TestMultiMode: # GPS Integration Tests # ============================================================================= + class TestGPSIntegration: """Test GPS coordinate integration.""" def test_status_includes_gps_flag(self, mode_manager): """Status should indicate GPS availability.""" status = mode_manager.get_status() - assert 'gps' in status + assert "gps" in status def test_mode_start_includes_gps_flag(self, mode_manager, mock_subprocess, mock_tools): """Mode start response should include GPS status.""" mock_popen, mock_proc = mock_subprocess - result = mode_manager.start_mode('sensor', {'device': '0'}) - if result['status'] == 'started': - assert 'gps_enabled' in result + result = mode_manager.start_mode("sensor", {"device": "0"}) + if result["status"] == "started": + assert "gps_enabled" in result # ============================================================================= # Run Tests # ============================================================================= -if __name__ == '__main__': - pytest.main([__file__, '-v']) +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_controller.py b/tests/test_controller.py index 345e852..edd8851 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -23,18 +23,19 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Fixtures # ============================================================================= + @pytest.fixture def setup_db(tmp_path): """Set up a temporary database.""" import utils.database as db_module from utils.database import init_db - test_db_path = tmp_path / 'test.db' + test_db_path = tmp_path / "test.db" original_db_path = db_module.DB_PATH db_module.DB_PATH = test_db_path db_module.DB_DIR = tmp_path - if hasattr(db_module._local, 'connection') and db_module._local.connection: + if hasattr(db_module._local, "connection") and db_module._local.connection: db_module._local.connection.close() db_module._local.connection = None @@ -42,7 +43,7 @@ def setup_db(tmp_path): yield - if hasattr(db_module._local, 'connection') and db_module._local.connection: + if hasattr(db_module._local, "connection") and db_module._local.connection: db_module._local.connection.close() db_module._local.connection = None db_module.DB_PATH = original_db_path @@ -56,7 +57,7 @@ def app(setup_db): from routes.controller import controller_bp app = Flask(__name__) - app.config['TESTING'] = True + app.config["TESTING"] = True app.register_blueprint(controller_bp) return app @@ -72,13 +73,14 @@ def client(app): def sample_agent(setup_db): """Create a sample agent in database.""" from utils.database import create_agent + agent_id = create_agent( - name='test-sensor', - base_url='http://192.168.1.50:8020', - api_key='test-key', - description='Test sensor node', - capabilities={'adsb': True, 'wifi': True}, - gps_coords={'lat': 40.7128, 'lon': -74.0060} + name="test-sensor", + base_url="http://192.168.1.50:8020", + api_key="test-key", + description="Test sensor node", + capabilities={"adsb": True, "wifi": True}, + gps_coords={"lat": 40.7128, "lon": -74.0060}, ) return agent_id @@ -87,125 +89,125 @@ def sample_agent(setup_db): # Agent CRUD Tests # ============================================================================= + class TestAgentCRUD: """Tests for agent CRUD operations.""" def test_list_agents_empty(self, client): """GET /controller/agents should return empty list initially.""" - response = client.get('/controller/agents') + response = client.get("/controller/agents") assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'success' - assert data['agents'] == [] - assert data['count'] == 0 + assert data["status"] == "success" + assert data["agents"] == [] + assert data["count"] == 0 def test_register_agent_success(self, client): """POST /controller/agents should register new agent.""" - with patch('routes.controller.AgentClient') as MockClient: + with patch("routes.controller.AgentClient") as MockClient: # Mock successful capability fetch mock_instance = Mock() mock_instance.get_capabilities.return_value = { - 'modes': {'adsb': True, 'wifi': True}, - 'devices': [{'name': 'RTL-SDR'}] + "modes": {"adsb": True, "wifi": True}, + "devices": [{"name": "RTL-SDR"}], } MockClient.return_value = mock_instance - response = client.post('/controller/agents', + response = client.post( + "/controller/agents", json={ - 'name': 'new-sensor', - 'base_url': 'http://192.168.1.51:8020', - 'api_key': 'secret123', - 'description': 'New sensor node' + "name": "new-sensor", + "base_url": "http://192.168.1.51:8020", + "api_key": "secret123", + "description": "New sensor node", }, - content_type='application/json' + content_type="application/json", ) assert response.status_code == 201 data = json.loads(response.data) - assert data['status'] == 'success' - assert data['agent']['name'] == 'new-sensor' + assert data["status"] == "success" + assert data["agent"]["name"] == "new-sensor" def test_register_agent_missing_name(self, client): """POST /controller/agents should reject missing name.""" - response = client.post('/controller/agents', - json={'base_url': 'http://localhost:8020'}, - content_type='application/json' + response = client.post( + "/controller/agents", json={"base_url": "http://localhost:8020"}, content_type="application/json" ) assert response.status_code == 400 data = json.loads(response.data) - assert 'name is required' in data['message'] + assert "name is required" in data["message"] def test_register_agent_missing_url(self, client): """POST /controller/agents should reject missing URL.""" - response = client.post('/controller/agents', - json={'name': 'test-sensor'}, - content_type='application/json' - ) + response = client.post("/controller/agents", json={"name": "test-sensor"}, content_type="application/json") assert response.status_code == 400 data = json.loads(response.data) - assert 'Base URL is required' in data['message'] + assert "Base URL is required" in data["message"] def test_register_agent_duplicate_name(self, client, sample_agent): """POST /controller/agents should reject duplicate name.""" - response = client.post('/controller/agents', + response = client.post( + "/controller/agents", json={ - 'name': 'test-sensor', # Same as sample_agent - 'base_url': 'http://192.168.1.60:8020' + "name": "test-sensor", # Same as sample_agent + "base_url": "http://192.168.1.60:8020", }, - content_type='application/json' + content_type="application/json", ) assert response.status_code == 409 data = json.loads(response.data) - assert 'already exists' in data['message'] + assert "already exists" in data["message"] def test_list_agents_with_agents(self, client, sample_agent): """GET /controller/agents should return registered agents.""" - response = client.get('/controller/agents') + response = client.get("/controller/agents") assert response.status_code == 200 data = json.loads(response.data) - assert data['count'] >= 1 + assert data["count"] >= 1 - names = [a['name'] for a in data['agents']] - assert 'test-sensor' in names + names = [a["name"] for a in data["agents"]] + assert "test-sensor" in names def test_get_agent_detail(self, client, sample_agent): """GET /controller/agents/ should return agent details.""" - response = client.get(f'/controller/agents/{sample_agent}') + response = client.get(f"/controller/agents/{sample_agent}") assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'success' - assert data['agent']['name'] == 'test-sensor' - assert data['agent']['capabilities']['adsb'] is True + assert data["status"] == "success" + assert data["agent"]["name"] == "test-sensor" + assert data["agent"]["capabilities"]["adsb"] is True def test_get_agent_not_found(self, client): """GET /controller/agents/ should return 404 for missing agent.""" - response = client.get('/controller/agents/99999') + response = client.get("/controller/agents/99999") assert response.status_code == 404 def test_update_agent(self, client, sample_agent): """PATCH /controller/agents/ should update agent.""" - response = client.patch(f'/controller/agents/{sample_agent}', - json={'description': 'Updated description'}, - content_type='application/json' + response = client.patch( + f"/controller/agents/{sample_agent}", + json={"description": "Updated description"}, + content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) - assert data['agent']['description'] == 'Updated description' + assert data["agent"]["description"] == "Updated description" def test_delete_agent(self, client, sample_agent): """DELETE /controller/agents/ should remove agent.""" - response = client.delete(f'/controller/agents/{sample_agent}') + response = client.delete(f"/controller/agents/{sample_agent}") assert response.status_code == 200 # Verify deleted - response = client.get(f'/controller/agents/{sample_agent}') + response = client.get(f"/controller/agents/{sample_agent}") assert response.status_code == 404 @@ -213,345 +215,325 @@ class TestAgentCRUD: # Proxy Operation Tests # ============================================================================= + class TestProxyOperations: """Tests for proxying operations to agents.""" def test_proxy_start_mode(self, client, sample_agent): """POST /controller/agents///start should proxy to agent.""" - with patch('routes.controller.create_client_from_agent') as mock_create: + with patch("routes.controller.create_client_from_agent") as mock_create: mock_client = Mock() - mock_client.start_mode.return_value = {'status': 'started', 'mode': 'adsb'} + mock_client.start_mode.return_value = {"status": "started", "mode": "adsb"} mock_create.return_value = mock_client response = client.post( - f'/controller/agents/{sample_agent}/adsb/start', - json={'device_index': 0}, - content_type='application/json' + f"/controller/agents/{sample_agent}/adsb/start", + json={"device_index": 0}, + content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'success' - assert data['mode'] == 'adsb' + assert data["status"] == "success" + assert data["mode"] == "adsb" - mock_client.start_mode.assert_called_once_with('adsb', {'device_index': 0}) + mock_client.start_mode.assert_called_once_with("adsb", {"device_index": 0}) def test_proxy_stop_mode(self, client, sample_agent): """POST /controller/agents///stop should proxy to agent.""" - with patch('routes.controller.create_client_from_agent') as mock_create: + with patch("routes.controller.create_client_from_agent") as mock_create: mock_client = Mock() - mock_client.stop_mode.return_value = {'status': 'stopped'} + mock_client.stop_mode.return_value = {"status": "stopped"} mock_create.return_value = mock_client - response = client.post( - f'/controller/agents/{sample_agent}/wifi/stop', - content_type='application/json' - ) + response = client.post(f"/controller/agents/{sample_agent}/wifi/stop", content_type="application/json") assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'success' + assert data["status"] == "success" def test_proxy_get_mode_data(self, client, sample_agent): """GET /controller/agents///data should return data.""" - with patch('routes.controller.create_client_from_agent') as mock_create: + with patch("routes.controller.create_client_from_agent") as mock_create: mock_client = Mock() - mock_client.get_mode_data.return_value = { - 'mode': 'adsb', - 'data': [{'icao': 'ABC123'}] - } + mock_client.get_mode_data.return_value = {"mode": "adsb", "data": [{"icao": "ABC123"}]} mock_create.return_value = mock_client - response = client.get(f'/controller/agents/{sample_agent}/adsb/data') + response = client.get(f"/controller/agents/{sample_agent}/adsb/data") assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'success' - assert 'agent_name' in data - assert data['agent_name'] == 'test-sensor' + assert data["status"] == "success" + assert "agent_name" in data + assert data["agent_name"] == "test-sensor" def test_proxy_agent_not_found(self, client): """Proxy operations should return 404 for missing agent.""" - response = client.post('/controller/agents/99999/adsb/start') + response = client.post("/controller/agents/99999/adsb/start") assert response.status_code == 404 def test_proxy_connection_error(self, client, sample_agent): """Proxy should return 503 when agent unreachable.""" from utils.agent_client import AgentConnectionError - with patch('routes.controller.create_client_from_agent') as mock_create: + with patch("routes.controller.create_client_from_agent") as mock_create: mock_client = Mock() mock_client.start_mode.side_effect = AgentConnectionError("Connection refused") mock_create.return_value = mock_client response = client.post( - f'/controller/agents/{sample_agent}/adsb/start', - json={}, - content_type='application/json' + f"/controller/agents/{sample_agent}/adsb/start", json={}, content_type="application/json" ) assert response.status_code == 503 data = json.loads(response.data) - assert 'Cannot connect' in data['message'] + assert "Cannot connect" in data["message"] # ============================================================================= # Push Data Ingestion Tests # ============================================================================= + class TestPushIngestion: """Tests for push data ingestion endpoint.""" def test_ingest_success(self, client, sample_agent): """POST /controller/api/ingest should store payload.""" payload = { - 'agent_name': 'test-sensor', - 'scan_type': 'adsb', - 'interface': 'rtlsdr0', - 'payload': { - 'aircraft': [{'icao': 'ABC123', 'altitude': 35000}] - } + "agent_name": "test-sensor", + "scan_type": "adsb", + "interface": "rtlsdr0", + "payload": {"aircraft": [{"icao": "ABC123", "altitude": 35000}]}, } - response = client.post('/controller/api/ingest', - json=payload, - headers={'X-API-Key': 'test-key'}, - content_type='application/json' + response = client.post( + "/controller/api/ingest", json=payload, headers={"X-API-Key": "test-key"}, content_type="application/json" ) assert response.status_code == 202 data = json.loads(response.data) - assert data['status'] == 'accepted' - assert 'payload_id' in data + assert data["status"] == "accepted" + assert "payload_id" in data def test_ingest_unknown_agent(self, client): """POST /controller/api/ingest should reject unknown agent.""" - payload = { - 'agent_name': 'nonexistent-sensor', - 'scan_type': 'adsb', - 'payload': {} - } + payload = {"agent_name": "nonexistent-sensor", "scan_type": "adsb", "payload": {}} - response = client.post('/controller/api/ingest', - json=payload, - content_type='application/json' - ) + response = client.post("/controller/api/ingest", json=payload, content_type="application/json") assert response.status_code == 401 data = json.loads(response.data) - assert 'Unknown agent' in data['message'] + assert "Unknown agent" in data["message"] def test_ingest_invalid_api_key(self, client, sample_agent): """POST /controller/api/ingest should reject invalid API key.""" - payload = { - 'agent_name': 'test-sensor', - 'scan_type': 'adsb', - 'payload': {} - } + payload = {"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}} - response = client.post('/controller/api/ingest', - json=payload, - headers={'X-API-Key': 'wrong-key'}, - content_type='application/json' + response = client.post( + "/controller/api/ingest", json=payload, headers={"X-API-Key": "wrong-key"}, content_type="application/json" ) assert response.status_code == 401 data = json.loads(response.data) - assert 'Invalid API key' in data['message'] + assert "Invalid API key" in data["message"] def test_ingest_missing_agent_name(self, client): """POST /controller/api/ingest should require agent_name.""" - response = client.post('/controller/api/ingest', - json={'scan_type': 'adsb', 'payload': {}}, - content_type='application/json' + response = client.post( + "/controller/api/ingest", json={"scan_type": "adsb", "payload": {}}, content_type="application/json" ) assert response.status_code == 400 data = json.loads(response.data) - assert 'agent_name required' in data['message'] + assert "agent_name required" in data["message"] def test_get_payloads(self, client, sample_agent): """GET /controller/api/payloads should return stored payloads.""" # First ingest some data for i in range(3): - client.post('/controller/api/ingest', + client.post( + "/controller/api/ingest", json={ - 'agent_name': 'test-sensor', - 'scan_type': 'adsb', - 'payload': {'aircraft': [{'icao': f'TEST{i}'}]} + "agent_name": "test-sensor", + "scan_type": "adsb", + "payload": {"aircraft": [{"icao": f"TEST{i}"}]}, }, - headers={'X-API-Key': 'test-key'}, - content_type='application/json' + headers={"X-API-Key": "test-key"}, + content_type="application/json", ) - response = client.get(f'/controller/api/payloads?agent_id={sample_agent}') + response = client.get(f"/controller/api/payloads?agent_id={sample_agent}") assert response.status_code == 200 data = json.loads(response.data) - assert data['count'] == 3 + assert data["count"] == 3 def test_get_payloads_filter_by_type(self, client, sample_agent): """GET /controller/api/payloads should filter by scan_type.""" # Ingest mixed data - client.post('/controller/api/ingest', - json={'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {}}, - headers={'X-API-Key': 'test-key'}, - content_type='application/json' + client.post( + "/controller/api/ingest", + json={"agent_name": "test-sensor", "scan_type": "adsb", "payload": {}}, + headers={"X-API-Key": "test-key"}, + content_type="application/json", ) - client.post('/controller/api/ingest', - json={'agent_name': 'test-sensor', 'scan_type': 'wifi', 'payload': {}}, - headers={'X-API-Key': 'test-key'}, - content_type='application/json' + client.post( + "/controller/api/ingest", + json={"agent_name": "test-sensor", "scan_type": "wifi", "payload": {}}, + headers={"X-API-Key": "test-key"}, + content_type="application/json", ) - response = client.get('/controller/api/payloads?scan_type=adsb') + response = client.get("/controller/api/payloads?scan_type=adsb") data = json.loads(response.data) - assert all(p['scan_type'] == 'adsb' for p in data['payloads']) + assert all(p["scan_type"] == "adsb" for p in data["payloads"]) # ============================================================================= # Location Estimation Tests # ============================================================================= + class TestLocationEstimation: """Tests for device location estimation (trilateration).""" def test_add_observation(self, client): """POST /controller/api/location/observe should accept observation.""" - response = client.post('/controller/api/location/observe', + response = client.post( + "/controller/api/location/observe", json={ - 'device_id': 'AA:BB:CC:DD:EE:FF', - 'agent_name': 'sensor-1', - 'agent_lat': 40.7128, - 'agent_lon': -74.0060, - 'rssi': -55 + "device_id": "AA:BB:CC:DD:EE:FF", + "agent_name": "sensor-1", + "agent_lat": 40.7128, + "agent_lon": -74.0060, + "rssi": -55, }, - content_type='application/json' + content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'success' - assert data['device_id'] == 'AA:BB:CC:DD:EE:FF' + assert data["status"] == "success" + assert data["device_id"] == "AA:BB:CC:DD:EE:FF" def test_add_observation_missing_fields(self, client): """POST /controller/api/location/observe should require all fields.""" - response = client.post('/controller/api/location/observe', + response = client.post( + "/controller/api/location/observe", json={ - 'device_id': 'AA:BB:CC:DD:EE:FF', - 'rssi': -55 + "device_id": "AA:BB:CC:DD:EE:FF", + "rssi": -55, # Missing agent_name, agent_lat, agent_lon }, - content_type='application/json' + content_type="application/json", ) assert response.status_code == 400 def test_estimate_location(self, client): """POST /controller/api/location/estimate should compute location.""" - response = client.post('/controller/api/location/estimate', + response = client.post( + "/controller/api/location/estimate", json={ - 'observations': [ - {'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'}, - {'agent_lat': 40.7135, 'agent_lon': -74.0055, 'rssi': -70, 'agent_name': 'node-2'}, - {'agent_lat': 40.7120, 'agent_lon': -74.0050, 'rssi': -62, 'agent_name': 'node-3'} + "observations": [ + {"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"}, + {"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"}, + {"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"}, ], - 'environment': 'outdoor' + "environment": "outdoor", }, - content_type='application/json' + content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) # Should have computed a location - if data['location']: - assert 'lat' in data['location'] - assert 'lon' in data['location'] + if data["location"]: + assert "latitude" in data["location"] + assert "longitude" in data["location"] def test_estimate_location_insufficient_data(self, client): """Estimation should require at least 2 observations.""" - response = client.post('/controller/api/location/estimate', - json={ - 'observations': [ - {'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'} - ] - }, - content_type='application/json' + response = client.post( + "/controller/api/location/estimate", + json={"observations": [{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"}]}, + content_type="application/json", ) assert response.status_code == 400 data = json.loads(response.data) - assert 'At least 2' in data['message'] + assert "At least 2" in data["message"] def test_get_device_location_not_found(self, client): """GET /controller/api/location/ returns not_found for unknown device.""" - response = client.get('/controller/api/location/unknown-device') + response = client.get("/controller/api/location/unknown-device") assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'not_found' - assert data['location'] is None + assert data["status"] == "not_found" + assert data["location"] is None def test_get_all_locations(self, client): """GET /controller/api/location/all should return all estimates.""" - response = client.get('/controller/api/location/all') + response = client.get("/controller/api/location/all") assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'success' - assert 'devices' in data + assert data["status"] == "success" + assert "devices" in data def test_get_devices_near(self, client): """GET /controller/api/location/near should find nearby devices.""" response = client.get( - '/controller/api/location/near', - query_string={'lat': 40.7128, 'lon': -74.0060, 'radius': 100} + "/controller/api/location/near", query_string={"lat": 40.7128, "lon": -74.0060, "radius": 100} ) assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'success' - assert data['center']['lat'] == 40.7128 + assert data["status"] == "success" + assert data["center"]["lat"] == 40.7128 # ============================================================================= # Agent Refresh Tests # ============================================================================= + class TestAgentRefresh: """Tests for agent refresh operations.""" def test_refresh_agent_success(self, client, sample_agent): """POST /controller/agents//refresh should update metadata.""" - with patch('routes.controller.create_client_from_agent') as mock_create: + with patch("routes.controller.create_client_from_agent") as mock_create: mock_client = Mock() mock_client.refresh_metadata.return_value = { - 'healthy': True, - 'capabilities': { - 'modes': {'adsb': True, 'wifi': True, 'bluetooth': True}, - 'devices': [{'name': 'RTL-SDR V3'}] + "healthy": True, + "capabilities": { + "modes": {"adsb": True, "wifi": True, "bluetooth": True}, + "devices": [{"name": "RTL-SDR V3"}], }, - 'status': {'running_modes': ['adsb']}, - 'config': {} + "status": {"running_modes": ["adsb"]}, + "config": {}, } mock_create.return_value = mock_client - response = client.post(f'/controller/agents/{sample_agent}/refresh') + response = client.post(f"/controller/agents/{sample_agent}/refresh") assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'success' - assert data['metadata']['healthy'] is True + assert data["status"] == "success" + assert data["metadata"]["healthy"] is True def test_refresh_agent_unreachable(self, client, sample_agent): """POST /controller/agents//refresh should return 503 if unreachable.""" - with patch('routes.controller.create_client_from_agent') as mock_create: + with patch("routes.controller.create_client_from_agent") as mock_create: mock_client = Mock() - mock_client.refresh_metadata.return_value = {'healthy': False} + mock_client.refresh_metadata.return_value = {"healthy": False} mock_create.return_value = mock_client - response = client.post(f'/controller/agents/{sample_agent}/refresh') + response = client.post(f"/controller/agents/{sample_agent}/refresh") assert response.status_code == 503 @@ -560,6 +542,7 @@ class TestAgentRefresh: # SSE Stream Tests # ============================================================================= + class TestSSEStream: """Tests for SSE streaming endpoint.""" @@ -567,5 +550,5 @@ class TestSSEStream: """GET /controller/stream/all should exist and return SSE.""" # Just verify the endpoint is accessible # Full SSE testing requires more complex setup - response = client.get('/controller/stream/all') - assert response.content_type == 'text/event-stream' + response = client.get("/controller/stream/all") + assert response.mimetype == "text/event-stream" diff --git a/tests/test_meshcore_client.py b/tests/test_meshcore_client.py index bc0d5e3..08fefec 100644 --- a/tests/test_meshcore_client.py +++ b/tests/test_meshcore_client.py @@ -126,7 +126,7 @@ class TestMeshcoreClientStateMachine: client.get_queue().get_nowait() # Call on_connected directly (simulating what AsyncWorker would call) client.on_connected(transport="serial", device="/dev/ttyUSB0") - assert client.get_state() == ConnectionState.CONNECTED + assert client.get_state()[0] == ConnectionState.CONNECTED event = client.get_queue().get_nowait() assert event["type"] == "status" assert event["data"]["state"] == "connected" diff --git a/tests/test_meshcore_integration.py b/tests/test_meshcore_integration.py index 4caf95e..d3043ae 100644 --- a/tests/test_meshcore_integration.py +++ b/tests/test_meshcore_integration.py @@ -164,7 +164,7 @@ class TestConnectionStateTransitions: client = MeshcoreClient() client.on_connected(transport="serial", device="/dev/ttyUSB0") - assert client.get_state() == ConnectionState.CONNECTED + assert client.get_state()[0] == ConnectionState.CONNECTED event = client.get_queue().get_nowait() assert event["type"] == "status" assert event["data"]["state"] == "connected" @@ -175,7 +175,7 @@ class TestConnectionStateTransitions: client = MeshcoreClient() client.on_error("timeout") - assert client.get_state() == ConnectionState.ERROR + assert client.get_state()[0] == ConnectionState.ERROR event = client.get_queue().get_nowait() assert event["data"]["state"] == "error" assert event["data"].get("message") == "timeout" diff --git a/tests/test_meshcore_routes.py b/tests/test_meshcore_routes.py index d30cebe..06c477b 100644 --- a/tests/test_meshcore_routes.py +++ b/tests/test_meshcore_routes.py @@ -25,7 +25,7 @@ def client(app): @pytest.fixture(autouse=True) def mock_meshcore_client(): mc = MagicMock() - mc.get_state.return_value = MagicMock(value="disconnected") + mc.get_state.return_value = (MagicMock(value="disconnected"), None) mc.get_messages.return_value = [] mc.get_nodes.return_value = [] mc.get_repeaters.return_value = [] diff --git a/tests/test_satellite.py b/tests/test_satellite.py index 2983298..9ef63b9 100644 --- a/tests/test_satellite.py +++ b/tests/test_satellite.py @@ -8,6 +8,23 @@ from flask import Flask from routes.satellite import satellite_bp +@pytest.fixture(autouse=True) +def _isolate_tle_state(monkeypatch): + """Keep TLE updates off the real data/satellites.py and reset the cache. + + Without this, the update-tle test rewrites the tracked data file on + every run and leaks 'ISS' into the module-global cache, breaking + later tests that depend on cache contents. + """ + import routes.satellite as sat + + monkeypatch.setattr(sat, "_persist_tle_cache", lambda: None) + saved = dict(sat._tle_cache) + yield + sat._tle_cache.clear() + sat._tle_cache.update(saved) + + @pytest.fixture def app(): app = Flask(__name__) diff --git a/tests/test_signal_guess_api.py b/tests/test_signal_guess_api.py index affcf61..85d2d93 100644 --- a/tests/test_signal_guess_api.py +++ b/tests/test_signal_guess_api.py @@ -7,94 +7,115 @@ import pytest def auth_client(client): """Client with logged-in session.""" with client.session_transaction() as sess: - sess['logged_in'] = True + sess["logged_in"] = True return client def test_signal_guess_fm_broadcast(auth_client): """FM broadcast frequency should return a known signal type.""" - resp = auth_client.post('/listening/signal/guess', json={ - 'frequency_mhz': 98.1, - 'modulation': 'wfm', - }) + resp = auth_client.post( + "/receiver/signal/guess", + json={ + "frequency_mhz": 98.1, + "modulation": "wfm", + }, + ) assert resp.status_code == 200 data = resp.get_json() - assert data['status'] == 'ok' - assert data['primary_label'] - assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW') + assert data["status"] == "ok" + assert data["primary_label"] + assert data["confidence"] in ("HIGH", "MEDIUM", "LOW") def test_signal_guess_airband(auth_client): """Airband frequency should be identified.""" - resp = auth_client.post('/listening/signal/guess', json={ - 'frequency_mhz': 121.5, - 'modulation': 'am', - }) + resp = auth_client.post( + "/receiver/signal/guess", + json={ + "frequency_mhz": 121.5, + "modulation": "am", + }, + ) assert resp.status_code == 200 data = resp.get_json() - assert data['status'] == 'ok' - assert data['primary_label'] + assert data["status"] == "ok" + assert data["primary_label"] def test_signal_guess_ism_band(auth_client): """ISM band frequency (433.92 MHz) should be identified.""" - resp = auth_client.post('/listening/signal/guess', json={ - 'frequency_mhz': 433.92, - }) + resp = auth_client.post( + "/receiver/signal/guess", + json={ + "frequency_mhz": 433.92, + }, + ) assert resp.status_code == 200 data = resp.get_json() - assert data['status'] == 'ok' - assert data['primary_label'] - assert data['confidence'] in ('HIGH', 'MEDIUM', 'LOW') + assert data["status"] == "ok" + assert data["primary_label"] + assert data["confidence"] in ("HIGH", "MEDIUM", "LOW") def test_signal_guess_missing_frequency(auth_client): """Missing frequency should return 400.""" - resp = auth_client.post('/listening/signal/guess', json={}) + resp = auth_client.post("/receiver/signal/guess", json={}) assert resp.status_code == 400 data = resp.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" def test_signal_guess_invalid_frequency(auth_client): """Invalid frequency value should return 400.""" - resp = auth_client.post('/listening/signal/guess', json={ - 'frequency_mhz': 'abc', - }) + resp = auth_client.post( + "/receiver/signal/guess", + json={ + "frequency_mhz": "abc", + }, + ) assert resp.status_code == 400 def test_signal_guess_negative_frequency(auth_client): """Negative frequency should return 400.""" - resp = auth_client.post('/listening/signal/guess', json={ - 'frequency_mhz': -5.0, - }) + resp = auth_client.post( + "/receiver/signal/guess", + json={ + "frequency_mhz": -5.0, + }, + ) assert resp.status_code == 400 def test_signal_guess_with_region(auth_client): """Specifying region should work.""" - resp = auth_client.post('/listening/signal/guess', json={ - 'frequency_mhz': 462.5625, - 'region': 'US', - }) + resp = auth_client.post( + "/receiver/signal/guess", + json={ + "frequency_mhz": 462.5625, + "region": "US", + }, + ) assert resp.status_code == 200 data = resp.get_json() - assert data['status'] == 'ok' + assert data["status"] == "ok" def test_signal_guess_response_structure(auth_client): """Response should have all expected fields.""" - resp = auth_client.post('/listening/signal/guess', json={ - 'frequency_mhz': 146.52, - 'modulation': 'fm', - }) + resp = auth_client.post( + "/receiver/signal/guess", + json={ + "frequency_mhz": 146.52, + "modulation": "fm", + }, + ) assert resp.status_code == 200 data = resp.get_json() - assert 'primary_label' in data - assert 'confidence' in data - assert 'alternatives' in data - assert 'explanation' in data - assert 'tags' in data - assert isinstance(data['alternatives'], list) - assert isinstance(data['tags'], list) + assert "primary_label" in data + assert "confidence" in data + assert "alternatives" in data + assert "explanation" in data + assert "tags" in data + assert isinstance(data["alternatives"], list) + assert isinstance(data["tags"], list) diff --git a/tests/test_weather_sat_decoder.py b/tests/test_weather_sat_decoder.py index 44cf7b0..b7538de 100644 --- a/tests/test_weather_sat_decoder.py +++ b/tests/test_weather_sat_decoder.py @@ -6,11 +6,14 @@ and image handling. from __future__ import annotations +import os import time from datetime import datetime, timezone from pathlib import Path from unittest.mock import MagicMock, patch +import pytest + from utils.weather_sat import ( WEATHER_SATELLITES, CaptureProgress, @@ -21,34 +24,57 @@ from utils.weather_sat import ( ) +@pytest.fixture(autouse=True) +def _stop_decoder_threads(): + """Stop watcher/reader threads leaked by tests that call start(). + + Leaked threads keep scanning the output dir and contend for the SQLite + lock, slowing every later test in the session. Full stop() is unsafe + here: it would os.close() the mocked pty fds (10, 11), which are real + fds of the pytest process. + """ + created: list[WeatherSatDecoder] = [] + orig_init = WeatherSatDecoder.__init__ + + def tracking_init(self, *args, **kwargs): + orig_init(self, *args, **kwargs) + created.append(self) + + with patch.object(WeatherSatDecoder, "__init__", tracking_init): + yield + for decoder in created: + decoder._running = False + decoder._stop_event.set() + + class TestWeatherSatDecoder: """Tests for WeatherSatDecoder class.""" def test_decoder_initialization(self): """Decoder should initialize with default output directory.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() assert decoder.is_running is False - assert decoder.decoder_available == 'satdump' - assert decoder.current_satellite == '' + assert decoder.decoder_available == "satdump" + assert decoder.current_satellite == "" assert decoder.current_frequency == 0.0 def test_decoder_initialization_no_satdump(self): """Decoder should detect when SatDump is unavailable.""" - with patch('shutil.which', return_value=None): + with patch("shutil.which", return_value=None): decoder = WeatherSatDecoder() assert decoder.decoder_available is None def test_decoder_custom_output_dir(self): """Decoder should accept custom output directory.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): - custom_dir = '/tmp/custom_output' + with patch("shutil.which", return_value="/usr/bin/satdump"): + custom_dir = "/tmp/custom_output" decoder = WeatherSatDecoder(output_dir=custom_dir) assert decoder._output_dir == Path(custom_dir) def test_set_callback(self): """Decoder should accept progress callback.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() callback = MagicMock() decoder.set_callback(callback) @@ -56,7 +82,7 @@ class TestWeatherSatDecoder: def test_set_on_complete(self): """Decoder should accept on_complete callback.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() callback = MagicMock() decoder.set_on_complete(callback) @@ -64,44 +90,47 @@ class TestWeatherSatDecoder: def test_start_no_decoder(self): """start() should fail when no decoder available.""" - with patch('shutil.which', return_value=None): + with patch("shutil.which", return_value=None): decoder = WeatherSatDecoder() callback = MagicMock() decoder.set_callback(callback) - success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + success, error_msg = decoder.start(satellite="NOAA-18", device_index=0, gain=40.0) assert success is False assert error_msg is not None callback.assert_called() progress = callback.call_args[0][0] - assert progress.status == 'error' - assert 'SatDump' in progress.message + assert progress.status == "error" + assert "SatDump" in progress.message def test_start_invalid_satellite(self): """start() should fail with invalid satellite.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() callback = MagicMock() decoder.set_callback(callback) - success, error_msg = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0) + success, error_msg = decoder.start(satellite="FAKE-SAT", device_index=0, gain=40.0) assert success is False callback.assert_called() progress = callback.call_args[0][0] - assert progress.status == 'error' - assert 'Unknown satellite' in progress.message + assert progress.status == "error" + assert "Unknown satellite" in progress.message - @patch('subprocess.Popen') - @patch('pty.openpty') - @patch('utils.weather_sat.register_process') + @patch("subprocess.Popen") + @patch("pty.openpty") + @patch("utils.weather_sat.register_process") def test_start_success(self, mock_register, mock_pty, mock_popen): """start() should successfully start SatDump.""" - with patch('shutil.which', return_value='/usr/bin/satdump'), \ - patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'): - - mock_pty.return_value = (10, 11) + with ( + patch("shutil.which", return_value="/usr/bin/satdump"), + patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id", return_value="0"), + ): + # Use a real pipe — hardcoded fds like (10, 11) collide with fds + # the test process actually has open (DB, log files, capture) + mock_pty.return_value = os.pipe() mock_process = MagicMock() mock_process.poll.return_value = None mock_popen.return_value = mock_process @@ -111,7 +140,7 @@ class TestWeatherSatDecoder: decoder.set_callback(callback) success, error_msg = decoder.start( - satellite='NOAA-18', + satellite="NOAA-18", device_index=0, gain=40.0, bias_t=True, @@ -120,25 +149,27 @@ class TestWeatherSatDecoder: assert success is True assert error_msg is None assert decoder.is_running is True - assert decoder.current_satellite == 'NOAA-18' + assert decoder.current_satellite == "NOAA-18" assert decoder.current_frequency == 137.9125 - assert decoder.current_mode == 'APT' + assert decoder.current_mode == "APT" assert decoder.device_index == 0 mock_popen.assert_called_once() cmd = mock_popen.call_args[0][0] - assert cmd[0] == 'satdump' - assert 'live' in cmd - assert 'noaa_apt' in cmd - assert '--bias' in cmd + assert cmd[0] == "satdump" + assert "live" in cmd + assert "noaa_apt" in cmd + assert "--bias" in cmd - @patch('subprocess.Popen') - @patch('pty.openpty') - @patch('utils.weather_sat.register_process') + @patch("subprocess.Popen") + @patch("pty.openpty") + @patch("utils.weather_sat.register_process") def test_start_rtl_tcp_uses_rtltcp_source(self, mock_register, mock_pty, mock_popen): """start() with rtl_tcp should use --source rtltcp instead of rtlsdr.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): - mock_pty.return_value = (10, 11) + with patch("shutil.which", return_value="/usr/bin/satdump"): + # Use a real pipe — hardcoded fds like (10, 11) collide with fds + # the test process actually has open (DB, log files, capture) + mock_pty.return_value = os.pipe() mock_process = MagicMock() mock_process.poll.return_value = None mock_popen.return_value = mock_process @@ -148,10 +179,10 @@ class TestWeatherSatDecoder: decoder.set_callback(callback) success, error_msg = decoder.start( - satellite='NOAA-18', + satellite="NOAA-18", device_index=0, gain=40.0, - rtl_tcp_host='192.168.1.100', + rtl_tcp_host="192.168.1.100", rtl_tcp_port=1234, ) @@ -160,24 +191,28 @@ class TestWeatherSatDecoder: mock_popen.assert_called_once() cmd = mock_popen.call_args[0][0] - assert '--source' in cmd - source_idx = cmd.index('--source') - assert cmd[source_idx + 1] == 'rtltcp' - assert '--ip_address' in cmd - assert '192.168.1.100' in cmd - assert '--port' in cmd - assert '1234' in cmd + assert "--source" in cmd + source_idx = cmd.index("--source") + assert cmd[source_idx + 1] == "rtltcp" + assert "--ip_address" in cmd + assert "192.168.1.100" in cmd + assert "--port" in cmd + assert "1234" in cmd # Should NOT have --source_id for remote - assert '--source_id' not in cmd + assert "--source_id" not in cmd - @patch('subprocess.Popen') - @patch('pty.openpty') - @patch('utils.weather_sat.register_process') + @patch("subprocess.Popen") + @patch("pty.openpty") + @patch("utils.weather_sat.register_process") def test_start_rtl_tcp_skips_device_resolve(self, mock_register, mock_pty, mock_popen): """start() with rtl_tcp should skip _resolve_device_id.""" - with patch('shutil.which', return_value='/usr/bin/satdump'), \ - patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id') as mock_resolve: - mock_pty.return_value = (10, 11) + with ( + patch("shutil.which", return_value="/usr/bin/satdump"), + patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id") as mock_resolve, + ): + # Use a real pipe — hardcoded fds like (10, 11) collide with fds + # the test process actually has open (DB, log files, capture) + mock_pty.return_value = os.pipe() mock_process = MagicMock() mock_process.poll.return_value = None mock_popen.return_value = mock_process @@ -185,83 +220,87 @@ class TestWeatherSatDecoder: decoder = WeatherSatDecoder() success, _ = decoder.start( - satellite='NOAA-18', + satellite="NOAA-18", device_index=0, gain=40.0, - rtl_tcp_host='10.0.0.1', + rtl_tcp_host="10.0.0.1", ) assert success is True mock_resolve.assert_not_called() - @patch('subprocess.Popen') - @patch('pty.openpty') + @patch("subprocess.Popen") + @patch("pty.openpty") def test_start_already_running(self, mock_pty, mock_popen): """start() should return True when already running.""" - with patch('shutil.which', return_value='/usr/bin/satdump'), \ - patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'): + with ( + patch("shutil.which", return_value="/usr/bin/satdump"), + patch("utils.weather_sat.WeatherSatDecoder._resolve_device_id", return_value="0"), + ): decoder = WeatherSatDecoder() decoder._running = True - success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + success, error_msg = decoder.start(satellite="NOAA-18", device_index=0, gain=40.0) assert success is True assert error_msg is None mock_popen.assert_not_called() - @patch('subprocess.Popen') - @patch('pty.openpty') + @patch("subprocess.Popen") + @patch("pty.openpty") def test_start_exception_handling(self, mock_pty, mock_popen): """start() should handle exceptions gracefully.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): - mock_pty.return_value = (10, 11) - mock_popen.side_effect = OSError('Device not found') + with patch("shutil.which", return_value="/usr/bin/satdump"): + # Use a real pipe — hardcoded fds like (10, 11) collide with fds + # the test process actually has open (DB, log files, capture) + mock_pty.return_value = os.pipe() + mock_popen.side_effect = OSError("Device not found") decoder = WeatherSatDecoder() callback = MagicMock() decoder.set_callback(callback) - success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + success, error_msg = decoder.start(satellite="NOAA-18", device_index=0, gain=40.0) assert success is False assert error_msg is not None assert decoder.is_running is False callback.assert_called() progress = callback.call_args[0][0] - assert progress.status == 'error' + assert progress.status == "error" def test_start_from_file_no_decoder(self): """start_from_file() should fail when no decoder available.""" - with patch('shutil.which', return_value=None): + with patch("shutil.which", return_value=None): decoder = WeatherSatDecoder() callback = MagicMock() decoder.set_callback(callback) success, error_msg = decoder.start_from_file( - satellite='NOAA-18', - input_file='data/test.wav', + satellite="NOAA-18", + input_file="data/test.wav", ) assert success is False assert error_msg is not None callback.assert_called() - @patch('subprocess.Popen') - @patch('pty.openpty') - @patch('pathlib.Path.is_file', return_value=True) - @patch('pathlib.Path.resolve') + @patch("subprocess.Popen") + @patch("pty.openpty") + @patch("pathlib.Path.is_file", return_value=True) + @patch("pathlib.Path.resolve") def test_start_from_file_success(self, mock_resolve, mock_is_file, mock_pty, mock_popen): """start_from_file() should successfully decode from file.""" - with patch('shutil.which', return_value='/usr/bin/satdump'), \ - patch('utils.weather_sat.register_process'): - + with patch("shutil.which", return_value="/usr/bin/satdump"), patch("utils.weather_sat.register_process"): # Mock path resolution mock_path = MagicMock() mock_path.is_relative_to.return_value = True - mock_path.suffix = '.wav' + mock_path.suffix = ".wav" mock_resolve.return_value = mock_path - mock_pty.return_value = (10, 11) + # Use a real pipe — hardcoded fds like (10, 11) collide with fds + # the test process actually has open (DB, log files, capture) + mock_pty.return_value = os.pipe() mock_process = MagicMock() mock_process.poll.return_value = None # Process still running mock_popen.return_value = mock_process @@ -271,27 +310,27 @@ class TestWeatherSatDecoder: decoder.set_callback(callback) success, error_msg = decoder.start_from_file( - satellite='NOAA-18', - input_file='data/test.wav', + satellite="NOAA-18", + input_file="data/test.wav", sample_rate=1000000, ) assert success is True assert error_msg is None assert decoder.is_running is True - assert decoder.current_satellite == 'NOAA-18' + assert decoder.current_satellite == "NOAA-18" mock_popen.assert_called_once() cmd = mock_popen.call_args[0][0] - assert cmd[0] == 'satdump' - assert 'noaa_apt' in cmd - assert 'audio_wav' in cmd - assert '--samplerate' in cmd + assert cmd[0] == "satdump" + assert "noaa_apt" in cmd + assert "audio_wav" in cmd + assert "--samplerate" in cmd - @patch('pathlib.Path.resolve') + @patch("pathlib.Path.resolve") def test_start_from_file_path_traversal(self, mock_resolve): """start_from_file() should block path traversal.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): # Mock path outside allowed directory mock_path = MagicMock() mock_path.is_relative_to.return_value = False @@ -302,20 +341,20 @@ class TestWeatherSatDecoder: decoder.set_callback(callback) success, error_msg = decoder.start_from_file( - satellite='NOAA-18', - input_file='/etc/passwd', + satellite="NOAA-18", + input_file="/etc/passwd", ) assert success is False callback.assert_called() progress = callback.call_args[0][0] - assert 'data/ directory' in progress.message + assert "must be under INTERCEPT data" in progress.message - @patch('pathlib.Path.is_file', return_value=False) - @patch('pathlib.Path.resolve') + @patch("pathlib.Path.is_file", return_value=False) + @patch("pathlib.Path.resolve") def test_start_from_file_not_found(self, mock_resolve, mock_is_file): """start_from_file() should fail when file not found.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): mock_path = MagicMock() mock_path.is_relative_to.return_value = True mock_resolve.return_value = mock_path @@ -325,32 +364,32 @@ class TestWeatherSatDecoder: decoder.set_callback(callback) success, error_msg = decoder.start_from_file( - satellite='NOAA-18', - input_file='data/missing.wav', + satellite="NOAA-18", + input_file="data/missing.wav", ) assert success is False callback.assert_called() progress = callback.call_args[0][0] - assert 'not found' in progress.message.lower() + assert "not found" in progress.message.lower() def test_stop_not_running(self): """stop() should be safe when not running.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() decoder.stop() # Should not raise - @patch('utils.weather_sat.safe_terminate') + @patch("utils.weather_sat.safe_terminate") def test_stop_running(self, mock_terminate): """stop() should terminate process.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() mock_process = MagicMock() decoder._process = mock_process decoder._running = True decoder._pty_master_fd = 10 - with patch('os.close') as mock_close: + with patch("os.close") as mock_close: decoder.stop() assert decoder._running is False @@ -359,21 +398,21 @@ class TestWeatherSatDecoder: def test_get_images_empty(self): """get_images() should return empty list initially.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() images = decoder.get_images() assert images == [] - @patch('pathlib.Path.glob') - @patch('pathlib.Path.stat') + @patch("pathlib.Path.glob") + @patch("pathlib.Path.stat") def test_get_images_scans_directory(self, mock_stat, mock_glob): """get_images() should scan output directory.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() # Mock image files mock_file = MagicMock() - mock_file.name = 'NOAA-18_test.png' + mock_file.name = "NOAA-18_test.png" mock_file.stat.return_value.st_size = 10000 mock_file.stat.return_value.st_mtime = time.time() mock_glob.return_value = [mock_file] @@ -381,39 +420,39 @@ class TestWeatherSatDecoder: images = decoder.get_images() assert len(images) == 1 - assert images[0].filename == 'NOAA-18_test.png' - assert images[0].satellite == 'NOAA-18' + assert images[0].filename == "NOAA-18_test.png" + assert images[0].satellite == "NOAA-18" def test_delete_image_success(self): """delete_image() should delete file.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() - with patch('pathlib.Path.exists', return_value=True), \ - patch('pathlib.Path.unlink') as mock_unlink: - - result = decoder.delete_image('test.png') + with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.unlink") as mock_unlink: + result = decoder.delete_image("test.png") assert result is True mock_unlink.assert_called_once() def test_delete_image_not_found(self): """delete_image() should return False for non-existent file.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() - with patch('pathlib.Path.exists', return_value=False): - result = decoder.delete_image('missing.png') + with patch("pathlib.Path.exists", return_value=False): + result = decoder.delete_image("missing.png") assert result is False def test_delete_all_images(self): """delete_all_images() should delete all images.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() mock_files = [MagicMock() for _ in range(3)] - with patch('pathlib.Path.glob', return_value=mock_files): + # delete_all_images globs three extensions; return files for the + # first pattern only so each mock is deleted exactly once + with patch("pathlib.Path.glob", side_effect=[mock_files, [], []]): count = decoder.delete_all_images() assert count == 3 @@ -422,74 +461,74 @@ class TestWeatherSatDecoder: def test_get_status_idle(self): """get_status() should return idle status.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() status = decoder.get_status() - assert status['available'] is True - assert status['decoder'] == 'satdump' - assert status['running'] is False - assert status['satellite'] == '' + assert status["available"] is True + assert status["decoder"] == "satdump" + assert status["running"] is False + assert status["satellite"] == "" def test_get_status_running(self): """get_status() should return running status.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() decoder._running = True - decoder._current_satellite = 'NOAA-18' + decoder._current_satellite = "NOAA-18" decoder._current_frequency = 137.9125 - decoder._current_mode = 'APT' + decoder._current_mode = "APT" decoder._capture_start_time = time.time() - 60 status = decoder.get_status() - assert status['running'] is True - assert status['satellite'] == 'NOAA-18' - assert status['frequency'] == 137.9125 - assert status['mode'] == 'APT' - assert status['elapsed_seconds'] >= 60 + assert status["running"] is True + assert status["satellite"] == "NOAA-18" + assert status["frequency"] == 137.9125 + assert status["mode"] == "APT" + assert status["elapsed_seconds"] >= 60 def test_classify_log_type_error(self): """_classify_log_type() should detect errors.""" - assert WeatherSatDecoder._classify_log_type('(E) Error occurred') == 'error' - assert WeatherSatDecoder._classify_log_type('Failed to open device') == 'error' + assert WeatherSatDecoder._classify_log_type("(E) Error occurred") == "error" + assert WeatherSatDecoder._classify_log_type("Failed to open device") == "error" def test_classify_log_type_progress(self): """_classify_log_type() should detect progress.""" - assert WeatherSatDecoder._classify_log_type('Progress: 50%') == 'progress' + assert WeatherSatDecoder._classify_log_type("Progress: 50%") == "progress" def test_classify_log_type_save(self): """_classify_log_type() should detect save events.""" - assert WeatherSatDecoder._classify_log_type('Saved image: test.png') == 'save' - assert WeatherSatDecoder._classify_log_type('Writing output file') == 'save' + assert WeatherSatDecoder._classify_log_type("Saved image: test.png") == "save" + assert WeatherSatDecoder._classify_log_type("Writing output file") == "save" def test_classify_log_type_signal(self): """_classify_log_type() should detect signal events.""" - assert WeatherSatDecoder._classify_log_type('Signal detected') == 'signal' - assert WeatherSatDecoder._classify_log_type('Lock acquired') == 'signal' + assert WeatherSatDecoder._classify_log_type("Signal detected") == "signal" + assert WeatherSatDecoder._classify_log_type("Lock acquired") == "signal" def test_classify_log_type_warning(self): """_classify_log_type() should detect warnings.""" - assert WeatherSatDecoder._classify_log_type('(W) Low signal quality') == 'warning' + assert WeatherSatDecoder._classify_log_type("(W) Low signal quality") == "warning" def test_classify_log_type_debug(self): """_classify_log_type() should detect debug messages.""" - assert WeatherSatDecoder._classify_log_type('(D) Debug info') == 'debug' + assert WeatherSatDecoder._classify_log_type("(D) Debug info") == "debug" - @patch('subprocess.run') + @patch("subprocess.run") def test_resolve_device_id_success(self, mock_run): """_resolve_device_id() should extract serial from rtl_test.""" mock_result = MagicMock() - mock_result.stdout = 'Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000' - mock_result.stderr = '' + mock_result.stdout = "Found 1 device(s):\n 0: RTLSDRBlog, SN: 00004000" + mock_result.stderr = "" mock_run.return_value = mock_result serial = WeatherSatDecoder._resolve_device_id(0) - assert serial == '00004000' + assert serial == "00004000" mock_run.assert_called_once() - @patch('subprocess.run') + @patch("subprocess.run") def test_resolve_device_id_fallback(self, mock_run): """_resolve_device_id() should return None when no serial found.""" mock_run.side_effect = FileNotFoundError @@ -500,59 +539,59 @@ class TestWeatherSatDecoder: def test_parse_product_name_rgb(self): """_parse_product_name() should identify RGB composite.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() - product = decoder._parse_product_name(Path('/tmp/output/rgb_composite.png')) - assert product == 'RGB Composite' + product = decoder._parse_product_name(Path("/tmp/output/rgb_composite.png")) + assert product == "RGB Composite" def test_parse_product_name_thermal(self): """_parse_product_name() should identify thermal imagery.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() - product = decoder._parse_product_name(Path('/tmp/output/thermal_image.png')) - assert product == 'Thermal' + product = decoder._parse_product_name(Path("/tmp/output/thermal_image.png")) + assert product == "Thermal" def test_parse_product_name_channel(self): """_parse_product_name() should identify channel images.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() - product = decoder._parse_product_name(Path('/tmp/output/channel_3.png')) - assert product == 'Channel 3' + product = decoder._parse_product_name(Path("/tmp/output/channel_3.png")) + assert product == "Channel 3" def test_parse_product_name_unknown(self): """_parse_product_name() should return stem for unknown products.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() - product = decoder._parse_product_name(Path('/tmp/output/unknown_image.png')) - assert product == 'unknown_image' + product = decoder._parse_product_name(Path("/tmp/output/unknown_image.png")) + assert product == "unknown_image" def test_emit_progress(self): """_emit_progress() should call callback.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() callback = MagicMock() decoder.set_callback(callback) - progress = CaptureProgress(status='capturing', message='Test') + progress = CaptureProgress(status="capturing", message="Test") decoder._emit_progress(progress) callback.assert_called_once_with(progress) def test_emit_progress_no_callback(self): """_emit_progress() should handle missing callback.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() - progress = CaptureProgress(status='capturing', message='Test') + progress = CaptureProgress(status="capturing", message="Test") decoder._emit_progress(progress) # Should not raise def test_emit_progress_callback_exception(self): """_emit_progress() should handle callback exceptions.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): decoder = WeatherSatDecoder() - callback = MagicMock(side_effect=Exception('Callback error')) + callback = MagicMock(side_effect=Exception("Callback error")) decoder.set_callback(callback) - progress = CaptureProgress(status='capturing', message='Test') + progress = CaptureProgress(status="capturing", message="Test") decoder._emit_progress(progress) # Should not raise @@ -562,26 +601,26 @@ class TestWeatherSatImage: def test_to_dict(self): """WeatherSatImage.to_dict() should serialize correctly.""" image = WeatherSatImage( - filename='test.png', - path=Path('/tmp/test.png'), - satellite='NOAA-18', - mode='APT', + filename="test.png", + path=Path("/tmp/test.png"), + satellite="NOAA-18", + mode="APT", timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), frequency=137.9125, size_bytes=12345, - product='RGB Composite', + product="RGB Composite", ) data = image.to_dict() - assert data['filename'] == 'test.png' - assert data['satellite'] == 'NOAA-18' - assert data['mode'] == 'APT' - assert data['timestamp'] == '2024-01-01T12:00:00+00:00' - assert data['frequency'] == 137.9125 - assert data['size_bytes'] == 12345 - assert data['product'] == 'RGB Composite' - assert data['url'] == '/weather-sat/images/test.png' + assert data["filename"] == "test.png" + assert data["satellite"] == "NOAA-18" + assert data["mode"] == "APT" + assert data["timestamp"] == "2024-01-01T12:00:00+00:00" + assert data["frequency"] == 137.9125 + assert data["size_bytes"] == 12345 + assert data["product"] == "RGB Composite" + assert data["url"] == "/weather-sat/images/test.png" class TestCaptureProgress: @@ -589,51 +628,51 @@ class TestCaptureProgress: def test_to_dict_minimal(self): """CaptureProgress.to_dict() with minimal fields.""" - progress = CaptureProgress(status='idle') + progress = CaptureProgress(status="idle") data = progress.to_dict() - assert data['type'] == 'weather_sat_progress' - assert data['status'] == 'idle' - assert data['satellite'] == '' - assert data['message'] == '' - assert data['progress'] == 0 + assert data["type"] == "weather_sat_progress" + assert data["status"] == "idle" + assert data["satellite"] == "" + assert data["message"] == "" + assert data["progress"] == 0 def test_to_dict_complete(self): """CaptureProgress.to_dict() with all fields.""" image = WeatherSatImage( - filename='test.png', - path=Path('/tmp/test.png'), - satellite='NOAA-18', - mode='APT', + filename="test.png", + path=Path("/tmp/test.png"), + satellite="NOAA-18", + mode="APT", timestamp=datetime.now(timezone.utc), frequency=137.9125, ) progress = CaptureProgress( - status='complete', - satellite='NOAA-18', + status="complete", + satellite="NOAA-18", frequency=137.9125, - mode='APT', - message='Capture complete', + mode="APT", + message="Capture complete", progress_percent=100, elapsed_seconds=600, image=image, - log_type='info', - capture_phase='complete', + log_type="info", + capture_phase="complete", ) data = progress.to_dict() - assert data['status'] == 'complete' - assert data['satellite'] == 'NOAA-18' - assert data['frequency'] == 137.9125 - assert data['mode'] == 'APT' - assert data['message'] == 'Capture complete' - assert data['progress'] == 100 - assert data['elapsed_seconds'] == 600 - assert 'image' in data - assert data['log_type'] == 'info' - assert data['capture_phase'] == 'complete' + assert data["status"] == "complete" + assert data["satellite"] == "NOAA-18" + assert data["frequency"] == 137.9125 + assert data["mode"] == "APT" + assert data["message"] == "Capture complete" + assert data["progress"] == 100 + assert data["elapsed_seconds"] == 600 + assert "image" in data + assert data["log_type"] == "info" + assert data["capture_phase"] == "complete" class TestGlobalFunctions: @@ -641,8 +680,9 @@ class TestGlobalFunctions: def test_get_weather_sat_decoder_singleton(self): """get_weather_sat_decoder() should return singleton.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): import utils.weather_sat as mod + old = mod._decoder mod._decoder = None @@ -656,8 +696,9 @@ class TestGlobalFunctions: def test_is_weather_sat_available_true(self): """is_weather_sat_available() should return True when available.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch("shutil.which", return_value="/usr/bin/satdump"): import utils.weather_sat as mod + old = mod._decoder mod._decoder = None @@ -668,8 +709,9 @@ class TestGlobalFunctions: def test_is_weather_sat_available_false(self): """is_weather_sat_available() should return False when unavailable.""" - with patch('shutil.which', return_value=None): + with patch("shutil.which", return_value=None): import utils.weather_sat as mod + old = mod._decoder mod._decoder = None @@ -684,26 +726,26 @@ class TestWeatherSatellitesConstant: def test_weather_satellites_structure(self): """WEATHER_SATELLITES should have correct structure.""" - assert 'NOAA-18' in WEATHER_SATELLITES - sat = WEATHER_SATELLITES['NOAA-18'] + assert "NOAA-18" in WEATHER_SATELLITES + sat = WEATHER_SATELLITES["NOAA-18"] - assert 'name' in sat - assert 'frequency' in sat - assert 'mode' in sat - assert 'pipeline' in sat - assert 'tle_key' in sat - assert 'description' in sat - assert 'active' in sat + assert "name" in sat + assert "frequency" in sat + assert "mode" in sat + assert "pipeline" in sat + assert "tle_key" in sat + assert "description" in sat + assert "active" in sat def test_noaa_satellites(self): """NOAA satellites should have correct frequencies.""" - assert WEATHER_SATELLITES['NOAA-15']['frequency'] == 137.620 - assert WEATHER_SATELLITES['NOAA-18']['frequency'] == 137.9125 - assert WEATHER_SATELLITES['NOAA-19']['frequency'] == 137.100 + assert WEATHER_SATELLITES["NOAA-15"]["frequency"] == 137.620 + assert WEATHER_SATELLITES["NOAA-18"]["frequency"] == 137.9125 + assert WEATHER_SATELLITES["NOAA-19"]["frequency"] == 137.100 def test_meteor_satellite(self): """Meteor satellite should use LRPT mode.""" - meteor = WEATHER_SATELLITES['METEOR-M2-3'] - assert meteor['mode'] == 'LRPT' - assert meteor['frequency'] == 137.900 - assert meteor['pipeline'] == 'meteor_m2-x_lrpt' + meteor = WEATHER_SATELLITES["METEOR-M2-3"] + assert meteor["mode"] == "LRPT" + assert meteor["frequency"] == 137.900 + assert meteor["pipeline"] == "meteor_m2-x_lrpt" diff --git a/tests/test_weather_sat_routes.py b/tests/test_weather_sat_routes.py index 642d65d..c024ddc 100644 --- a/tests/test_weather_sat_routes.py +++ b/tests/test_weather_sat_routes.py @@ -20,7 +20,7 @@ from utils.weather_sat import WeatherSatImage def client(client): """Authenticated client for weather-sat route tests.""" with client.session_transaction() as sess: - sess['logged_in'] = True + sess["logged_in"] = True return client @@ -29,241 +29,216 @@ class TestWeatherSatRoutes: def test_get_status(self, client): """GET /weather-sat/status returns decoder status.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() mock_decoder.get_status.return_value = { - 'available': True, - 'decoder': 'satdump', - 'running': False, - 'satellite': '', - 'frequency': 0.0, - 'mode': '', - 'elapsed_seconds': 0, - 'image_count': 0, + "available": True, + "decoder": "satdump", + "running": False, + "satellite": "", + "frequency": 0.0, + "mode": "", + "elapsed_seconds": 0, + "image_count": 0, } mock_get.return_value = mock_decoder - response = client.get('/weather-sat/status') + response = client.get("/weather-sat/status") assert response.status_code == 200 data = response.get_json() - assert data['available'] is True - assert data['decoder'] == 'satdump' - assert data['running'] is False + assert data["available"] is True + assert data["decoder"] == "satdump" + assert data["running"] is False def test_list_satellites(self, client): """GET /weather-sat/satellites returns satellite list.""" - response = client.get('/weather-sat/satellites') + response = client.get("/weather-sat/satellites") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert 'satellites' in data - assert len(data['satellites']) > 0 + assert data["status"] == "ok" + assert "satellites" in data + assert len(data["satellites"]) > 0 # Check structure - sat = data['satellites'][0] - assert 'key' in sat - assert 'name' in sat - assert 'frequency' in sat - assert 'mode' in sat - assert 'description' in sat - assert 'active' in sat + sat = data["satellites"][0] + assert "key" in sat + assert "name" in sat + assert "frequency" in sat + assert "mode" in sat + assert "description" in sat + assert "active" in sat # Verify NOAA-18 is in list - noaa_18 = next((s for s in data['satellites'] if s['key'] == 'NOAA-18'), None) + noaa_18 = next((s for s in data["satellites"] if s["key"] == "NOAA-18"), None) assert noaa_18 is not None - assert noaa_18['frequency'] == 137.9125 - assert noaa_18['mode'] == 'APT' + assert noaa_18["frequency"] == 137.9125 + assert noaa_18["mode"] == "APT" def test_start_capture_success(self, client): """POST /weather-sat/start successfully starts capture.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('routes.weather_sat.queue.Queue'), \ - patch('app.claim_sdr_device', return_value=None): - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("routes.weather_sat.queue.Queue"), + patch("app.claim_sdr_device", return_value=None), + ): mock_decoder = MagicMock() mock_decoder.is_running = False mock_decoder.start.return_value = (True, None) mock_get.return_value = mock_decoder payload = { - 'satellite': 'NOAA-18', - 'device': 0, - 'gain': 40.0, - 'bias_t': False, + "satellite": "NOAA-18", + "device": 0, + "gain": 40.0, + "bias_t": False, } - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'started' - assert data['satellite'] == 'NOAA-18' - assert data['frequency'] == 137.9125 - assert data['mode'] == 'APT' - assert data['device'] == 0 + assert data["status"] == "started" + assert data["satellite"] == "NOAA-18" + assert data["frequency"] == 137.9125 + assert data["mode"] == "APT" + assert data["device"] == 0 mock_decoder.start.assert_called_once() call_kwargs = mock_decoder.start.call_args[1] - assert call_kwargs['satellite'] == 'NOAA-18' - assert call_kwargs['device_index'] == 0 - assert call_kwargs['gain'] == 40.0 - assert call_kwargs['bias_t'] is False + assert call_kwargs["satellite"] == "NOAA-18" + assert call_kwargs["device_index"] == 0 + assert call_kwargs["gain"] == 40.0 + assert call_kwargs["bias_t"] is False def test_start_capture_no_satdump(self, client): """POST /weather-sat/start returns error when SatDump unavailable.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=False): - payload = {'satellite': 'NOAA-18'} - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + with patch("routes.weather_sat.is_weather_sat_available", return_value=False): + payload = {"satellite": "NOAA-18"} + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' - assert 'SatDump not installed' in data['message'] + assert data["status"] == "error" + assert "SatDump not installed" in data["message"] def test_start_capture_already_running(self, client): """POST /weather-sat/start when already running.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + ): mock_decoder = MagicMock() mock_decoder.is_running = True - mock_decoder.current_satellite = 'NOAA-19' + mock_decoder.current_satellite = "NOAA-19" mock_decoder.current_frequency = 137.100 mock_get.return_value = mock_decoder - payload = {'satellite': 'NOAA-18'} - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + payload = {"satellite": "NOAA-18"} + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'already_running' - assert data['satellite'] == 'NOAA-19' + assert data["status"] == "already_running" + assert data["satellite"] == "NOAA-19" def test_start_capture_invalid_satellite(self, client): """POST /weather-sat/start with invalid satellite.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + ): mock_decoder = MagicMock() mock_decoder.is_running = False mock_get.return_value = mock_decoder - payload = {'satellite': 'FAKE-SAT-99'} - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + payload = {"satellite": "FAKE-SAT-99"} + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' - assert 'Invalid satellite' in data['message'] + assert data["status"] == "error" + assert "Invalid satellite" in data["message"] def test_start_capture_invalid_device(self, client): """POST /weather-sat/start with invalid device index.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + ): mock_decoder = MagicMock() mock_decoder.is_running = False mock_get.return_value = mock_decoder - payload = {'satellite': 'NOAA-18', 'device': -1} - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + payload = {"satellite": "NOAA-18", "device": -1} + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" def test_start_capture_invalid_gain(self, client): """POST /weather-sat/start with invalid gain.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + ): mock_decoder = MagicMock() mock_decoder.is_running = False mock_get.return_value = mock_decoder - payload = {'satellite': 'NOAA-18', 'gain': 999} - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + payload = {"satellite": "NOAA-18", "gain": 999} + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" def test_start_capture_device_busy(self, client): """POST /weather-sat/start when SDR device is busy.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('app.claim_sdr_device', return_value='Device busy with pager'): - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("app.claim_sdr_device", return_value="Device busy with pager"), + ): mock_decoder = MagicMock() mock_decoder.is_running = False mock_get.return_value = mock_decoder - payload = {'satellite': 'NOAA-18'} - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + payload = {"satellite": "NOAA-18"} + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 409 data = response.get_json() - assert data['status'] == 'error' - assert data['error_type'] == 'DEVICE_BUSY' - assert 'Device busy' in data['message'] + assert data["status"] == "error" + assert data["error_type"] == "DEVICE_BUSY" + assert "Device busy" in data["message"] def test_start_capture_rtl_tcp_success(self, client): """POST /weather-sat/start with rtl_tcp remote SDR.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('app.claim_sdr_device') as mock_claim: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("app.claim_sdr_device") as mock_claim, + ): mock_decoder = MagicMock() mock_decoder.is_running = False mock_decoder.start.return_value = (True, None) mock_get.return_value = mock_decoder payload = { - 'satellite': 'NOAA-18', - 'device': 0, - 'gain': 40.0, - 'rtl_tcp_host': '192.168.1.100', - 'rtl_tcp_port': 1234, + "satellite": "NOAA-18", + "device": 0, + "gain": 40.0, + "rtl_tcp_host": "192.168.1.100", + "rtl_tcp_port": 1234, } - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'started' + assert data["status"] == "started" # Device claim should NOT be called for remote SDR mock_claim.assert_not_called() @@ -271,63 +246,58 @@ class TestWeatherSatRoutes: # Verify rtl_tcp params passed to decoder mock_decoder.start.assert_called_once() call_kwargs = mock_decoder.start.call_args - assert call_kwargs[1]['rtl_tcp_host'] == '192.168.1.100' - assert call_kwargs[1]['rtl_tcp_port'] == 1234 + assert call_kwargs[1]["rtl_tcp_host"] == "192.168.1.100" + assert call_kwargs[1]["rtl_tcp_port"] == 1234 def test_start_capture_rtl_tcp_invalid_host(self, client): """POST /weather-sat/start with invalid rtl_tcp host.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + ): mock_decoder = MagicMock() mock_decoder.is_running = False mock_get.return_value = mock_decoder payload = { - 'satellite': 'NOAA-18', - 'rtl_tcp_host': 'not a valid host!@#', + "satellite": "NOAA-18", + "rtl_tcp_host": "not a valid host!@#", } - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" def test_start_capture_start_failure(self, client): """POST /weather-sat/start when decoder.start() fails.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('app.claim_sdr_device', return_value=None): - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("app.claim_sdr_device", return_value=None), + ): mock_decoder = MagicMock() mock_decoder.is_running = False - mock_decoder.start.return_value = (False, 'SatDump exited immediately (code 1)') + mock_decoder.start.return_value = (False, "SatDump exited immediately (code 1)") mock_get.return_value = mock_decoder - payload = {'satellite': 'NOAA-18'} - response = client.post( - '/weather-sat/start', - data=json.dumps(payload), - content_type='application/json' - ) + payload = {"satellite": "NOAA-18"} + response = client.post("/weather-sat/start", data=json.dumps(payload), content_type="application/json") assert response.status_code == 500 data = response.get_json() - assert data['status'] == 'error' - assert 'SatDump exited immediately' in data['message'] + assert data["status"] == "error" + assert "SatDump exited immediately" in data["message"] def test_test_decode_success(self, client): """POST /weather-sat/test-decode successfully starts file decode.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('pathlib.Path.is_file', return_value=True), \ - patch('pathlib.Path.resolve') as mock_resolve: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("pathlib.Path.is_file", return_value=True), + patch("pathlib.Path.resolve") as mock_resolve, + ): # Mock path resolution to be under data/ mock_path = MagicMock() mock_path.is_relative_to.return_value = True @@ -339,29 +309,28 @@ class TestWeatherSatRoutes: mock_get.return_value = mock_decoder payload = { - 'satellite': 'NOAA-18', - 'input_file': 'data/weather_sat/test.wav', - 'sample_rate': 1000000, + "satellite": "NOAA-18", + "input_file": "data/weather_sat/test.wav", + "sample_rate": 1000000, } response = client.post( - '/weather-sat/test-decode', - data=json.dumps(payload), - content_type='application/json' + "/weather-sat/test-decode", data=json.dumps(payload), content_type="application/json" ) assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'started' - assert data['satellite'] == 'NOAA-18' - assert data['source'] == 'file' + assert data["status"] == "started" + assert data["satellite"] == "NOAA-18" + assert data["source"] == "file" def test_test_decode_invalid_path(self, client): """POST /weather-sat/test-decode with path outside data/.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('pathlib.Path.resolve') as mock_resolve: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("pathlib.Path.resolve") as mock_resolve, + ): # Mock path outside allowed directory mock_path = MagicMock() mock_path.is_relative_to.return_value = False @@ -372,28 +341,27 @@ class TestWeatherSatRoutes: mock_get.return_value = mock_decoder payload = { - 'satellite': 'NOAA-18', - 'input_file': '/etc/passwd', + "satellite": "NOAA-18", + "input_file": "/etc/passwd", } response = client.post( - '/weather-sat/test-decode', - data=json.dumps(payload), - content_type='application/json' + "/weather-sat/test-decode", data=json.dumps(payload), content_type="application/json" ) assert response.status_code == 403 data = response.get_json() - assert data['status'] == 'error' - assert 'data/ directory' in data['message'] + assert data["status"] == "error" + assert "must be under INTERCEPT data" in data["message"] def test_test_decode_file_not_found(self, client): """POST /weather-sat/test-decode with non-existent file.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('pathlib.Path.is_file', return_value=False), \ - patch('pathlib.Path.resolve') as mock_resolve: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("pathlib.Path.is_file", return_value=False), + patch("pathlib.Path.resolve") as mock_resolve, + ): mock_path = MagicMock() mock_path.is_relative_to.return_value = True mock_resolve.return_value = mock_path @@ -403,28 +371,27 @@ class TestWeatherSatRoutes: mock_get.return_value = mock_decoder payload = { - 'satellite': 'NOAA-18', - 'input_file': 'data/missing.wav', + "satellite": "NOAA-18", + "input_file": "data/missing.wav", } response = client.post( - '/weather-sat/test-decode', - data=json.dumps(payload), - content_type='application/json' + "/weather-sat/test-decode", data=json.dumps(payload), content_type="application/json" ) assert response.status_code == 404 data = response.get_json() - assert data['status'] == 'error' - assert 'not found' in data['message'].lower() + assert data["status"] == "error" + assert "not found" in data["message"].lower() def test_test_decode_invalid_sample_rate(self, client): """POST /weather-sat/test-decode with invalid sample rate.""" - with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \ - patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('pathlib.Path.is_file', return_value=True), \ - patch('pathlib.Path.resolve') as mock_resolve: - + with ( + patch("routes.weather_sat.is_weather_sat_available", return_value=True), + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("pathlib.Path.is_file", return_value=True), + patch("pathlib.Path.resolve") as mock_resolve, + ): mock_path = MagicMock() mock_path.is_relative_to.return_value = True mock_resolve.return_value = mock_path @@ -434,113 +401,111 @@ class TestWeatherSatRoutes: mock_get.return_value = mock_decoder payload = { - 'satellite': 'NOAA-18', - 'input_file': 'data/test.wav', - 'sample_rate': 100, # Too low + "satellite": "NOAA-18", + "input_file": "data/test.wav", + "sample_rate": 100, # Too low } response = client.post( - '/weather-sat/test-decode', - data=json.dumps(payload), - content_type='application/json' + "/weather-sat/test-decode", data=json.dumps(payload), content_type="application/json" ) assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' - assert 'sample_rate' in data['message'] + assert data["status"] == "error" + assert "sample_rate" in data["message"] def test_stop_capture(self, client): """POST /weather-sat/stop stops capture.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() mock_decoder.device_index = 0 mock_get.return_value = mock_decoder - response = client.post('/weather-sat/stop') + response = client.post("/weather-sat/stop") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'stopped' + assert data["status"] == "stopped" mock_decoder.stop.assert_called_once() def test_list_images_empty(self, client): """GET /weather-sat/images with no images.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() mock_decoder.get_images.return_value = [] mock_get.return_value = mock_decoder - response = client.get('/weather-sat/images') + response = client.get("/weather-sat/images") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['images'] == [] - assert data['count'] == 0 + assert data["status"] == "ok" + assert data["images"] == [] + assert data["count"] == 0 def test_list_images_with_data(self, client): """GET /weather-sat/images with images.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() image = WeatherSatImage( - filename='NOAA-18_test.png', - path=Path('/tmp/test.png'), - satellite='NOAA-18', - mode='APT', + filename="NOAA-18_test.png", + path=Path("/tmp/test.png"), + satellite="NOAA-18", + mode="APT", timestamp=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), frequency=137.9125, size_bytes=12345, - product='RGB Composite', + product="RGB Composite", ) mock_decoder.get_images.return_value = [image] mock_get.return_value = mock_decoder - response = client.get('/weather-sat/images') + response = client.get("/weather-sat/images") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['count'] == 1 - assert data['images'][0]['filename'] == 'NOAA-18_test.png' - assert data['images'][0]['satellite'] == 'NOAA-18' + assert data["status"] == "ok" + assert data["count"] == 1 + assert data["images"][0]["filename"] == "NOAA-18_test.png" + assert data["images"][0]["satellite"] == "NOAA-18" def test_list_images_with_filter(self, client): """GET /weather-sat/images with satellite filter.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() image1 = WeatherSatImage( - filename='NOAA-18_test.png', - path=Path('/tmp/test1.png'), - satellite='NOAA-18', - mode='APT', + filename="NOAA-18_test.png", + path=Path("/tmp/test1.png"), + satellite="NOAA-18", + mode="APT", timestamp=datetime.now(timezone.utc), frequency=137.9125, ) image2 = WeatherSatImage( - filename='NOAA-19_test.png', - path=Path('/tmp/test2.png'), - satellite='NOAA-19', - mode='APT', + filename="NOAA-19_test.png", + path=Path("/tmp/test2.png"), + satellite="NOAA-19", + mode="APT", timestamp=datetime.now(timezone.utc), frequency=137.100, ) mock_decoder.get_images.return_value = [image1, image2] mock_get.return_value = mock_decoder - response = client.get('/weather-sat/images?satellite=NOAA-18') + response = client.get("/weather-sat/images?satellite=NOAA-18") assert response.status_code == 200 data = response.get_json() - assert data['count'] == 1 - assert data['images'][0]['satellite'] == 'NOAA-18' + assert data["count"] == 1 + assert data["images"][0]["satellite"] == "NOAA-18" def test_list_images_with_limit(self, client): """GET /weather-sat/images with limit.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() images = [ WeatherSatImage( - filename=f'test{i}.png', - path=Path(f'/tmp/test{i}.png'), - satellite='NOAA-18', - mode='APT', + filename=f"test{i}.png", + path=Path(f"/tmp/test{i}.png"), + satellite="NOAA-18", + mode="APT", timestamp=datetime.now(timezone.utc), frequency=137.9125, ) @@ -549,185 +514,187 @@ class TestWeatherSatRoutes: mock_decoder.get_images.return_value = images mock_get.return_value = mock_decoder - response = client.get('/weather-sat/images?limit=5') + response = client.get("/weather-sat/images?limit=5") assert response.status_code == 200 data = response.get_json() - assert data['count'] == 5 + assert data["count"] == 5 def test_get_image_success(self, client): """GET /weather-sat/images/ serves image.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('routes.weather_sat.send_file') as mock_send, \ - patch('pathlib.Path.exists', return_value=True): - + with ( + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("routes.weather_sat.send_file") as mock_send, + patch("pathlib.Path.exists", return_value=True), + ): mock_decoder = MagicMock() - mock_decoder._output_dir = Path('/tmp') + mock_decoder._output_dir = Path("/tmp") mock_get.return_value = mock_decoder mock_send.return_value = MagicMock() - client.get('/weather-sat/images/test_image.png') + client.get("/weather-sat/images/test_image.png") mock_send.assert_called_once() call_args = mock_send.call_args - assert call_args[1]['mimetype'] == 'image/png' + assert call_args[1]["mimetype"] == "image/png" def test_get_image_invalid_filename(self, client): """GET /weather-sat/images/ with invalid filename.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() mock_get.return_value = mock_decoder - response = client.get('/weather-sat/images/bad!file@name.png') + response = client.get("/weather-sat/images/bad!file@name.png") assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' - assert 'Invalid filename' in data['message'] + assert data["status"] == "error" + assert "Invalid filename" in data["message"] def test_get_image_wrong_extension(self, client): """GET /weather-sat/images/ with wrong extension.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() mock_get.return_value = mock_decoder - response = client.get('/weather-sat/images/test.txt') + response = client.get("/weather-sat/images/test.txt") assert response.status_code == 400 data = response.get_json() - assert 'PNG/JPG' in data['message'] + assert "PNG/JPG" in data["message"] def test_get_image_not_found(self, client): """GET /weather-sat/images/ for non-existent image.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \ - patch('pathlib.Path.exists', return_value=False): - + with ( + patch("routes.weather_sat.get_weather_sat_decoder") as mock_get, + patch("pathlib.Path.exists", return_value=False), + ): mock_decoder = MagicMock() - mock_decoder._output_dir = Path('/tmp') + mock_decoder._output_dir = Path("/tmp") mock_get.return_value = mock_decoder - response = client.get('/weather-sat/images/missing.png') + response = client.get("/weather-sat/images/missing.png") assert response.status_code == 404 def test_delete_image_success(self, client): """DELETE /weather-sat/images/ deletes image.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() mock_decoder.delete_image.return_value = True mock_get.return_value = mock_decoder - response = client.delete('/weather-sat/images/test.png') + response = client.delete("/weather-sat/images/test.png") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'deleted' - assert data['filename'] == 'test.png' + assert data["status"] == "deleted" + assert data["filename"] == "test.png" def test_delete_image_not_found(self, client): """DELETE /weather-sat/images/ for non-existent image.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() mock_decoder.delete_image.return_value = False mock_get.return_value = mock_decoder - response = client.delete('/weather-sat/images/missing.png') + response = client.delete("/weather-sat/images/missing.png") assert response.status_code == 404 def test_delete_all_images(self, client): """DELETE /weather-sat/images deletes all images.""" - with patch('routes.weather_sat.get_weather_sat_decoder') as mock_get: + with patch("routes.weather_sat.get_weather_sat_decoder") as mock_get: mock_decoder = MagicMock() mock_decoder.delete_all_images.return_value = 5 mock_get.return_value = mock_decoder - response = client.delete('/weather-sat/images') + response = client.delete("/weather-sat/images") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['deleted'] == 5 + assert data["status"] == "ok" + assert data["deleted"] == 5 def test_stream_progress(self, client): """GET /weather-sat/stream returns SSE stream.""" - response = client.get('/weather-sat/stream') + response = client.get("/weather-sat/stream") assert response.status_code == 200 - assert response.mimetype == 'text/event-stream' - assert response.headers['Cache-Control'] == 'no-cache' + assert response.mimetype == "text/event-stream" + assert response.headers["Cache-Control"] == "no-cache" def test_get_passes_missing_params(self, client): """GET /weather-sat/passes without required params.""" - response = client.get('/weather-sat/passes') + response = client.get("/weather-sat/passes") assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' - assert 'latitude and longitude' in data['message'] + assert data["status"] == "error" + assert "latitude and longitude" in data["message"] def test_get_passes_invalid_coords(self, client): """GET /weather-sat/passes with invalid coordinates.""" - response = client.get('/weather-sat/passes?latitude=999&longitude=0') + response = client.get("/weather-sat/passes?latitude=999&longitude=0") assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" def test_get_passes_success(self, client): """GET /weather-sat/passes successfully predicts passes.""" - with patch('utils.weather_sat_predict.predict_passes') as mock_predict: + with patch("utils.weather_sat_predict.predict_passes") as mock_predict: mock_predict.return_value = [ { - 'id': 'NOAA-18_202401011200', - 'satellite': 'NOAA-18', - 'name': 'NOAA 18', - 'frequency': 137.9125, - 'mode': 'APT', - 'startTime': '2024-01-01 12:00 UTC', - 'startTimeISO': '2024-01-01T12:00:00+00:00', - 'endTimeISO': '2024-01-01T12:15:00+00:00', - 'maxEl': 45.0, - 'maxElAz': 180.0, - 'riseAz': 160.0, - 'setAz': 200.0, - 'duration': 15.0, - 'quality': 'good', + "id": "NOAA-18_202401011200", + "satellite": "NOAA-18", + "name": "NOAA 18", + "frequency": 137.9125, + "mode": "APT", + "startTime": "2024-01-01 12:00 UTC", + "startTimeISO": "2024-01-01T12:00:00+00:00", + "endTimeISO": "2024-01-01T12:15:00+00:00", + "maxEl": 45.0, + "maxElAz": 180.0, + "riseAz": 160.0, + "setAz": 200.0, + "duration": 15.0, + "quality": "good", } ] - response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') + response = client.get("/weather-sat/passes?latitude=51.5&longitude=-0.1") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['count'] == 1 - assert data['passes'][0]['satellite'] == 'NOAA-18' + assert data["status"] == "ok" + assert data["count"] == 1 + assert data["passes"][0]["satellite"] == "NOAA-18" def test_get_passes_with_options(self, client): """GET /weather-sat/passes with trajectory and ground track.""" - with patch('utils.weather_sat_predict.predict_passes') as mock_predict: + with patch("utils.weather_sat_predict.predict_passes") as mock_predict: mock_predict.return_value = [] response = client.get( - '/weather-sat/passes?latitude=51.5&longitude=-0.1&' - 'hours=48&min_elevation=20&trajectory=true&ground_track=true' + "/weather-sat/passes?latitude=51.5&longitude=-0.1&" + "hours=48&min_elevation=20&trajectory=true&ground_track=true" ) assert response.status_code == 200 mock_predict.assert_called_once() call_kwargs = mock_predict.call_args[1] - assert call_kwargs['lat'] == 51.5 - assert call_kwargs['lon'] == -0.1 - assert call_kwargs['hours'] == 48 - assert call_kwargs['min_elevation'] == 20.0 - assert call_kwargs['include_trajectory'] is True - assert call_kwargs['include_ground_track'] is True + assert call_kwargs["lat"] == 51.5 + assert call_kwargs["lon"] == -0.1 + assert call_kwargs["hours"] == 48 + assert call_kwargs["min_elevation"] == 20.0 + assert call_kwargs["include_trajectory"] is True + assert call_kwargs["include_ground_track"] is True def test_get_passes_import_error(self, client): """GET /weather-sat/passes when skyfield not installed.""" - with patch('utils.weather_sat_predict.predict_passes', side_effect=ImportError): - response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') + with patch("utils.weather_sat_predict.predict_passes", side_effect=ImportError): + response = client.get("/weather-sat/passes?latitude=51.5&longitude=-0.1") assert response.status_code == 503 data = response.get_json() - assert data['status'] == 'error' - assert 'skyfield' in data['message'] + assert data["status"] == "error" + assert "skyfield" in data["message"] def test_get_passes_prediction_error(self, client): """GET /weather-sat/passes when prediction fails.""" - with patch('utils.weather_sat_predict.predict_passes', side_effect=Exception('TLE error')): - response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1') + with patch("utils.weather_sat_predict.predict_passes", side_effect=Exception("TLE error")): + response = client.get("/weather-sat/passes?latitude=51.5&longitude=-0.1") assert response.status_code == 500 data = response.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" class TestWeatherSatScheduler: @@ -735,146 +702,140 @@ class TestWeatherSatScheduler: def test_enable_schedule_success(self, client): """POST /weather-sat/schedule/enable enables scheduler.""" - with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get: + with patch("utils.weather_sat_scheduler.get_weather_sat_scheduler") as mock_get: mock_scheduler = MagicMock() mock_scheduler.enable.return_value = { - 'enabled': True, - 'observer': {'latitude': 51.5, 'longitude': -0.1}, - 'device': 0, - 'gain': 40.0, - 'bias_t': False, - 'min_elevation': 15.0, - 'scheduled_count': 3, - 'total_passes': 3, + "enabled": True, + "observer": {"latitude": 51.5, "longitude": -0.1}, + "device": 0, + "gain": 40.0, + "bias_t": False, + "min_elevation": 15.0, + "scheduled_count": 3, + "total_passes": 3, } mock_get.return_value = mock_scheduler payload = { - 'latitude': 51.5, - 'longitude': -0.1, - 'min_elevation': 15, - 'device': 0, - 'gain': 40.0, - 'bias_t': False, + "latitude": 51.5, + "longitude": -0.1, + "min_elevation": 15, + "device": 0, + "gain": 40.0, + "bias_t": False, } response = client.post( - '/weather-sat/schedule/enable', - data=json.dumps(payload), - content_type='application/json' + "/weather-sat/schedule/enable", data=json.dumps(payload), content_type="application/json" ) assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['enabled'] is True + assert data["status"] == "ok" + assert data["enabled"] is True def test_enable_schedule_missing_coords(self, client): """POST /weather-sat/schedule/enable without coordinates.""" - payload = {'device': 0} + payload = {"device": 0} response = client.post( - '/weather-sat/schedule/enable', - data=json.dumps(payload), - content_type='application/json' + "/weather-sat/schedule/enable", data=json.dumps(payload), content_type="application/json" ) assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' - assert 'latitude and longitude' in data['message'] + assert data["status"] == "error" + assert "latitude and longitude" in data["message"] def test_enable_schedule_invalid_coords(self, client): """POST /weather-sat/schedule/enable with invalid coordinates.""" - payload = {'latitude': 999, 'longitude': 0} + payload = {"latitude": 999, "longitude": 0} response = client.post( - '/weather-sat/schedule/enable', - data=json.dumps(payload), - content_type='application/json' + "/weather-sat/schedule/enable", data=json.dumps(payload), content_type="application/json" ) assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" def test_disable_schedule(self, client): """POST /weather-sat/schedule/disable disables scheduler.""" - with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get: + with patch("utils.weather_sat_scheduler.get_weather_sat_scheduler") as mock_get: mock_scheduler = MagicMock() - mock_scheduler.disable.return_value = {'status': 'disabled'} + mock_scheduler.disable.return_value = {"status": "disabled"} mock_get.return_value = mock_scheduler - response = client.post('/weather-sat/schedule/disable') + response = client.post("/weather-sat/schedule/disable") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'disabled' + assert data["status"] == "disabled" def test_schedule_status(self, client): """GET /weather-sat/schedule/status returns scheduler status.""" - with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get: + with patch("utils.weather_sat_scheduler.get_weather_sat_scheduler") as mock_get: mock_scheduler = MagicMock() mock_scheduler.get_status.return_value = { - 'enabled': False, - 'observer': {'latitude': 0, 'longitude': 0}, - 'device': 0, - 'gain': 40.0, - 'bias_t': False, - 'min_elevation': 15.0, - 'scheduled_count': 0, - 'total_passes': 0, + "enabled": False, + "observer": {"latitude": 0, "longitude": 0}, + "device": 0, + "gain": 40.0, + "bias_t": False, + "min_elevation": 15.0, + "scheduled_count": 0, + "total_passes": 0, } mock_get.return_value = mock_scheduler - response = client.get('/weather-sat/schedule/status') + response = client.get("/weather-sat/schedule/status") assert response.status_code == 200 data = response.get_json() - assert 'enabled' in data + assert "enabled" in data def test_schedule_passes(self, client): """GET /weather-sat/schedule/passes lists scheduled passes.""" - with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get: + with patch("utils.weather_sat_scheduler.get_weather_sat_scheduler") as mock_get: mock_scheduler = MagicMock() mock_scheduler.get_passes.return_value = [ { - 'id': 'NOAA-18_202401011200', - 'satellite': 'NOAA-18', - 'status': 'scheduled', + "id": "NOAA-18_202401011200", + "satellite": "NOAA-18", + "status": "scheduled", } ] mock_get.return_value = mock_scheduler - response = client.get('/weather-sat/schedule/passes') + response = client.get("/weather-sat/schedule/passes") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['count'] == 1 + assert data["status"] == "ok" + assert data["count"] == 1 def test_skip_pass_success(self, client): """POST /weather-sat/schedule/skip/ skips a pass.""" - with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get: + with patch("utils.weather_sat_scheduler.get_weather_sat_scheduler") as mock_get: mock_scheduler = MagicMock() mock_scheduler.skip_pass.return_value = True mock_get.return_value = mock_scheduler - response = client.post('/weather-sat/schedule/skip/NOAA-18_202401011200') + response = client.post("/weather-sat/schedule/skip/NOAA-18_202401011200") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'skipped' - assert data['pass_id'] == 'NOAA-18_202401011200' + assert data["status"] == "skipped" + assert data["pass_id"] == "NOAA-18_202401011200" def test_skip_pass_not_found(self, client): """POST /weather-sat/schedule/skip/ for non-existent pass.""" - with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get: + with patch("utils.weather_sat_scheduler.get_weather_sat_scheduler") as mock_get: mock_scheduler = MagicMock() mock_scheduler.skip_pass.return_value = False mock_get.return_value = mock_scheduler - response = client.post('/weather-sat/schedule/skip/nonexistent') + response = client.post("/weather-sat/schedule/skip/nonexistent") assert response.status_code == 404 def test_skip_pass_invalid_id(self, client): """POST /weather-sat/schedule/skip/ with invalid ID.""" - response = client.post('/weather-sat/schedule/skip/invalid!pass@id') + response = client.post("/weather-sat/schedule/skip/invalid!pass@id") assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' - assert 'Invalid pass ID' in data['message'] + assert data["status"] == "error" + assert "Invalid pass ID" in data["message"] diff --git a/utils/agent_client.py b/utils/agent_client.py index ff89c3b..23bee93 100644 --- a/utils/agent_client.py +++ b/utils/agent_client.py @@ -8,7 +8,7 @@ import logging import requests -logger = logging.getLogger('intercept.agent_client') +logger = logging.getLogger("intercept.agent_client") class AgentHTTPError(RuntimeError): @@ -21,18 +21,14 @@ class AgentHTTPError(RuntimeError): class AgentConnectionError(AgentHTTPError): """Exception raised when agent is unreachable.""" + pass class AgentClient: """HTTP client for communicating with a remote Intercept agent.""" - def __init__( - self, - base_url: str, - api_key: str | None = None, - timeout: float = 60.0 - ): + def __init__(self, base_url: str, api_key: str | None = None, timeout: float = 60.0): """ Initialize agent client. @@ -41,15 +37,15 @@ class AgentClient: api_key: Optional API key for authentication timeout: Request timeout in seconds """ - self.base_url = base_url.rstrip('/') + self.base_url = base_url.rstrip("/") self.api_key = api_key self.timeout = timeout def _headers(self) -> dict: """Get request headers.""" - headers = {'Content-Type': 'application/json'} + headers = {"Content-Type": "application/json"} if self.api_key: - headers['X-API-Key'] = self.api_key + headers["X-API-Key"] = self.api_key return headers def _get(self, path: str, params: dict | None = None) -> dict: @@ -69,12 +65,7 @@ class AgentClient: """ url = f"{self.base_url}{path}" try: - response = requests.get( - url, - headers=self._headers(), - params=params, - timeout=self.timeout - ) + response = requests.get(url, headers=self._headers(), params=params, timeout=self.timeout) response.raise_for_status() return response.json() if response.content else {} except requests.ConnectionError as e: @@ -86,17 +77,17 @@ class AgentClient: error_msg = f"Agent returned error: {e.response.status_code}" try: error_data = e.response.json() - if 'message' in error_data: - error_msg = error_data['message'] - elif 'error' in error_data: - error_msg = error_data['error'] + if "message" in error_data: + error_msg = error_data["message"] + elif "error" in error_data: + error_msg = error_data["error"] except Exception: pass raise AgentHTTPError(error_msg, status_code=e.response.status_code) except requests.RequestException as e: raise AgentHTTPError(f"Request failed: {e}") - def _post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict: + def _post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict: """ Perform POST request to agent. @@ -111,39 +102,38 @@ class AgentClient: AgentHTTPError: On HTTP errors AgentConnectionError: If agent is unreachable """ - url = f"{self.base_url}{path}" - request_timeout = self.timeout if timeout is None else timeout - try: - response = requests.post( - url, - json=data or {}, - headers=self._headers(), - timeout=request_timeout - ) - response.raise_for_status() - return response.json() if response.content else {} - except requests.ConnectionError as e: - raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}") - except requests.Timeout: - raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s") + url = f"{self.base_url}{path}" + request_timeout = self.timeout if timeout is None else timeout + try: + response = requests.post(url, json=data or {}, headers=self._headers(), timeout=request_timeout) + response.raise_for_status() + return response.json() if response.content else {} + except requests.ConnectionError as e: + raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}") + except requests.Timeout: + raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s") except requests.HTTPError as e: # Try to extract error message from response body error_msg = f"Agent returned error: {e.response.status_code}" try: error_data = e.response.json() - if 'message' in error_data: - error_msg = error_data['message'] - elif 'error' in error_data: - error_msg = error_data['error'] + if "message" in error_data: + error_msg = error_data["message"] + elif "error" in error_data: + error_msg = error_data["error"] except Exception: pass raise AgentHTTPError(error_msg, status_code=e.response.status_code) except requests.RequestException as e: raise AgentHTTPError(f"Request failed: {e}") - def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict: - """Public POST method for arbitrary endpoints.""" - return self._post(path, data, timeout=timeout) + def get(self, path: str, params: dict | None = None) -> dict: + """Public GET method for arbitrary endpoints.""" + return self._get(path, params) + + def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict: + """Public POST method for arbitrary endpoints.""" + return self._post(path, data, timeout=timeout) # ========================================================================= # Capability & Status @@ -156,7 +146,7 @@ class AgentClient: Returns: Dict with 'modes' (mode -> bool), 'devices' (list), 'agent_version' """ - return self._get('/capabilities') + return self._get("/capabilities") def get_status(self) -> dict: """ @@ -165,7 +155,7 @@ class AgentClient: Returns: Dict with 'running_modes', 'uptime', 'push_enabled', etc. """ - return self._get('/status') + return self._get("/status") def health_check(self) -> bool: """ @@ -175,14 +165,14 @@ class AgentClient: True if agent is reachable and healthy """ try: - result = self._get('/health') - return result.get('status') == 'healthy' + result = self._get("/health") + return result.get("status") == "healthy" except (AgentHTTPError, AgentConnectionError): return False def get_config(self) -> dict: """Get agent configuration (non-sensitive fields).""" - return self._get('/config') + return self._get("/config") def update_config(self, **kwargs) -> dict: """ @@ -195,7 +185,7 @@ class AgentClient: Returns: Updated config """ - return self._post('/config', kwargs) + return self._post("/config", kwargs) # ========================================================================= # Mode Operations @@ -212,9 +202,9 @@ class AgentClient: Returns: Start result with 'status' field """ - return self._post(f'/{mode}/start', params or {}) + return self._post(f"/{mode}/start", params or {}) - def stop_mode(self, mode: str, timeout: float = 8.0) -> dict: + def stop_mode(self, mode: str, timeout: float = 8.0) -> dict: """ Stop a running mode on the agent. @@ -224,7 +214,7 @@ class AgentClient: Returns: Stop result with 'status' field """ - return self._post(f'/{mode}/stop', timeout=timeout) + return self._post(f"/{mode}/stop", timeout=timeout) def get_mode_status(self, mode: str) -> dict: """ @@ -236,7 +226,7 @@ class AgentClient: Returns: Mode status with 'running' field """ - return self._get(f'/{mode}/status') + return self._get(f"/{mode}/status") def get_mode_data(self, mode: str) -> dict: """ @@ -248,7 +238,7 @@ class AgentClient: Returns: Data snapshot with 'data' field """ - return self._get(f'/{mode}/data') + return self._get(f"/{mode}/data") # ========================================================================= # Convenience Methods @@ -262,17 +252,17 @@ class AgentClient: Dict with capabilities, status, and config """ metadata = { - 'capabilities': None, - 'status': None, - 'config': None, - 'healthy': False, + "capabilities": None, + "status": None, + "config": None, + "healthy": False, } try: - metadata['capabilities'] = self.get_capabilities() - metadata['status'] = self.get_status() - metadata['config'] = self.get_config() - metadata['healthy'] = True + metadata["capabilities"] = self.get_capabilities() + metadata["status"] = self.get_status() + metadata["config"] = self.get_config() + metadata["healthy"] = True except (AgentHTTPError, AgentConnectionError) as e: logger.warning(f"Failed to refresh agent metadata: {e}") @@ -292,8 +282,4 @@ def create_client_from_agent(agent: dict) -> AgentClient: Returns: Configured AgentClient """ - return AgentClient( - base_url=agent['base_url'], - api_key=agent.get('api_key'), - timeout=60.0 - ) + return AgentClient(base_url=agent["base_url"], api_key=agent.get("api_key"), timeout=60.0) diff --git a/utils/meshcore.py b/utils/meshcore.py index 857bb3f..8305456 100644 --- a/utils/meshcore.py +++ b/utils/meshcore.py @@ -35,21 +35,10 @@ def is_meshcore_available() -> bool: return HAS_MESHCORE -# Try to import ContactType for repeater detection -try: - from meshcore import ContactType as _ContactType - - _REPEATER_TYPE = getattr(_ContactType, "REPEATER", None) -except Exception: - _ContactType = None - _REPEATER_TYPE = None - - def _is_repeater_contact(contact_dict: dict) -> bool: """Return True if this contact is a repeater node.""" - if _REPEATER_TYPE is not None: - return contact_dict.get("type") == _REPEATER_TYPE - # Fallback: meshcore repeaters have type==2 by convention + # meshcore exports no ContactType enum (checked through 2.3.7); + # repeaters have type==2 by library convention return contact_dict.get("type") == 2 diff --git a/utils/weather_sat.py b/utils/weather_sat.py index ff3fbb2..a104f23 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -29,77 +29,78 @@ from typing import Callable from utils.logging import get_logger from utils.process import register_process, safe_terminate -logger = get_logger('intercept.weather_sat') +logger = get_logger("intercept.weather_sat") PROJECT_ROOT = Path(__file__).resolve().parent.parent ALLOWED_OFFLINE_INPUT_DIRS = ( - PROJECT_ROOT / 'data', - PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings', + PROJECT_ROOT / "data", + PROJECT_ROOT / "instance" / "ground_station" / "recordings", ) # Weather satellite definitions. # NOAA APT entries are retained as inactive compatibility metadata. WEATHER_SATELLITES = { - 'NOAA-15': { - 'name': 'NOAA 15', - 'frequency': 137.620, - 'mode': 'APT', - 'pipeline': 'noaa_apt', - 'tle_key': 'NOAA-15', - 'description': 'NOAA-15 APT (decommissioned Aug 2025)', - 'active': False, + "NOAA-15": { + "name": "NOAA 15", + "frequency": 137.620, + "mode": "APT", + "pipeline": "noaa_apt", + "tle_key": "NOAA-15", + "description": "NOAA-15 APT (decommissioned Aug 2025)", + "active": False, }, - 'NOAA-18': { - 'name': 'NOAA 18', - 'frequency': 137.9125, - 'mode': 'APT', - 'pipeline': 'noaa_apt', - 'tle_key': 'NOAA-18', - 'description': 'NOAA-18 APT (decommissioned Jun 2025)', - 'active': False, + "NOAA-18": { + "name": "NOAA 18", + "frequency": 137.9125, + "mode": "APT", + "pipeline": "noaa_apt", + "tle_key": "NOAA-18", + "description": "NOAA-18 APT (decommissioned Jun 2025)", + "active": False, }, - 'NOAA-19': { - 'name': 'NOAA 19', - 'frequency': 137.100, - 'mode': 'APT', - 'pipeline': 'noaa_apt', - 'tle_key': 'NOAA-19', - 'description': 'NOAA-19 APT (decommissioned Aug 2025)', - 'active': False, + "NOAA-19": { + "name": "NOAA 19", + "frequency": 137.100, + "mode": "APT", + "pipeline": "noaa_apt", + "tle_key": "NOAA-19", + "description": "NOAA-19 APT (decommissioned Aug 2025)", + "active": False, }, - 'METEOR-M2-3': { - 'name': 'Meteor-M2-3', - 'frequency': 137.900, - 'mode': 'LRPT', - 'pipeline': 'meteor_m2-x_lrpt', - 'tle_key': 'METEOR-M2-3', - 'description': 'Meteor-M2-3 LRPT (digital color imagery)', - 'active': True, + "METEOR-M2-3": { + "name": "Meteor-M2-3", + "frequency": 137.900, + "mode": "LRPT", + "pipeline": "meteor_m2-x_lrpt", + "tle_key": "METEOR-M2-3", + "description": "Meteor-M2-3 LRPT (digital color imagery)", + "active": True, }, - 'METEOR-M2-4': { - 'name': 'Meteor-M2-4', - 'frequency': 137.900, - 'mode': 'LRPT', - 'pipeline': 'meteor_m2-x_lrpt', - 'tle_key': 'METEOR-M2-4', - 'description': 'Meteor-M2-4 LRPT (digital color imagery)', - 'active': True, + "METEOR-M2-4": { + "name": "Meteor-M2-4", + "frequency": 137.900, + "mode": "LRPT", + "pipeline": "meteor_m2-x_lrpt", + "tle_key": "METEOR-M2-4", + "description": "Meteor-M2-4 LRPT (digital color imagery)", + "active": True, }, - 'METEOR-M2-4-80K': { - 'name': 'Meteor-M2-4 (80k)', - 'frequency': 137.900, - 'mode': 'LRPT', - 'pipeline': 'meteor_m2-x_lrpt_80k', - 'tle_key': 'METEOR-M2-4', - 'description': 'Meteor-M2-4 LRPT 80k baud (fallback symbol rate)', - 'active': True, + "METEOR-M2-4-80K": { + "name": "Meteor-M2-4 (80k)", + "frequency": 137.900, + "mode": "LRPT", + "pipeline": "meteor_m2-x_lrpt_80k", + "tle_key": "METEOR-M2-4", + "description": "Meteor-M2-4 LRPT 80k baud (fallback symbol rate)", + "active": True, }, } # Default sample rate for weather satellite reception try: from config import WEATHER_SAT_SAMPLE_RATE as _configured_rate + DEFAULT_SAMPLE_RATE = _configured_rate except ImportError: DEFAULT_SAMPLE_RATE = 2400000 # 2.4 MHz — minimum for Meteor LRPT @@ -108,6 +109,7 @@ except ImportError: @dataclass class WeatherSatImage: """Decoded weather satellite image.""" + filename: str path: Path satellite: str @@ -115,50 +117,51 @@ class WeatherSatImage: timestamp: datetime frequency: float size_bytes: int = 0 - product: str = '' # e.g. 'RGB', 'Thermal', 'Channel 1' + product: str = "" # e.g. 'RGB', 'Thermal', 'Channel 1' def to_dict(self) -> dict: return { - 'filename': self.filename, - 'satellite': self.satellite, - 'mode': self.mode, - 'timestamp': self.timestamp.isoformat(), - 'frequency': self.frequency, - 'size_bytes': self.size_bytes, - 'product': self.product, - 'url': f'/weather-sat/images/{self.filename}', + "filename": self.filename, + "satellite": self.satellite, + "mode": self.mode, + "timestamp": self.timestamp.isoformat(), + "frequency": self.frequency, + "size_bytes": self.size_bytes, + "product": self.product, + "url": f"/weather-sat/images/{self.filename}", } @dataclass class CaptureProgress: """Weather satellite capture/decode progress update.""" + status: str # 'idle', 'capturing', 'decoding', 'complete', 'error' - satellite: str = '' + satellite: str = "" frequency: float = 0.0 - mode: str = '' - message: str = '' + mode: str = "" + message: str = "" progress_percent: int = 0 elapsed_seconds: int = 0 image: WeatherSatImage | None = None - log_type: str = '' # 'info', 'debug', 'progress', 'error', 'signal', 'save', 'warning' - capture_phase: str = '' # 'tuning', 'listening', 'signal_detected', 'decoding', 'complete', 'error' + log_type: str = "" # 'info', 'debug', 'progress', 'error', 'signal', 'save', 'warning' + capture_phase: str = "" # 'tuning', 'listening', 'signal_detected', 'decoding', 'complete', 'error' def to_dict(self) -> dict: result = { - 'type': 'weather_sat_progress', - 'status': self.status, - 'satellite': self.satellite, - 'frequency': self.frequency, - 'mode': self.mode, - 'message': self.message, - 'progress': self.progress_percent, - 'elapsed_seconds': self.elapsed_seconds, - 'log_type': self.log_type, - 'capture_phase': self.capture_phase, + "type": "weather_sat_progress", + "status": self.status, + "satellite": self.satellite, + "frequency": self.frequency, + "mode": self.mode, + "message": self.message, + "progress": self.progress_percent, + "elapsed_seconds": self.elapsed_seconds, + "log_type": self.log_type, + "capture_phase": self.capture_phase, } if self.image: - result['image'] = self.image.to_dict() + result["image"] = self.image.to_dict() return result @@ -177,20 +180,20 @@ class WeatherSatDecoder: self._images_lock = threading.Lock() self._stop_event = threading.Event() self._callback: Callable[[CaptureProgress], None] | None = None - self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat') + self._output_dir = Path(output_dir) if output_dir else Path("data/weather_sat") self._images: list[WeatherSatImage] = [] self._reader_thread: threading.Thread | None = None self._watcher_thread: threading.Thread | None = None self._pty_master_fd: int | None = None - self._current_satellite: str = '' + self._current_satellite: str = "" self._current_frequency: float = 0.0 - self._current_mode: str = '' + self._current_mode: str = "" self._capture_start_time: float = 0 self._device_index: int = -1 self._capture_output_dir: Path | None = None self._on_complete_callback: Callable[[], None] | None = None - self._capture_phase: str = 'idle' - self._last_error_message: str = '' + self._capture_phase: str = "idle" + self._last_error_message: str = "" self._last_process_returncode: int | None = None # Ensure output directory exists @@ -216,6 +219,10 @@ class WeatherSatDecoder: def current_frequency(self) -> float: return self._current_frequency + @property + def current_mode(self) -> str: + return self._current_mode + @property def device_index(self) -> int: """Return current device index.""" @@ -223,13 +230,12 @@ class WeatherSatDecoder: def _detect_decoder(self) -> str | None: """Detect which weather satellite decoder is available.""" - if shutil.which('satdump'): + if shutil.which("satdump"): logger.info("SatDump decoder detected") - return 'satdump' + return "satdump" logger.warning( - "SatDump not found. Install SatDump for weather satellite decoding. " - "See: https://github.com/SatDump/SatDump" + "SatDump not found. Install SatDump for weather satellite decoding. See: https://github.com/SatDump/SatDump" ) return None @@ -273,21 +279,25 @@ class WeatherSatDecoder: if not self._decoder: logger.error("No weather satellite decoder available") - msg = 'SatDump not installed. Build from source or install via package manager.' - self._emit_progress(CaptureProgress( - status='error', - message=msg, - )) + msg = "SatDump not installed. Build from source or install via package manager." + self._emit_progress( + CaptureProgress( + status="error", + message=msg, + ) + ) return False, msg sat_info = WEATHER_SATELLITES.get(satellite) if not sat_info: logger.error(f"Unknown satellite: {satellite}") - msg = f'Unknown satellite: {satellite}' - self._emit_progress(CaptureProgress( - status='error', - message=msg, - )) + msg = f"Unknown satellite: {satellite}" + self._emit_progress( + CaptureProgress( + status="error", + message=msg, + ) + ) return False, msg input_path = Path(input_file) @@ -298,58 +308,67 @@ class WeatherSatDecoder: resolved = input_path.resolve() if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS): logger.warning(f"Path traversal blocked in start_from_file: {input_file}") - msg = 'Input file must be under INTERCEPT data or ground-station recordings' - self._emit_progress(CaptureProgress( - status='error', - message=msg, - )) + msg = "Input file must be under INTERCEPT data or ground-station recordings" + self._emit_progress( + CaptureProgress( + status="error", + message=msg, + ) + ) return False, msg except (OSError, ValueError): - msg = 'Invalid file path' - self._emit_progress(CaptureProgress( - status='error', - message=msg, - )) + msg = "Invalid file path" + self._emit_progress( + CaptureProgress( + status="error", + message=msg, + ) + ) return False, msg if not input_path.is_file(): logger.error(f"Input file not found: {input_file}") - msg = 'Input file not found' - self._emit_progress(CaptureProgress( - status='error', - message=msg, - )) + msg = "Input file not found" + self._emit_progress( + CaptureProgress( + status="error", + message=msg, + ) + ) return False, msg self._current_satellite = satellite - self._current_frequency = sat_info['frequency'] - self._current_mode = sat_info['mode'] + self._current_frequency = sat_info["frequency"] + self._current_mode = sat_info["mode"] self._device_index = -1 # Offline decode does not claim an SDR device self._capture_start_time = time.time() - self._capture_phase = 'decoding' - self._last_error_message = '' + self._capture_phase = "decoding" + self._last_error_message = "" self._last_process_returncode = None self._stop_event.clear() try: self._running = True self._start_satdump_offline( - sat_info, input_path, sample_rate, + sat_info, + input_path, + sample_rate, ) logger.info( - f"Weather satellite file decode started: {satellite} " - f"({sat_info['mode']}) from {input_file}" + f"Weather satellite file decode started: {satellite} ({sat_info['mode']}) from {input_file}" + ) + self._emit_progress( + CaptureProgress( + status="decoding", + satellite=satellite, + frequency=sat_info["frequency"], + mode=sat_info["mode"], + message=f"Decoding {sat_info['name']} from file ({sat_info['mode']})...", + log_type="info", + capture_phase="decoding", + ) ) - self._emit_progress(CaptureProgress( - status='decoding', - satellite=satellite, - frequency=sat_info['frequency'], - mode=sat_info['mode'], - message=f"Decoding {sat_info['name']} from file ({sat_info['mode']})...", - log_type='info', - capture_phase='decoding', - )) return True, None @@ -357,11 +376,13 @@ class WeatherSatDecoder: self._running = False error_msg = str(e) logger.error(f"Failed to start file decode: {e}") - self._emit_progress(CaptureProgress( - status='error', - satellite=satellite, - message=error_msg, - )) + self._emit_progress( + CaptureProgress( + status="error", + satellite=satellite, + message=error_msg, + ) + ) return False, error_msg def start( @@ -392,11 +413,13 @@ class WeatherSatDecoder: sat_info = WEATHER_SATELLITES.get(satellite) if not sat_info: logger.error(f"Unknown satellite: {satellite}") - msg = f'Unknown satellite: {satellite}' - self._emit_progress(CaptureProgress( - status='error', - message=msg, - )) + msg = f"Unknown satellite: {satellite}" + self._emit_progress( + CaptureProgress( + status="error", + message=msg, + ) + ) return False, msg # Resolve device ID BEFORE lock — this runs rtl_test which can @@ -410,41 +433,52 @@ class WeatherSatDecoder: if not self._decoder: logger.error("No weather satellite decoder available") - msg = 'SatDump not installed. Build from source or install via package manager.' - self._emit_progress(CaptureProgress( - status='error', - message=msg, - )) + msg = "SatDump not installed. Build from source or install via package manager." + self._emit_progress( + CaptureProgress( + status="error", + message=msg, + ) + ) return False, msg self._current_satellite = satellite - self._current_frequency = sat_info['frequency'] - self._current_mode = sat_info['mode'] + self._current_frequency = sat_info["frequency"] + self._current_mode = sat_info["mode"] self._device_index = device_index self._capture_start_time = time.time() - self._capture_phase = 'tuning' - self._last_error_message = '' + self._capture_phase = "tuning" + self._last_error_message = "" self._last_process_returncode = None self._stop_event.clear() try: self._running = True - self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t, source_id, - rtl_tcp_host=rtl_tcp_host, rtl_tcp_port=rtl_tcp_port) + self._start_satdump( + sat_info, + device_index, + gain, + sample_rate, + bias_t, + source_id, + rtl_tcp_host=rtl_tcp_host, + rtl_tcp_port=rtl_tcp_port, + ) logger.info( - f"Weather satellite capture started: {satellite} " - f"({sat_info['frequency']} MHz, {sat_info['mode']})" + f"Weather satellite capture started: {satellite} ({sat_info['frequency']} MHz, {sat_info['mode']})" + ) + self._emit_progress( + CaptureProgress( + status="capturing", + satellite=satellite, + frequency=sat_info["frequency"], + mode=sat_info["mode"], + message=f"Capturing {sat_info['name']} on {sat_info['frequency']} MHz ({sat_info['mode']})...", + log_type="info", + capture_phase=self._capture_phase, + ) ) - self._emit_progress(CaptureProgress( - status='capturing', - satellite=satellite, - frequency=sat_info['frequency'], - mode=sat_info['mode'], - message=f"Capturing {sat_info['name']} on {sat_info['frequency']} MHz ({sat_info['mode']})...", - log_type='info', - capture_phase=self._capture_phase, - )) return True, None @@ -452,11 +486,13 @@ class WeatherSatDecoder: self._running = False error_msg = str(e) logger.error(f"Failed to start weather satellite capture: {e}") - self._emit_progress(CaptureProgress( - status='error', - satellite=satellite, - message=error_msg, - )) + self._emit_progress( + CaptureProgress( + status="error", + satellite=satellite, + message=error_msg, + ) + ) return False, error_msg def _start_satdump( @@ -472,25 +508,32 @@ class WeatherSatDecoder: ) -> None: """Start SatDump live capture and decode.""" # Create timestamped output directory for this capture - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - sat_name = sat_info['tle_key'].replace(' ', '_') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + sat_name = sat_info["tle_key"].replace(" ", "_") self._capture_output_dir = self._output_dir / f"{sat_name}_{timestamp}" self._capture_output_dir.mkdir(parents=True, exist_ok=True) - freq_hz = int(sat_info['frequency'] * 1_000_000) + freq_hz = int(sat_info["frequency"] * 1_000_000) if rtl_tcp_host: # Remote SDR via rtl_tcp cmd = [ - 'satdump', 'live', - sat_info['pipeline'], + "satdump", + "live", + sat_info["pipeline"], str(self._capture_output_dir), - '--source', 'rtltcp', - '--ip_address', rtl_tcp_host, - '--port', str(rtl_tcp_port), - '--samplerate', str(sample_rate), - '--frequency', str(freq_hz), - '--gain', str(int(gain)), + "--source", + "rtltcp", + "--ip_address", + rtl_tcp_host, + "--port", + str(rtl_tcp_port), + "--samplerate", + str(sample_rate), + "--frequency", + str(freq_hz), + "--gain", + str(int(gain)), ] logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") else: @@ -500,23 +543,28 @@ class WeatherSatDecoder: source_id = self._resolve_device_id(device_index) cmd = [ - 'satdump', 'live', - sat_info['pipeline'], + "satdump", + "live", + sat_info["pipeline"], str(self._capture_output_dir), - '--source', 'rtlsdr', - '--samplerate', str(sample_rate), - '--frequency', str(freq_hz), - '--gain', str(int(gain)), + "--source", + "rtlsdr", + "--samplerate", + str(sample_rate), + "--frequency", + str(freq_hz), + "--gain", + str(int(gain)), ] # Only pass --source_id if we have a real serial number. # When _resolve_device_id returns None (no serial found), # omit the flag so SatDump uses the first available device. if source_id is not None: - cmd.extend(['--source_id', source_id]) + cmd.extend(["--source_id", source_id]) if bias_t: - cmd.append('--bias') + cmd.append("--bias") logger.info(f"Starting SatDump: {' '.join(cmd)}") @@ -561,28 +609,26 @@ class WeatherSatDecoder: if error_output: logger.error(f"SatDump output:\n{error_output}") error_msg = self._extract_error(error_output, process.returncode) - self._emit_progress(CaptureProgress( - status='error', - satellite=self._current_satellite, - frequency=self._current_frequency, - mode=self._current_mode, - message=error_msg, - log_type='error', - capture_phase='error', - )) + self._emit_progress( + CaptureProgress( + status="error", + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=error_msg, + log_type="error", + capture_phase="error", + ) + ) threading.Thread(target=_check_early_exit, daemon=True).start() # Start reader thread to monitor output - self._reader_thread = threading.Thread( - target=self._read_satdump_output, daemon=True - ) + self._reader_thread = threading.Thread(target=self._read_satdump_output, daemon=True) self._reader_thread.start() # Start image watcher thread - self._watcher_thread = threading.Thread( - target=self._watch_images, daemon=True - ) + self._watcher_thread = threading.Thread(target=self._watch_images, daemon=True) self._watcher_thread.start() def _start_satdump_offline( @@ -593,8 +639,8 @@ class WeatherSatDecoder: ) -> None: """Start SatDump offline decode from a recorded file.""" # Create timestamped output directory for this decode - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - sat_name = sat_info['tle_key'].replace(' ', '_') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + sat_name = sat_info["tle_key"].replace(" ", "_") self._capture_output_dir = self._output_dir / f"{sat_name}_{timestamp}" self._capture_output_dir.mkdir(parents=True, exist_ok=True) @@ -602,18 +648,19 @@ class WeatherSatDecoder: # WAV audio files (FM-demodulated) use 'audio_wav' level. # Raw IQ baseband files use 'baseband' level. suffix = input_file.suffix.lower() - if suffix in ('.wav', '.wave'): - input_level = 'audio_wav' + if suffix in (".wav", ".wave"): + input_level = "audio_wav" else: - input_level = 'baseband' + input_level = "baseband" cmd = [ - 'satdump', - sat_info['pipeline'], + "satdump", + sat_info["pipeline"], input_level, str(input_file), str(self._capture_output_dir), - '--samplerate', str(sample_rate), + "--samplerate", + str(sample_rate), ] logger.info(f"Starting SatDump offline: {' '.join(cmd)}") @@ -648,34 +695,30 @@ class WeatherSatDecoder: raise RuntimeError(error_msg) # Start reader thread to monitor output - self._reader_thread = threading.Thread( - target=self._read_satdump_output, daemon=True - ) + self._reader_thread = threading.Thread(target=self._read_satdump_output, daemon=True) self._reader_thread.start() # Start image watcher thread - self._watcher_thread = threading.Thread( - target=self._watch_images, daemon=True - ) + self._watcher_thread = threading.Thread(target=self._watch_images, daemon=True) self._watcher_thread.start() @staticmethod def _classify_log_type(line: str) -> str: """Classify a SatDump output line into a log type.""" lower = line.lower() - if '(e)' in lower or 'error' in lower or 'fail' in lower: - return 'error' - if 'progress' in lower and '%' in line: - return 'progress' - if 'saved' in lower or 'writing' in lower: - return 'save' - if 'detected' in lower or 'lock' in lower or 'sync' in lower: - return 'signal' - if '(w)' in lower: - return 'warning' - if '(d)' in lower: - return 'debug' - return 'info' + if "(e)" in lower or "error" in lower or "fail" in lower: + return "error" + if "progress" in lower and "%" in line: + return "progress" + if "saved" in lower or "writing" in lower: + return "save" + if "detected" in lower or "lock" in lower or "sync" in lower: + return "signal" + if "(w)" in lower: + return "warning" + if "(d)" in lower: + return "debug" + return "info" @staticmethod def _resolve_device_id(device_index: int) -> str | None: @@ -687,21 +730,23 @@ class WeatherSatDecoder: """ try: result = subprocess.run( - ['rtl_test', '-d', str(device_index), '-t'], - capture_output=True, text=True, timeout=5, + ["rtl_test", "-d", str(device_index), "-t"], + capture_output=True, + text=True, + timeout=5, ) # rtl_test outputs: "Found 2 device(s):" then # " 0: RTLSDRBlog, Blog V4, SN: 00004000" output = result.stdout + result.stderr for line in output.splitlines(): # Match SN: pattern - match = re.search(r'SN:\s*(\S+)', line) + match = re.search(r"SN:\s*(\S+)", line) if match: serial = match.group(1) logger.info(f"RTL-SDR device {device_index} serial: {serial}") return serial # Also match "Using device #N: ..." then "Serial number is " - match = re.search(r'Serial number is\s+(\S+)', line) + match = re.search(r"Serial number is\s+(\S+)", line) if match: serial = match.group(1) logger.info(f"RTL-SDR device {device_index} serial: {serial}") @@ -715,7 +760,7 @@ class WeatherSatDecoder: @staticmethod def _drain_pty_output(master_fd: int) -> str: """Read all available output from a PTY master fd.""" - output = b'' + output = b"" try: while True: r, _, _ = select.select([master_fd], [], [], 0.1) @@ -727,7 +772,7 @@ class WeatherSatDecoder: output += chunk except OSError: pass - return output.decode('utf-8', errors='replace') + return output.decode("utf-8", errors="replace") @staticmethod def _extract_error(output: str, returncode: int) -> str: @@ -735,7 +780,7 @@ class WeatherSatDecoder: if output: for line in output.strip().splitlines(): lower = line.lower() - if 'error' in lower or 'could not' in lower or 'cannot' in lower or 'failed' in lower: + if "error" in lower or "could not" in lower or "cannot" in lower or "failed" in lower: return line.strip() return f"SatDump exited immediately (code {returncode})" @@ -750,7 +795,7 @@ class WeatherSatDecoder: if master_fd is None: return - buf = b'' + buf = b"" while self._running: try: r, _, _ = select.select([master_fd], [], [], 1.0) @@ -764,10 +809,10 @@ class WeatherSatDecoder: break buf += chunk # Split on \r and \n - while b'\n' in buf or b'\r' in buf: + while b"\n" in buf or b"\r" in buf: # Find earliest delimiter - idx_n = buf.find(b'\n') - idx_r = buf.find(b'\r') + idx_n = buf.find(b"\n") + idx_r = buf.find(b"\r") if idx_n == -1: idx = idx_r elif idx_r == -1: @@ -775,19 +820,19 @@ class WeatherSatDecoder: else: idx = min(idx_n, idx_r) line = buf[:idx] - buf = buf[idx + 1:] + buf = buf[idx + 1 :] # Skip empty lines - text = line.decode('utf-8', errors='replace').strip() + text = line.decode("utf-8", errors="replace").strip() # Strip ANSI escape codes that terminals produce - text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text) + text = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", text) if text: yield text except OSError: break # Drain remaining buffer - text = buf.decode('utf-8', errors='replace').strip() + text = buf.decode("utf-8", errors="replace").strip() if text: - text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text) + text = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", text) if text: yield text @@ -815,61 +860,37 @@ class WeatherSatDecoder: # Track phase transitions lower = line.lower() - if log_type == 'signal': - self._capture_phase = 'signal_detected' - elif log_type == 'progress': - self._capture_phase = 'decoding' - elif self._capture_phase == 'tuning' and ( - 'freq' in lower or 'processing' in lower - or 'starting' in lower or 'source' in lower + if log_type == "signal": + self._capture_phase = "signal_detected" + elif log_type == "progress": + self._capture_phase = "decoding" + elif self._capture_phase == "tuning" and ( + "freq" in lower or "processing" in lower or "starting" in lower or "source" in lower ): - self._capture_phase = 'listening' + self._capture_phase = "listening" # Parse progress from SatDump output - if log_type == 'progress': - match = re.search(r'(\d+(?:\.\d+)?)\s*%', line) + if log_type == "progress": + match = re.search(r"(\d+(?:\.\d+)?)\s*%", line) pct = int(float(match.group(1))) if match else 0 - self._emit_progress(CaptureProgress( - status='decoding', - satellite=self._current_satellite, - frequency=self._current_frequency, - mode=self._current_mode, - message=line, - progress_percent=pct, - elapsed_seconds=elapsed, - log_type=log_type, - capture_phase=self._capture_phase, - )) + self._emit_progress( + CaptureProgress( + status="decoding", + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=line, + progress_percent=pct, + elapsed_seconds=elapsed, + log_type=log_type, + capture_phase=self._capture_phase, + ) + ) last_emit_time = now - elif log_type == 'save': - self._emit_progress(CaptureProgress( - status='decoding', - satellite=self._current_satellite, - frequency=self._current_frequency, - mode=self._current_mode, - message=line, - elapsed_seconds=elapsed, - log_type=log_type, - capture_phase=self._capture_phase, - )) - last_emit_time = now - elif log_type == 'error' or log_type == 'signal': - self._emit_progress(CaptureProgress( - status='capturing', - satellite=self._current_satellite, - frequency=self._current_frequency, - mode=self._current_mode, - message=line, - elapsed_seconds=elapsed, - log_type=log_type, - capture_phase=self._capture_phase, - )) - last_emit_time = now - else: - # Emit other lines, throttled to every 0.5 seconds - if now - last_emit_time >= 0.5: - self._emit_progress(CaptureProgress( - status='capturing', + elif log_type == "save": + self._emit_progress( + CaptureProgress( + status="decoding", satellite=self._current_satellite, frequency=self._current_frequency, mode=self._current_mode, @@ -877,7 +898,38 @@ class WeatherSatDecoder: elapsed_seconds=elapsed, log_type=log_type, capture_phase=self._capture_phase, - )) + ) + ) + last_emit_time = now + elif log_type == "error" or log_type == "signal": + self._emit_progress( + CaptureProgress( + status="capturing", + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=line, + elapsed_seconds=elapsed, + log_type=log_type, + capture_phase=self._capture_phase, + ) + ) + last_emit_time = now + else: + # Emit other lines, throttled to every 0.5 seconds + if now - last_emit_time >= 0.5: + self._emit_progress( + CaptureProgress( + status="capturing", + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=line, + elapsed_seconds=elapsed, + log_type=log_type, + capture_phase=self._capture_phase, + ) + ) last_emit_time = now except Exception as e: @@ -908,29 +960,33 @@ class WeatherSatDecoder: retcode = process.returncode if process else None self._last_process_returncode = retcode if retcode and retcode != 0: - self._capture_phase = 'error' - self._emit_progress(CaptureProgress( - status='error', - satellite=self._current_satellite, - frequency=self._current_frequency, - mode=self._current_mode, - message=f"SatDump crashed (exit code {retcode}). Check SatDump installation and SDR device.", - elapsed_seconds=elapsed, - log_type='error', - capture_phase='error', - )) + self._capture_phase = "error" + self._emit_progress( + CaptureProgress( + status="error", + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=f"SatDump crashed (exit code {retcode}). Check SatDump installation and SDR device.", + elapsed_seconds=elapsed, + log_type="error", + capture_phase="error", + ) + ) else: - self._capture_phase = 'complete' - self._emit_progress(CaptureProgress( - status='complete', - satellite=self._current_satellite, - frequency=self._current_frequency, - mode=self._current_mode, - message=f"Capture complete ({elapsed}s)", - elapsed_seconds=elapsed, - log_type='info', - capture_phase='complete', - )) + self._capture_phase = "complete" + self._emit_progress( + CaptureProgress( + status="complete", + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=f"Capture complete ({elapsed}s)", + elapsed_seconds=elapsed, + log_type="info", + capture_phase="complete", + ) + ) # Notify route layer to release SDR device if self._on_complete_callback: @@ -966,7 +1022,7 @@ class WeatherSatDecoder: try: # Recursively scan for image files - for ext in ('*.png', '*.jpg', '*.jpeg'): + for ext in ("*.png", "*.jpg", "*.jpeg"): for filepath in self._capture_output_dir.rglob(ext): file_key = str(filepath) if file_key in known_files: @@ -984,15 +1040,12 @@ class WeatherSatDecoder: product = self._parse_product_name(filepath) # Copy image to main output dir for serving - safe_sat = re.sub(r'[^A-Za-z0-9_-]+', '_', self._current_satellite).strip('_') or 'satellite' - safe_stem = re.sub(r'[^A-Za-z0-9_-]+', '_', filepath.stem).strip('_') or 'image' + safe_sat = re.sub(r"[^A-Za-z0-9_-]+", "_", self._current_satellite).strip("_") or "satellite" + safe_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", filepath.stem).strip("_") or "image" suffix = filepath.suffix.lower() - if suffix not in ('.png', '.jpg', '.jpeg'): - suffix = '.png' - serve_name = ( - f"{safe_sat}_{safe_stem}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" - f"{suffix}" - ) + if suffix not in (".png", ".jpg", ".jpeg"): + suffix = ".png" + serve_name = f"{safe_sat}_{safe_stem}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}{suffix}" serve_path = self._output_dir / serve_name try: shutil.copy2(filepath, serve_path) @@ -1017,14 +1070,16 @@ class WeatherSatDecoder: self._images.append(image) logger.info(f"New weather satellite image: {serve_name} ({product})") - self._emit_progress(CaptureProgress( - status='complete', - satellite=self._current_satellite, - frequency=self._current_frequency, - mode=self._current_mode, - message=f'Image decoded: {product}', - image=image, - )) + self._emit_progress( + CaptureProgress( + status="complete", + satellite=self._current_satellite, + frequency=self._current_frequency, + mode=self._current_mode, + message=f"Image decoded: {product}", + image=image, + ) + ) except Exception as e: logger.error(f"Error scanning for images: {e}") @@ -1035,29 +1090,29 @@ class WeatherSatDecoder: parts = filepath.parts # Common SatDump product names - if 'rgb' in name: - return 'RGB Composite' - if 'msa' in name or 'multispectral' in name: - return 'Multispectral Analysis' - if 'thermal' in name or 'temp' in name: - return 'Thermal' - if 'ndvi' in name: - return 'NDVI Vegetation' - if 'channel' in name or 'ch' in name: - match = re.search(r'(?:channel|ch)[\s_-]*(\d+)', name) + if "rgb" in name: + return "RGB Composite" + if "msa" in name or "multispectral" in name: + return "Multispectral Analysis" + if "thermal" in name or "temp" in name: + return "Thermal" + if "ndvi" in name: + return "NDVI Vegetation" + if "channel" in name or "ch" in name: + match = re.search(r"(?:channel|ch)[\s_-]*(\d+)", name) if match: - return f'Channel {match.group(1)}' - if 'avhrr' in name: - return 'AVHRR' - if 'msu' in name or 'mtvza' in name: - return 'MSU-MR' + return f"Channel {match.group(1)}" + if "avhrr" in name: + return "AVHRR" + if "msu" in name or "mtvza" in name: + return "MSU-MR" # Check parent directories for clues for part in parts: - if 'rgb' in part.lower(): - return 'RGB Composite' - if 'channel' in part.lower(): - return 'Channel Data' + if "rgb" in part.lower(): + return "RGB Composite" + if "channel" in part.lower(): + return "Channel Data" return filepath.stem @@ -1091,7 +1146,7 @@ class WeatherSatDecoder: """ known_filenames = {img.filename for img in self._images} - for ext in ('*.png', '*.jpg', '*.jpeg'): + for ext in ("*.png", "*.jpg", "*.jpeg"): for filepath in self._output_dir.glob(ext): if filepath.name in known_filenames: continue @@ -1104,7 +1159,7 @@ class WeatherSatDecoder: continue # Parse satellite name from filename - satellite = 'Unknown' + satellite = "Unknown" for sat_key in WEATHER_SATELLITES: if sat_key in filepath.name: satellite = sat_key @@ -1116,9 +1171,9 @@ class WeatherSatDecoder: filename=filepath.name, path=filepath, satellite=satellite, - mode=sat_info.get('mode', 'Unknown'), + mode=sat_info.get("mode", "Unknown"), timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), - frequency=sat_info.get('frequency', 0.0), + frequency=sat_info.get("frequency", 0.0), size_bytes=stat.st_size, product=self._parse_product_name(filepath), ) @@ -1141,7 +1196,7 @@ class WeatherSatDecoder: def delete_all_images(self) -> int: """Delete all decoded images.""" count = 0 - for ext in ('*.png', '*.jpg', '*.jpeg'): + for ext in ("*.png", "*.jpg", "*.jpeg"): for filepath in self._output_dir.glob(ext): try: filepath.unlink() @@ -1154,7 +1209,7 @@ class WeatherSatDecoder: def _emit_progress(self, progress: CaptureProgress) -> None: """Emit progress update to callback.""" - if progress.status == 'error' and progress.message: + if progress.status == "error" and progress.message: self._last_error_message = str(progress.message) if self._callback: try: @@ -1169,17 +1224,17 @@ class WeatherSatDecoder: elapsed = int(time.time() - self._capture_start_time) return { - 'available': self._decoder is not None, - 'decoder': self._decoder, - 'running': self._running, - 'satellite': self._current_satellite, - 'frequency': self._current_frequency, - 'mode': self._current_mode, - 'capture_phase': self._capture_phase, - 'elapsed_seconds': elapsed, - 'image_count': len(self._images), - 'last_error': self._last_error_message, - 'last_returncode': self._last_process_returncode, + "available": self._decoder is not None, + "decoder": self._decoder, + "running": self._running, + "satellite": self._current_satellite, + "frequency": self._current_frequency, + "mode": self._current_mode, + "capture_phase": self._capture_phase, + "elapsed_seconds": elapsed, + "image_count": len(self._images), + "last_error": self._last_error_message, + "last_returncode": self._last_process_returncode, }