diff --git a/.dockerignore b/.dockerignore index ea9fd0d..e92261e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -35,6 +35,7 @@ htmlcov/ # Local Postgres data pgdata/ +pgdata.bak/ # Captured files (don't include in image) *.cap diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e2c1ff --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Uncomment and set to use external storage for ADS-B history +# PGDATA_PATH=/mnt/external/intercept/pgdata diff --git a/.gitignore b/.gitignore index e7b6292..18ae397 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,8 @@ intercept_agent_*.cfg # Temporary files /tmp/ *.tmp + +# Env files +.env +.env.* +!.env.example diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e551e..ddf0e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to iNTERCEPT will be documented in this file. +## [2.14.0] - 2026-02-06 + +### Added +- **DMR Digital Voice Decoder** - Decode DMR, P25, NXDN, and D-STAR protocols + - Integration with dsd-fme (Digital Speech Decoder - Florida Man Edition) + - Real-time SSE streaming of sync, call, voice, and slot events + - Call history table with talkgroup, source ID, and protocol tracking + - Protocol auto-detection or manual selection + - Pipeline error diagnostics with rtl_fm stderr capture +- **DMR Visual Synthesizer** - Canvas-based signal activity visualization + - Spring-physics animated bars reacting to SSE decoder events + - Color-coded by event type: cyan (sync), green (call), orange (voice) + - Center-outward ripple bursts on sync events + - Smooth decay and idle breathing animation + - Responsive canvas with window resize handling +- **HF SSTV General Mode** - Terrestrial slow-scan TV on shortwave frequencies + - Predefined HF SSTV frequencies (14.230, 21.340, 28.680 MHz, etc.) + - Modulation support for USB/LSB reception +- **WebSDR Integration** - Remote HF/shortwave listening via WebSDR servers +- **Listening Post Enhancements** - Improved signal scanner and audio handling + +### Fixed +- APRS rtl_fm startup failure and SDR device conflicts +- DSD voice decoder detection for dsd-fme and PulseAudio errors +- dsd-fme protocol flags and ncurses disable for headless operation +- dsd-fme audio output flag for pipeline compatibility +- TSCM sweep scan resilience with per-device error isolation +- TSCM WiFi detection using scanner singleton for device availability +- TSCM correlation and cluster emission fixes +- Detected Threats panel items now clickable to show device details +- Proximity radar tooltip flicker on hover +- Radar blip flicker by deferring renders during hover +- ISS position API priority swap to avoid timeout delays +- Updater settings panel error when updater.js is blocked +- Missing scapy in optionals dependency group + +--- + ## [2.13.1] - 2026-02-04 ### Added diff --git a/Dockerfile b/Dockerfile index 18817d2..b4ceabc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ soapysdr-module-rtlsdr \ soapysdr-module-hackrf \ soapysdr-module-lms7 \ + soapysdr-module-airspy \ + airspy \ limesuite \ hackrf \ # Utilities @@ -81,6 +83,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libcurl4-openssl-dev \ zlib1g-dev \ libzmq3-dev \ + libpulse-dev \ + libfftw3-dev \ + liblapack-dev \ + libcodec2-dev \ # Build dump1090 && cd /tmp \ && git clone --depth 1 https://github.com/flightaware/dump1090.git \ @@ -163,6 +169,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && go install github.com/bemasher/rtlamr@latest \ && cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \ && rm -rf /usr/local/go /tmp/gopath \ + # Build mbelib (required by DSD) + && cd /tmp \ + && git clone https://github.com/lwvmobile/mbelib.git \ + && cd mbelib \ + && (git checkout ambe_tones || true) \ + && mkdir build && cd build \ + && cmake .. \ + && make -j$(nproc) \ + && make install \ + && ldconfig \ + && rm -rf /tmp/mbelib \ + # Build DSD-FME (Digital Speech Decoder for DMR/P25) + && cd /tmp \ + && git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \ + && cd dsd-fme \ + && mkdir build && cd build \ + && cmake .. \ + && make -j$(nproc) \ + && make install \ + && ldconfig \ + && rm -rf /tmp/dsd-fme \ # Cleanup build tools to reduce image size && apt-get remove -y \ build-essential \ @@ -185,6 +212,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libcurl4-openssl-dev \ zlib1g-dev \ libzmq3-dev \ + libpulse-dev \ + libfftw3-dev \ + liblapack-dev \ + libcodec2-dev \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index b7904b4..1ae0fe8 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,17 @@ Support the developer of this open-source project - **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar - **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring - **ACARS Messaging** - Aircraft datalink messages via acarsdec +- **DMR Digital Voice** - DMR/P25/NXDN/D-STAR decoding via dsd-fme with visual synthesizer - **Listening Post** - Frequency scanner with audio monitoring - **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump +- **WebSDR** - Remote HF/shortwave listening via WebSDR servers - **ISS SSTV** - Slow-scan TV image reception from the International Space Station +- **HF SSTV** - Terrestrial SSTV on shortwave frequencies - **Satellite Tracking** - Pass prediction using TLE data - **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional) - **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng - **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support) +- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection - **Meshtastic** - LoRa mesh network integration - **Spy Stations** - Number stations and diplomatic HF network database - **Remote Agents** - Distributed SIGINT with remote sensor nodes diff --git a/app.py b/app.py index ad54ea4..829c104 100644 --- a/app.py +++ b/app.py @@ -172,6 +172,12 @@ dsc_rtl_process = None dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dsc_lock = threading.Lock() +# DMR / Digital Voice +dmr_process = None +dmr_rtl_process = None +dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +dmr_lock = threading.Lock() + # TSCM (Technical Surveillance Countermeasures) tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_lock = threading.Lock() @@ -641,6 +647,7 @@ def health_check() -> Response: 'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), 'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False), 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), + 'dmr': dmr_process is not None and (dmr_process.poll() is None if dmr_process else False), }, 'data': { 'aircraft_count': len(adsb_aircraft), @@ -658,6 +665,7 @@ def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process + global dmr_process, dmr_rtl_process # Import adsb and ais modules to reset their state from routes import adsb as adsb_module @@ -669,7 +677,8 @@ def kill_all() -> Response: 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', - 'hcitool', 'bluetoothctl', 'satdump' + 'hcitool', 'bluetoothctl', 'satdump', 'dsd', + 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', ] for proc in processes_to_kill: @@ -713,6 +722,11 @@ def kill_all() -> Response: dsc_process = None dsc_rtl_process = None + # Reset DMR state + with dmr_lock: + dmr_process = None + dmr_rtl_process = None + # Reset Bluetooth state (legacy) with bt_lock: if bt_process: @@ -853,6 +867,14 @@ def main() -> None: except ImportError as e: print(f"WebSocket audio disabled (install flask-sock): {e}") + # Initialize KiwiSDR WebSocket audio proxy + try: + from routes.websdr import init_websdr_audio + init_websdr_audio(app) + print("KiwiSDR audio proxy enabled") + except ImportError as e: + print(f"KiwiSDR audio proxy disabled: {e}") + print(f"Open http://localhost:{args.port} in your browser") print() print("Press Ctrl+C to stop") diff --git a/config.py b/config.py index b468f07..2bf9427 100644 --- a/config.py +++ b/config.py @@ -7,10 +7,23 @@ import os import sys # Application version -VERSION = "2.13.1" +VERSION = "2.14.0" # Changelog - latest release notes (shown on welcome screen) CHANGELOG = [ + { + "version": "2.14.0", + "date": "February 2026", + "highlights": [ + "DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme", + "DMR visual synthesizer with event-driven spring-physics bars", + "HF SSTV general mode with predefined shortwave frequencies", + "WebSDR integration for remote HF/shortwave listening", + "Listening Post signal scanner and audio pipeline improvements", + "TSCM sweep resilience, WiFi detection, and correlation fixes", + "APRS rtl_fm startup and SDR device conflict fixes", + ] + }, { "version": "2.13.1", "date": "February 2026", @@ -206,6 +219,11 @@ GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept') UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True) UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6) +# Alerting +ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '') +ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '') +ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5) + # Admin credentials ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin') ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin') diff --git a/docker-compose.yml b/docker-compose.yml index b8e524c..19b998d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,7 +112,8 @@ services: - POSTGRES_USER=intercept - POSTGRES_PASSWORD=intercept volumes: - - ./pgdata:/var/lib/postgresql/data + # Default local path (override with PGDATA_PATH for external storage) + - ${PGDATA_PATH:-./pgdata}:/var/lib/postgresql/data restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"] diff --git a/intercept_agent.py b/intercept_agent.py index 1cc91b1..ac635a3 100644 --- a/intercept_agent.py +++ b/intercept_agent.py @@ -838,14 +838,15 @@ class ModeManager: 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()), - 'bt_devices': list(self.bluetooth_devices.values()), - 'rf_signals': getattr(self, 'tscm_rf_signals', []), - } + 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', []), @@ -1104,23 +1105,24 @@ class ModeManager: self.wifi_clients.clear() elif mode == 'bluetooth': self.bluetooth_devices.clear() - elif mode == 'tscm': - # Clean up TSCM sub-threads - 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(): - thread.join(timeout=2) - del self.output_threads[sub_thread_name] - # Clear TSCM data - self.tscm_anomalies = [] - self.tscm_baseline = {} - self.tscm_rf_signals = [] - # Clear reported threat tracking sets - if hasattr(self, '_tscm_reported_wifi'): - self._tscm_reported_wifi.clear() - if hasattr(self, '_tscm_reported_bt'): - self._tscm_reported_bt.clear() + elif mode == 'tscm': + # Clean up TSCM sub-threads + 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(): + thread.join(timeout=2) + del self.output_threads[sub_thread_name] + # Clear TSCM data + self.tscm_anomalies = [] + self.tscm_baseline = {} + self.tscm_rf_signals = [] + self.tscm_wifi_clients = {} + # Clear reported threat tracking sets + if hasattr(self, '_tscm_reported_wifi'): + self._tscm_reported_wifi.clear() + if hasattr(self, '_tscm_reported_bt'): + self._tscm_reported_bt.clear() elif mode == 'dsc': # Clear DSC data if hasattr(self, 'dsc_messages'): @@ -1540,9 +1542,10 @@ class ModeManager: def _start_wifi(self, params: dict) -> dict: """Start WiFi scanning using Intercept's UnifiedWiFiScanner.""" interface = params.get('interface') - channel = params.get('channel') - band = params.get('band', 'abg') - scan_type = params.get('scan_type', 'deep') + 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': @@ -1571,8 +1574,21 @@ class ModeManager: else: scan_band = 'all' - # Start deep scan - if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel): + channel_list = None + if channels: + if isinstance(channels, str): + channel_list = [c.strip() for c in channels.split(',') if c.strip()] + elif isinstance(channels, (list, tuple, set)): + channel_list = list(channels) + else: + channel_list = [channels] + try: + channel_list = [int(c) for c in channel_list] + except (TypeError, ValueError): + 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, @@ -1591,12 +1607,12 @@ class ModeManager: else: 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) - except Exception as e: - logger.error(f"WiFi scanner error: {e}") - return {'status': 'error', 'message': str(e)} + 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)} def _wifi_data_sync(self, scanner): """Sync WiFi scanner data to agent's data structures.""" @@ -1630,8 +1646,14 @@ class ModeManager: if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance: self._wifi_scanner_instance.stop_deep_scan() - def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict: - """Fallback WiFi deep scan using airodump-ng directly.""" + def _start_wifi_fallback( + self, + interface: str | None, + channel: int | None, + band: str, + channels: list[int] | str | None = None, + ) -> dict: + """Fallback WiFi deep scan using airodump-ng directly.""" if not interface: return {'status': 'error', 'message': 'WiFi interface required'} @@ -1658,8 +1680,23 @@ class ModeManager: cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band] if gps_manager.is_running: cmd.append('--gpsd') - if channel: - cmd.extend(['-c', str(channel)]) + channel_list = None + if channels: + if isinstance(channels, str): + channel_list = [c.strip() for c in channels.split(',') if c.strip()] + elif isinstance(channels, (list, tuple, set)): + channel_list = list(channels) + else: + channel_list = [channels] + try: + channel_list = [int(c) for c in channel_list] + except (TypeError, ValueError): + return {'status': 'error', 'message': 'Invalid channels'} + + if channel_list: + cmd.extend(['-c', ','.join(str(c) for c in channel_list)]) + elif channel: + cmd.extend(['-c', str(channel)]) cmd.append(interface) try: @@ -3111,9 +3148,12 @@ class ModeManager: self.tscm_baseline = {} if not hasattr(self, 'tscm_anomalies'): self.tscm_anomalies = [] - if not hasattr(self, 'tscm_rf_signals'): - self.tscm_rf_signals = [] - self.tscm_anomalies.clear() + if not hasattr(self, 'tscm_rf_signals'): + self.tscm_rf_signals = [] + 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) @@ -3168,7 +3208,7 @@ class ModeManager: stop_event = self.stop_events.get(mode) # Import existing Intercept TSCM functions - from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals + from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals logger.info("TSCM imports successful") sweep_ranges = None @@ -3202,8 +3242,9 @@ class ModeManager: self._tscm_correlation = None # Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts) - seen_wifi = {} - seen_bt = {} + seen_wifi = {} + seen_wifi_clients = {} + seen_bt = {} last_rf_scan = 0 rf_scan_interval = 30 @@ -3261,10 +3302,51 @@ class ModeManager: for i in profile.indicators ] enriched['recommended_action'] = profile.recommended_action - - self.wifi_networks[bssid] = enriched - except Exception as e: - logger.debug(f"WiFi scan error: {e}") + + self.wifi_networks[bssid] = enriched + + # WiFi clients (monitor mode only) + try: + wifi_clients = _scan_wifi_clients(wifi_interface or '') + for client in wifi_clients: + 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') + if rssi_val is None: + 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, + } + + 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['recommended_action'] = profile.recommended_action + + self.tscm_wifi_clients[mac] = client_device + except Exception as e: + logger.debug(f"WiFi client scan error: {e}") + except Exception as e: + logger.debug(f"WiFi scan error: {e}") # Bluetooth scan using Intercept's function (same as local mode) if scan_bt: diff --git a/pyproject.toml b/pyproject.toml index 4aebb82..1040a65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "intercept" -version = "2.13.1" +version = "2.14.0" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" readme = "README.md" requires-python = ">=3.9" @@ -33,6 +33,7 @@ dependencies = [ "flask-limiter>=2.5.4", "bleak>=0.21.0", "flask-sock", + "websocket-client>=1.6.0", "requests>=2.28.0", ] @@ -56,6 +57,7 @@ optionals = [ "scipy>=1.10.0", "qrcode[pil]>=7.4", "numpy>=1.24.0", + "Pillow>=9.0.0", "meshtastic>=2.0.0", "psycopg2-binary>=2.9.9", "scapy>=2.4.5", diff --git a/requirements.txt b/requirements.txt index a6d0adf..911afde 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,10 +13,13 @@ bleak>=0.21.0 # Satellite tracking (optional - only needed for satellite features) skyfield>=1.45 -# DSC decoding (optional - only needed for VHF DSC maritime distress) +# DSC decoding and SSTV decoding (DSP pipeline) scipy>=1.10.0 numpy>=1.24.0 +# SSTV image output (optional - needed for SSTV image decoding) +Pillow>=9.0.0 + # GPS dongle support (optional - only needed for USB GPS receivers) pyserial>=3.5 @@ -35,4 +38,6 @@ qrcode[pil]>=7.4 # ruff>=0.1.0 # black>=23.0.0 # mypy>=1.0.0 +# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post) flask-sock +websocket-client>=1.6.0 diff --git a/routes/__init__.py b/routes/__init__.py index b35ffbb..0cb7e1a 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -27,6 +27,11 @@ def register_blueprints(app): from .updater import updater_bp from .sstv import sstv_bp from .weather_sat import weather_sat_bp + from .sstv_general import sstv_general_bp + from .dmr import dmr_bp + from .websdr import websdr_bp + from .alerts import alerts_bp + from .recordings import recordings_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -53,6 +58,11 @@ def register_blueprints(app): app.register_blueprint(updater_bp) # GitHub update checking app.register_blueprint(sstv_bp) # ISS SSTV decoder app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder + app.register_blueprint(sstv_general_bp) # General terrestrial SSTV + app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice + app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR + app.register_blueprint(alerts_bp) # Cross-mode alerts + app.register_blueprint(recordings_bp) # Session recordings # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/acars.py b/routes/acars.py index 160ecd9..47f572a 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -20,13 +20,15 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.logging import sensor_logger as logger from utils.validation import validate_device_index, validate_gain, validate_ppm -from utils.sse import format_sse +from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.constants import ( PROCESS_TERMINATE_TIMEOUT, SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT, PROCESS_START_WAIT, ) +from utils.process import register_process, unregister_process acars_bp = Blueprint('acars', __name__, url_prefix='/acars') @@ -144,9 +146,24 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - logger.error(f"ACARS stream error: {e}") app_module.acars_queue.put({'type': 'error', 'message': str(e)}) finally: + global acars_active_device + # Ensure process is terminated + try: + process.terminate() + process.wait(timeout=2) + except Exception: + try: + process.kill() + except Exception: + pass + unregister_process(process) app_module.acars_queue.put({'type': 'status', 'status': 'stopped'}) with app_module.acars_lock: app_module.acars_process = None + # Release SDR device + if acars_active_device is not None: + app_module.release_sdr_device(acars_active_device) + acars_active_device = None @acars_bp.route('/tools') @@ -311,6 +328,7 @@ def start_acars() -> Response: return jsonify({'status': 'error', 'message': error_msg}), 500 app_module.acars_process = process + register_process(process) # Start output streaming thread thread = threading.Thread( @@ -374,9 +392,13 @@ def stream_acars() -> Response: while True: try: - msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - yield format_sse(msg) + msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + try: + process_event('acars', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: diff --git a/routes/adsb.py b/routes/adsb.py index 2f34e1d..0239c4a 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -43,6 +43,7 @@ from utils.validation import ( validate_rtl_tcp_host, validate_rtl_tcp_port ) from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.sdr import SDRFactory, SDRType from utils.constants import ( ADSB_SBS_PORT, @@ -843,6 +844,10 @@ def stream_adsb(): try: msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT) last_keepalive = time.time() + try: + process_event('adsb', msg, msg.get('type')) + except Exception: + pass yield format_sse(msg) except queue.Empty: now = time.time() diff --git a/routes/ais.py b/routes/ais.py index 68f6140..b481fd8 100644 --- a/routes/ais.py +++ b/routes/ais.py @@ -19,6 +19,7 @@ from config import SHARED_OBSERVER_LOCATION_ENABLED from utils.logging import get_logger from utils.validation import validate_device_index, validate_gain from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.sdr import SDRFactory, SDRType from utils.constants import ( AIS_TCP_PORT, @@ -484,6 +485,10 @@ def stream_ais(): try: msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT) last_keepalive = time.time() + try: + process_event('ais', msg, msg.get('type')) + except Exception: + pass yield format_sse(msg) except queue.Empty: now = time.time() diff --git a/routes/alerts.py b/routes/alerts.py new file mode 100644 index 0000000..578d1bc --- /dev/null +++ b/routes/alerts.py @@ -0,0 +1,76 @@ +"""Alerting API endpoints.""" + +from __future__ import annotations + +import queue +import time +from typing import Generator + +from flask import Blueprint, Response, jsonify, request + +from utils.alerts import get_alert_manager +from utils.sse import format_sse + +alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts') + + +@alerts_bp.route('/rules', methods=['GET']) +def list_rules(): + manager = get_alert_manager() + include_disabled = request.args.get('all') in ('1', 'true', 'yes') + return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)}) + + +@alerts_bp.route('/rules', methods=['POST']) +def create_rule(): + data = request.get_json() or {} + if not isinstance(data.get('match', {}), dict): + return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400 + + manager = get_alert_manager() + rule_id = manager.add_rule(data) + return jsonify({'status': 'success', 'rule_id': rule_id}) + + +@alerts_bp.route('/rules/', methods=['PUT', 'PATCH']) +def update_rule(rule_id: int): + data = request.get_json() or {} + manager = get_alert_manager() + ok = manager.update_rule(rule_id, data) + if not ok: + return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404 + return jsonify({'status': 'success'}) + + +@alerts_bp.route('/rules/', methods=['DELETE']) +def delete_rule(rule_id: int): + manager = get_alert_manager() + ok = manager.delete_rule(rule_id) + if not ok: + return jsonify({'status': 'error', 'message': 'Rule not found'}), 404 + return jsonify({'status': 'success'}) + + +@alerts_bp.route('/events', methods=['GET']) +def list_events(): + manager = get_alert_manager() + limit = request.args.get('limit', default=100, type=int) + mode = request.args.get('mode') + severity = request.args.get('severity') + events = manager.list_events(limit=limit, mode=mode, severity=severity) + return jsonify({'status': 'success', 'events': events}) + + +@alerts_bp.route('/stream', methods=['GET']) +def stream_alerts() -> Response: + manager = get_alert_manager() + + def generate() -> Generator[str, None, None]: + for event in manager.stream_events(timeout=1.0): + yield format_sse(event) + + 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 diff --git a/routes/aprs.py b/routes/aprs.py index 41bb886..fbbced7 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -13,7 +13,7 @@ import tempfile import threading import time from datetime import datetime -from subprocess import DEVNULL, PIPE, STDOUT +from subprocess import PIPE, STDOUT from typing import Generator, Optional from flask import Blueprint, jsonify, request, Response @@ -21,7 +21,8 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.logging import sensor_logger as logger from utils.validation import validate_device_index, validate_gain, validate_ppm -from utils.sse import format_sse +from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.constants import ( PROCESS_TERMINATE_TIMEOUT, SSE_KEEPALIVE_INTERVAL, @@ -31,6 +32,9 @@ from utils.constants import ( aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') +# Track which SDR device is being used +aprs_active_device: int | None = None + # APRS frequencies by region (MHz) APRS_FREQUENCIES = { 'north_america': '144.390', @@ -1301,7 +1305,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces This function reads from the decoder's stdout (text mode, line-buffered). The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. - rtl_fm's stderr is sent to DEVNULL for the same reason. + rtl_fm's stderr is captured via PIPE with a monitor thread. Outputs two types of messages to the queue: - type='aprs': Decoded APRS packets @@ -1383,6 +1387,7 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces logger.error(f"APRS stream error: {e}") app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) finally: + global aprs_active_device app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) # Cleanup processes for proc in [rtl_process, decoder_process]: @@ -1394,6 +1399,10 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces proc.kill() except Exception: pass + # Release SDR device + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None @aprs_bp.route('/tools') @@ -1441,6 +1450,7 @@ def get_stations() -> Response: def start_aprs() -> Response: """Start APRS decoder.""" global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations + global aprs_active_device with app_module.aprs_lock: if app_module.aprs_process and app_module.aprs_process.poll() is None: @@ -1477,6 +1487,16 @@ def start_aprs() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Reserve SDR device to prevent conflicts with other modes + error = app_module.claim_sdr_device(device, 'aprs') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + aprs_active_device = device + # Get frequency for region region = data.get('region', 'north_america') frequency = APRS_FREQUENCIES.get(region, '144.390') @@ -1552,15 +1572,25 @@ def start_aprs() -> Response: try: # Start rtl_fm with stdout piped to decoder. - # stderr goes to DEVNULL to prevent blocking (rtl_fm logs to stderr). + # stderr is captured via PIPE so errors are reported to the user. # NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal. rtl_process = subprocess.Popen( rtl_cmd, stdout=PIPE, - stderr=DEVNULL, + stderr=PIPE, start_new_session=True ) + # Start a thread to monitor rtl_fm stderr for errors + def monitor_rtl_stderr(): + for line in rtl_process.stderr: + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + logger.debug(f"[RTL_FM] {err_text}") + + rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True) + rtl_stderr_thread.start() + # Start decoder with stdin wired to rtl_fm's stdout. # Use text mode with line buffering for reliable line-by-line reading. # Merge stderr into stdout to avoid blocking on unbuffered stderr. @@ -1582,13 +1612,25 @@ def start_aprs() -> Response: time.sleep(PROCESS_START_WAIT) if rtl_process.poll() is not None: - # rtl_fm exited early - something went wrong + # rtl_fm exited early - capture stderr for diagnostics + stderr_output = '' + try: + remaining = rtl_process.stderr.read() + if remaining: + stderr_output = remaining.decode('utf-8', errors='replace').strip() + except Exception: + pass error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})' + if stderr_output: + error_msg += f': {stderr_output[:200]}' logger.error(error_msg) try: decoder_process.kill() except Exception: pass + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None return jsonify({'status': 'error', 'message': error_msg}), 500 if decoder_process.poll() is not None: @@ -1602,6 +1644,9 @@ def start_aprs() -> Response: rtl_process.kill() except Exception: pass + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None return jsonify({'status': 'error', 'message': error_msg}), 500 # Store references for status checks and cleanup @@ -1626,12 +1671,17 @@ def start_aprs() -> Response: except Exception as e: logger.error(f"Failed to start APRS decoder: {e}") + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None return jsonify({'status': 'error', 'message': str(e)}), 500 @aprs_bp.route('/stop', methods=['POST']) def stop_aprs() -> Response: """Stop APRS decoder.""" + global aprs_active_device + with app_module.aprs_lock: processes_to_stop = [] @@ -1660,6 +1710,11 @@ def stop_aprs() -> Response: if hasattr(app_module, 'aprs_rtl_process'): app_module.aprs_rtl_process = None + # Release SDR device + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None + return jsonify({'status': 'stopped'}) @@ -1671,9 +1726,13 @@ def stream_aprs() -> Response: while True: try: - msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - yield format_sse(msg) + msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + try: + process_event('aprs', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: diff --git a/routes/audio_websocket.py b/routes/audio_websocket.py index 17abaa2..6d70d0b 100644 --- a/routes/audio_websocket.py +++ b/routes/audio_websocket.py @@ -66,12 +66,6 @@ def kill_audio_processes(): pass rtl_process = None - # Kill any orphaned processes - try: - subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1) - except: - pass - time.sleep(0.3) @@ -228,13 +222,13 @@ def init_audio_websocket(app: Flask): except TimeoutError: pass - except Exception as e: - msg = str(e).lower() - if "connection closed" in msg: - logger.info("WebSocket closed by client") - break - if "timed out" not in msg: - logger.error(f"WebSocket receive error: {e}") + except Exception as e: + msg = str(e).lower() + if "connection closed" in msg: + logger.info("WebSocket closed by client") + break + if "timed out" not in msg: + logger.error(f"WebSocket receive error: {e}") # Stream audio data if active if streaming and proc and proc.poll() is None: diff --git a/routes/bluetooth.py b/routes/bluetooth.py index d6fc1fe..f7f7d1f 100644 --- a/routes/bluetooth.py +++ b/routes/bluetooth.py @@ -18,10 +18,11 @@ from typing import Any, Generator from flask import Blueprint, jsonify, request, Response import app as app_module -from utils.dependencies import check_tool -from utils.logging import bluetooth_logger as logger -from utils.sse import format_sse -from utils.validation import validate_bluetooth_interface +from utils.dependencies import check_tool +from utils.logging import bluetooth_logger as logger +from utils.sse import format_sse +from utils.event_pipeline import process_event +from utils.validation import validate_bluetooth_interface from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER from utils.constants import ( @@ -561,9 +562,13 @@ def stream_bt(): while True: try: - msg = app_module.bt_queue.get(timeout=1) - last_keepalive = time.time() - yield format_sse(msg) + msg = app_module.bt_queue.get(timeout=1) + last_keepalive = time.time() + try: + process_event('bluetooth', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= keepalive_interval: diff --git a/routes/bluetooth_v2.py b/routes/bluetooth_v2.py index e75ec7f..24ace6e 100644 --- a/routes/bluetooth_v2.py +++ b/routes/bluetooth_v2.py @@ -7,32 +7,40 @@ aggregation, and heuristics. from __future__ import annotations -import csv -import io -import json -import logging +import csv +import io +import json +import logging +import threading +import time from datetime import datetime from typing import Generator from flask import Blueprint, Response, jsonify, request, session -from utils.bluetooth import ( - BluetoothScanner, - BTDeviceAggregate, - get_bluetooth_scanner, - check_capabilities, - RANGE_UNKNOWN, +from utils.bluetooth import ( + BluetoothScanner, + BTDeviceAggregate, + get_bluetooth_scanner, + check_capabilities, + RANGE_UNKNOWN, TrackerType, TrackerConfidence, get_tracker_engine, -) -from utils.database import get_db -from utils.sse import format_sse +) +from utils.database import get_db +from utils.sse import format_sse +from utils.event_pipeline import process_event logger = logging.getLogger('intercept.bluetooth_v2') # Blueprint -bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth') +bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth') + +# Seen-before tracking +_bt_seen_cache: set[str] = set() +_bt_session_seen: set[str] = set() +_bt_seen_lock = threading.Lock() # ============================================================================= # DATABASE FUNCTIONS @@ -164,13 +172,20 @@ def get_all_baselines() -> list[dict]: return [dict(row) for row in cursor] -def save_observation_history(device: BTDeviceAggregate) -> None: - """Save device observation to history.""" - with get_db() as conn: - conn.execute(''' - INSERT INTO bt_observation_history (device_id, rssi, seen_count) - VALUES (?, ?, ?) - ''', (device.device_id, device.rssi_current, device.seen_count)) +def save_observation_history(device: BTDeviceAggregate) -> None: + """Save device observation to history.""" + with get_db() as conn: + conn.execute(''' + INSERT INTO bt_observation_history (device_id, rssi, seen_count) + VALUES (?, ?, ?) + ''', (device.device_id, device.rssi_current, device.seen_count)) + + +def load_seen_device_ids() -> set[str]: + """Load distinct device IDs from history for seen-before tracking.""" + with get_db() as conn: + cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history') + return {row['device_id'] for row in cursor} # ============================================================================= @@ -191,7 +206,7 @@ def get_capabilities(): @bluetooth_v2_bp.route('/scan/start', methods=['POST']) -def start_scan(): +def start_scan(): """ Start Bluetooth scanning. @@ -221,17 +236,42 @@ def start_scan(): # Get scanner instance scanner = get_bluetooth_scanner(adapter_id) - # Check if already scanning - if scanner.is_scanning: - return jsonify({ - 'status': 'already_running', - 'scan_status': scanner.get_status().to_dict() - }) - - # Initialize database tables if needed - init_bt_tables() - - # Load active baseline if exists + # Initialize database tables if needed + init_bt_tables() + + def _handle_seen_before(device: BTDeviceAggregate) -> None: + try: + with _bt_seen_lock: + device.seen_before = device.device_id in _bt_seen_cache + if device.device_id not in _bt_session_seen: + save_observation_history(device) + _bt_session_seen.add(device.device_id) + except Exception as e: + logger.debug(f"BT seen-before update failed: {e}") + + # Setup seen-before callback + if scanner._on_device_updated is None: + scanner._on_device_updated = _handle_seen_before + + # Ensure cache is initialized + with _bt_seen_lock: + if not _bt_seen_cache: + _bt_seen_cache.update(load_seen_device_ids()) + + # Check if already scanning + if scanner.is_scanning: + return jsonify({ + 'status': 'already_running', + 'scan_status': scanner.get_status().to_dict() + }) + + # Refresh seen-before cache and reset session set for a new scan + with _bt_seen_lock: + _bt_seen_cache.clear() + _bt_seen_cache.update(load_seen_device_ids()) + _bt_session_seen.clear() + + # Load active baseline if exists baseline_id = get_active_baseline_id() if baseline_id: device_ids = get_baseline_device_ids(baseline_id) @@ -856,11 +896,15 @@ def stream_events(): else: return event_type, event - def event_generator() -> Generator[str, None, None]: - """Generate SSE events from scanner.""" - for event in scanner.stream_events(timeout=1.0): - event_name, event_data = map_event_type(event) - yield format_sse(event_data, event=event_name) + def event_generator() -> Generator[str, None, None]: + """Generate SSE events from scanner.""" + for event in scanner.stream_events(timeout=1.0): + event_name, event_data = map_event_type(event) + try: + process_event('bluetooth', event_data, event_name) + except Exception: + pass + yield format_sse(event_data, event=event_name) return Response( event_generator(), @@ -944,23 +988,34 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]: devices = scanner.get_devices() logger.info(f"TSCM snapshot: get_devices() returned {len(devices)} devices") - # Convert to TSCM format with tracker detection data - tscm_devices = [] - for device in devices: - device_data = { - 'mac': device.address, - 'address_type': device.address_type, - 'device_key': device.device_key, - 'name': device.name or 'Unknown', - 'rssi': device.rssi_current or -100, - 'rssi_median': device.rssi_median, - 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, - 'type': _classify_device_type(device), - 'manufacturer': device.manufacturer_name, - 'manufacturer_id': device.manufacturer_id, - 'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, - 'protocol': device.protocol, - 'first_seen': device.first_seen.isoformat(), + # Convert to TSCM format with tracker detection data + tscm_devices = [] + for device in devices: + manufacturer_name = device.manufacturer_name + if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'): + if device.address and not device.is_randomized_mac: + try: + from data.oui import get_manufacturer + oui_vendor = get_manufacturer(device.address) + if oui_vendor and oui_vendor != 'Unknown': + manufacturer_name = oui_vendor + except Exception: + pass + + device_data = { + 'mac': device.address, + 'address_type': device.address_type, + 'device_key': device.device_key, + 'name': device.name or 'Unknown', + 'rssi': device.rssi_current or -100, + 'rssi_median': device.rssi_median, + 'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None, + 'type': _classify_device_type(device), + 'manufacturer': manufacturer_name, + 'manufacturer_id': device.manufacturer_id, + 'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None, + 'protocol': device.protocol, + 'first_seen': device.first_seen.isoformat(), 'last_seen': device.last_seen.isoformat(), 'seen_count': device.seen_count, 'range_band': device.range_band, @@ -1174,14 +1229,38 @@ def get_device_timeseries(device_key: str): return jsonify(result) -def _classify_device_type(device: BTDeviceAggregate) -> str: - """Classify device type from available data.""" - name_lower = (device.name or '').lower() - manufacturer_lower = (device.manufacturer_name or '').lower() - - # Check by name patterns - if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']): - return 'audio' +def _classify_device_type(device: BTDeviceAggregate) -> str: + """Classify device type from available data.""" + name_lower = (device.name or '').lower() + manufacturer_lower = (device.manufacturer_name or '').lower() + service_uuids = device.service_uuids or [] + + if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'): + if device.address and not device.is_randomized_mac: + try: + from data.oui import get_manufacturer + oui_vendor = get_manufacturer(device.address) + if oui_vendor and oui_vendor != 'Unknown': + manufacturer_lower = oui_vendor.lower() + except Exception: + pass + + def normalize_uuid(uuid: str) -> str: + if not uuid: + return '' + value = str(uuid).lower().strip() + if value.startswith('0x'): + value = value[2:] + # Bluetooth Base UUID normalization (16-bit UUIDs) + if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8: + return value[4:8] + if len(value) == 4: + return value + return value + + # Check by name patterns + if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']): + return 'audio' if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']): return 'wearable' if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']): @@ -1190,18 +1269,41 @@ def _classify_device_type(device: BTDeviceAggregate) -> str: return 'computer' if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']): return 'peripheral' - if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']): - return 'tracker' - if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']): - return 'speaker' - if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']): - return 'media' - - # Check by manufacturer - if 'apple' in manufacturer_lower: - return 'apple_device' - if 'samsung' in manufacturer_lower: - return 'samsung_device' + if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']): + return 'tracker' + if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']): + return 'speaker' + if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']): + return 'media' + + # Tracker signals (metadata or Find My service) + if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None): + return 'tracker' + + normalized_uuids = {normalize_uuid(u) for u in service_uuids if u} + if 'fd6f' in normalized_uuids: + return 'tracker' + + # Service UUIDs (GATT / classic) + audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'} + wearable_uuids = {'180d', '1814', '1816'} + hid_uuids = {'1812'} + beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'} + + if normalized_uuids & audio_uuids: + return 'audio' + if normalized_uuids & hid_uuids: + return 'peripheral' + if normalized_uuids & wearable_uuids: + return 'wearable' + if normalized_uuids & beacon_uuids: + return 'beacon' + + # Check by manufacturer + if 'apple' in manufacturer_lower: + return 'apple_device' + if 'samsung' in manufacturer_lower: + return 'samsung_device' # Check by class of device if device.major_class: diff --git a/routes/dmr.py b/routes/dmr.py new file mode 100644 index 0000000..d993614 --- /dev/null +++ b/routes/dmr.py @@ -0,0 +1,513 @@ +"""DMR / P25 / Digital Voice decoding routes.""" + +from __future__ import annotations + +import os +import queue +import re +import select +import shutil +import subprocess +import threading +import time +from datetime import datetime +from typing import Generator, Optional + +from flask import Blueprint, jsonify, request, Response + +import app as app_module +from utils.logging import get_logger +from utils.sse import format_sse +from utils.event_pipeline import process_event +from utils.process import register_process, unregister_process +from utils.constants import ( + SSE_QUEUE_TIMEOUT, + SSE_KEEPALIVE_INTERVAL, + QUEUE_MAX_SIZE, +) + +logger = get_logger('intercept.dmr') + +dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr') + +# ============================================ +# GLOBAL STATE +# ============================================ + +dmr_rtl_process: Optional[subprocess.Popen] = None +dmr_dsd_process: Optional[subprocess.Popen] = None +dmr_thread: Optional[threading.Thread] = None +dmr_running = False +dmr_lock = threading.Lock() +dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +dmr_active_device: Optional[int] = None + +VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice'] + +# Classic dsd flags +_DSD_PROTOCOL_FLAGS = { + 'auto': [], + 'dmr': ['-fd'], + 'p25': ['-fp'], + 'nxdn': ['-fn'], + 'dstar': ['-fi'], + 'provoice': ['-fv'], +} + +# dsd-fme uses different flag names +_DSD_FME_PROTOCOL_FLAGS = { + 'auto': ['-ft'], + 'dmr': ['-fs'], + 'p25': ['-f1'], + 'nxdn': ['-fi'], + 'dstar': [], + 'provoice': ['-fp'], +} + +# ============================================ +# HELPERS +# ============================================ + + +def find_dsd() -> tuple[str | None, bool]: + """Find DSD (Digital Speech Decoder) binary. + + Checks for dsd-fme first (common fork), then falls back to dsd. + Returns (path, is_fme) tuple. + """ + path = shutil.which('dsd-fme') + if path: + return path, True + path = shutil.which('dsd') + if path: + return path, False + return None, False + + +def find_rtl_fm() -> str | None: + """Find rtl_fm binary.""" + return shutil.which('rtl_fm') + + +def parse_dsd_output(line: str) -> dict | None: + """Parse a line of DSD stderr output into a structured event. + + Handles output from both classic ``dsd`` and ``dsd-fme`` which use + different formatting for talkgroup / source / voice frame lines. + """ + line = line.strip() + if not line: + return None + + # Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.) + # These contain box-drawing characters or are pure decoration. + if re.search(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░]', line): + return None + if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line): + return None + + ts = datetime.now().strftime('%H:%M:%S') + + # Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1" + sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line) + if sync_match: + return { + 'type': 'sync', + 'protocol': sync_match.group(1).strip(), + 'timestamp': ts, + } + + # Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …" + # is captured as a call event rather than a bare slot event. + # Classic dsd: "TG: 12345 Src: 67890" + # dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890" + tg_match = re.search( + r'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE + ) + if tg_match: + result = { + 'type': 'call', + 'talkgroup': int(tg_match.group(1)), + 'source_id': int(tg_match.group(2)), + 'timestamp': ts, + } + # Extract slot if present on the same line + slot_inline = re.search(r'Slot\s*(\d+)', line) + if slot_inline: + result['slot'] = int(slot_inline.group(1)) + return result + + # P25 NAC (Network Access Code) — check before voice/slot + nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line) + if nac_match: + return { + 'type': 'nac', + 'nac': nac_match.group(1), + 'timestamp': ts, + } + + # Voice frame detection — check BEFORE bare slot match + # Classic dsd: "Voice" keyword in frame lines + # dsd-fme: "voice" or "Voice LC" or "VOICE" in output + if re.search(r'\bvoice\b', line, re.IGNORECASE): + result = { + 'type': 'voice', + 'detail': line, + 'timestamp': ts, + } + slot_inline = re.search(r'Slot\s*(\d+)', line) + if slot_inline: + result['slot'] = int(slot_inline.group(1)) + return result + + # Bare slot info (only when line is *just* slot info, not voice/call) + slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line) + if slot_match: + return { + 'type': 'slot', + 'slot': int(slot_match.group(1)), + 'timestamp': ts, + } + + # dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc. + # Also catches "Closing", "Input", and other lifecycle lines. + # Forward as raw so the frontend can show decoder is alive. + return { + 'type': 'raw', + 'text': line[:200], + 'timestamp': ts, + } + + +_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle + + +def _queue_put(event: dict): + """Put an event on the DMR queue, dropping oldest if full.""" + try: + dmr_queue.put_nowait(event) + except queue.Full: + try: + dmr_queue.get_nowait() + except queue.Empty: + pass + try: + dmr_queue.put_nowait(event) + except queue.Full: + pass + + +def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen): + """Read DSD stderr output and push parsed events to the queue. + + Uses select() with a timeout so we can send periodic heartbeat + events while readline() would otherwise block indefinitely during + silence (no signal being decoded). + """ + global dmr_running + + try: + _queue_put({'type': 'status', 'text': 'started'}) + last_heartbeat = time.time() + + while dmr_running: + if dsd_process.poll() is not None: + break + + # Wait up to 1s for data on stderr instead of blocking forever + ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0) + + if ready: + line = dsd_process.stderr.readline() + if not line: + if dsd_process.poll() is not None: + break + continue + + text = line.decode('utf-8', errors='replace').strip() + if not text: + continue + + parsed = parse_dsd_output(text) + if parsed: + _queue_put(parsed) + last_heartbeat = time.time() + else: + # No stderr output — send heartbeat so frontend knows + # decoder is still alive and listening + now = time.time() + if now - last_heartbeat >= _HEARTBEAT_INTERVAL: + _queue_put({ + 'type': 'heartbeat', + 'timestamp': datetime.now().strftime('%H:%M:%S'), + }) + last_heartbeat = now + + except Exception as e: + logger.error(f"DSD stream error: {e}") + finally: + global dmr_active_device, dmr_rtl_process, dmr_dsd_process + dmr_running = False + # Capture exit info for diagnostics + rc = dsd_process.poll() + reason = 'stopped' + detail = '' + if rc is not None and rc != 0: + reason = 'crashed' + try: + remaining = dsd_process.stderr.read(1024) + if remaining: + detail = remaining.decode('utf-8', errors='replace').strip()[:200] + except Exception: + pass + logger.warning(f"DSD process exited with code {rc}: {detail}") + # Cleanup both processes + for proc in [dsd_process, rtl_process]: + if proc and proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + if proc: + unregister_process(proc) + dmr_rtl_process = None + dmr_dsd_process = None + _queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail}) + # Release SDR device + if dmr_active_device is not None: + app_module.release_sdr_device(dmr_active_device) + dmr_active_device = None + logger.info("DSD stream thread stopped") + + +# ============================================ +# API ENDPOINTS +# ============================================ + +@dmr_bp.route('/tools') +def check_tools() -> Response: + """Check for required tools.""" + dsd_path, _ = find_dsd() + rtl_fm = find_rtl_fm() + return jsonify({ + 'dsd': dsd_path is not None, + 'rtl_fm': rtl_fm is not None, + 'available': dsd_path is not None and rtl_fm is not None, + 'protocols': VALID_PROTOCOLS, + }) + + +@dmr_bp.route('/start', methods=['POST']) +def start_dmr() -> Response: + """Start digital voice decoding.""" + global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device + + with dmr_lock: + if dmr_running: + return jsonify({'status': 'error', 'message': 'Already running'}), 409 + + dsd_path, is_fme = find_dsd() + if not dsd_path: + return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503 + + rtl_fm_path = find_rtl_fm() + if not rtl_fm_path: + return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503 + + data = request.json or {} + + try: + frequency = float(data.get('frequency', 462.5625)) + gain = int(data.get('gain', 40)) + device = int(data.get('device', 0)) + protocol = str(data.get('protocol', 'auto')).lower() + except (ValueError, TypeError) as e: + return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 + + if frequency <= 0: + return jsonify({'status': 'error', 'message': 'Frequency must be positive'}), 400 + + if protocol not in VALID_PROTOCOLS: + return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400 + + # Clear stale queue + try: + while True: + dmr_queue.get_nowait() + except queue.Empty: + pass + + # Claim SDR device + error = app_module.claim_sdr_device(device, 'dmr') + if error: + return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 + + dmr_active_device = device + + freq_hz = int(frequency * 1e6) + + # Build rtl_fm command (48kHz sample rate for DSD) + rtl_cmd = [ + rtl_fm_path, + '-M', 'fm', + '-f', str(freq_hz), + '-s', '48000', + '-g', str(gain), + '-d', str(device), + '-l', '1', # squelch level + ] + + # Build DSD command + # Use -o - to send decoded audio to stdout (piped to DEVNULL) + # instead of PulseAudio which may not be available under sudo + dsd_cmd = [dsd_path, '-i', '-', '-o', '-'] + if is_fme: + dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, [])) + else: + dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, [])) + + try: + dmr_rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + register_process(dmr_rtl_process) + + dmr_dsd_process = subprocess.Popen( + dsd_cmd, + stdin=dmr_rtl_process.stdout, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + register_process(dmr_dsd_process) + + # Allow rtl_fm to send directly to dsd + dmr_rtl_process.stdout.close() + + time.sleep(0.3) + + rtl_rc = dmr_rtl_process.poll() + dsd_rc = dmr_dsd_process.poll() + if rtl_rc is not None or dsd_rc is not None: + # Process died — capture stderr for diagnostics + rtl_err = '' + if dmr_rtl_process.stderr: + rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500] + dsd_err = '' + if dmr_dsd_process.stderr: + dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500] + logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}") + if dmr_active_device is not None: + app_module.release_sdr_device(dmr_active_device) + dmr_active_device = None + # Surface a clear error to the user + detail = rtl_err.strip() or dsd_err.strip() + if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err: + msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.' + elif detail: + msg = f'Failed to start DSD pipeline: {detail}' + else: + msg = 'Failed to start DSD pipeline' + return jsonify({'status': 'error', 'message': msg}), 500 + + # Drain rtl_fm stderr in background to prevent pipe blocking + def _drain_rtl_stderr(proc): + try: + for line in proc.stderr: + pass + except Exception: + pass + + threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start() + + dmr_running = True + dmr_thread = threading.Thread( + target=stream_dsd_output, + args=(dmr_rtl_process, dmr_dsd_process), + daemon=True, + ) + dmr_thread.start() + + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'protocol': protocol, + }) + + except Exception as e: + logger.error(f"Failed to start DMR: {e}") + if dmr_active_device is not None: + app_module.release_sdr_device(dmr_active_device) + dmr_active_device = None + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@dmr_bp.route('/stop', methods=['POST']) +def stop_dmr() -> Response: + """Stop digital voice decoding.""" + global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device + + with dmr_lock: + dmr_running = False + + for proc in [dmr_dsd_process, dmr_rtl_process]: + if proc and proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + if proc: + unregister_process(proc) + + dmr_rtl_process = None + dmr_dsd_process = None + + if dmr_active_device is not None: + app_module.release_sdr_device(dmr_active_device) + dmr_active_device = None + + return jsonify({'status': 'stopped'}) + + +@dmr_bp.route('/status') +def dmr_status() -> Response: + """Get DMR decoder status.""" + return jsonify({ + 'running': dmr_running, + 'device': dmr_active_device, + }) + + +@dmr_bp.route('/stream') +def stream_dmr() -> Response: + """SSE stream for DMR decoder events.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + while True: + try: + msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + try: + process_event('dmr', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) + except queue.Empty: + now = time.time() + if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response diff --git a/routes/dsc.py b/routes/dsc.py index 913b1ba..5dadfcd 100644 --- a/routes/dsc.py +++ b/routes/dsc.py @@ -36,9 +36,11 @@ from utils.database import ( ) from utils.dsc.parser import parse_dsc_message from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.validation import validate_device_index, validate_gain from utils.sdr import SDRFactory, SDRType from utils.dependencies import get_tool_path +from utils.process import register_process, unregister_process logger = logging.getLogger('intercept.dsc') @@ -169,17 +171,34 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non 'error': str(e) }) finally: + global dsc_active_device try: os.close(master_fd) except OSError: pass - decoder_process.wait() dsc_running = False + # Cleanup both processes + with app_module.dsc_lock: + rtl_proc = app_module.dsc_rtl_process + for proc in [rtl_proc, decoder_process]: + if proc: + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + unregister_process(proc) app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'}) - with app_module.dsc_lock: app_module.dsc_process = None app_module.dsc_rtl_process = None + # Release SDR device + if dsc_active_device is not None: + app_module.release_sdr_device(dsc_active_device) + dsc_active_device = None def _store_critical_alert(msg: dict) -> None: @@ -362,6 +381,7 @@ def start_decoding() -> Response: stdout=subprocess.PIPE, stderr=subprocess.PIPE ) + register_process(rtl_process) # Start stderr monitor thread stderr_thread = threading.Thread( @@ -382,6 +402,7 @@ def start_decoding() -> Response: stderr=slave_fd, close_fds=True ) + register_process(decoder_process) os.close(slave_fd) rtl_process.stdout.close() @@ -408,6 +429,15 @@ def start_decoding() -> Response: }) except FileNotFoundError as e: + # Kill orphaned rtl_fm process + try: + rtl_process.terminate() + rtl_process.wait(timeout=2) + except Exception: + try: + rtl_process.kill() + except Exception: + pass # Release device on failure if dsc_active_device is not None: app_module.release_sdr_device(dsc_active_device) @@ -417,6 +447,15 @@ def start_decoding() -> Response: 'message': f'Tool not found: {e.filename}' }), 400 except Exception as e: + # Kill orphaned rtl_fm process if it was started + try: + rtl_process.terminate() + rtl_process.wait(timeout=2) + except Exception: + try: + rtl_process.kill() + except Exception: + pass # Release device on failure if dsc_active_device is not None: app_module.release_sdr_device(dsc_active_device) @@ -487,6 +526,10 @@ def stream() -> Response: try: msg = app_module.dsc_queue.get(timeout=1) last_keepalive = time.time() + try: + process_event('dsc', msg, msg.get('type')) + except Exception: + pass yield format_sse(msg) except queue.Empty: now = time.time() diff --git a/routes/listening_post.py b/routes/listening_post.py index 092f262..658acdb 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -19,7 +19,8 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.logging import get_logger -from utils.sse import format_sse +from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.constants import ( SSE_QUEUE_TIMEOUT, SSE_KEEPALIVE_INTERVAL, @@ -101,6 +102,17 @@ def find_ffmpeg() -> str | None: return shutil.which('ffmpeg') +VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb'] + + +def normalize_modulation(value: str) -> str: + """Normalize and validate modulation string.""" + mod = str(value or '').lower().strip() + if mod not in VALID_MODULATIONS: + raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}') + return mod + + def add_activity_log(event_type: str, frequency: float, details: str = ''): @@ -724,31 +736,52 @@ def _start_audio_stream(frequency: float, modulation: str): ] try: - # Use shell pipe for reliable streaming - # Log stderr to temp files for error diagnosis + # Use subprocess piping for reliable streaming. + # Log stderr to temp files for error diagnosis. rtl_stderr_log = '/tmp/rtl_fm_stderr.log' ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' - shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}" logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}") # Retry loop for USB device contention (device may not be # released immediately after a previous process exits) max_attempts = 3 for attempt in range(max_attempts): - audio_rtl_process = None # Not used in shell mode - audio_process = subprocess.Popen( - shell_cmd, - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - start_new_session=True # Create new process group for clean shutdown - ) + audio_rtl_process = None + audio_process = None + rtl_err_handle = None + ffmpeg_err_handle = None + try: + rtl_err_handle = open(rtl_stderr_log, 'w') + ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w') + audio_rtl_process = subprocess.Popen( + sdr_cmd, + stdout=subprocess.PIPE, + stderr=rtl_err_handle, + bufsize=0, + start_new_session=True # Create new process group for clean shutdown + ) + audio_process = subprocess.Popen( + encoder_cmd, + stdin=audio_rtl_process.stdout, + stdout=subprocess.PIPE, + stderr=ffmpeg_err_handle, + bufsize=0, + start_new_session=True # Create new process group for clean shutdown + ) + if audio_rtl_process.stdout: + audio_rtl_process.stdout.close() + finally: + if rtl_err_handle: + rtl_err_handle.close() + if ffmpeg_err_handle: + ffmpeg_err_handle.close() # Brief delay to check if process started successfully time.sleep(0.3) - if audio_process.poll() is not None: + if (audio_rtl_process and audio_rtl_process.poll() is not None) or ( + audio_process and audio_process.poll() is not None + ): # Read stderr from temp files rtl_stderr = '' ffmpeg_stderr = '' @@ -765,10 +798,39 @@ def _start_audio_stream(frequency: float, modulation: str): if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") + if audio_process: + try: + audio_process.terminate() + audio_process.wait(timeout=0.5) + except Exception: + pass + if audio_rtl_process: + try: + audio_rtl_process.terminate() + audio_rtl_process.wait(timeout=0.5) + except Exception: + pass time.sleep(1.0) continue - logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}") + if audio_process and audio_process.poll() is None: + try: + audio_process.terminate() + audio_process.wait(timeout=0.5) + except Exception: + pass + if audio_rtl_process and audio_rtl_process.poll() is None: + try: + audio_rtl_process.terminate() + audio_rtl_process.wait(timeout=0.5) + except Exception: + pass + audio_process = None + audio_rtl_process = None + + logger.error( + f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}" + ) return # Pipeline started successfully @@ -778,9 +840,13 @@ def _start_audio_stream(frequency: float, modulation: str): try: ready, _, _ = select.select([audio_process.stdout], [], [], 4.0) if not ready: - logger.warning("Audio pipeline produced no data in startup window") + logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline") + _stop_audio_stream_internal() + return except Exception as e: logger.warning(f"Audio startup check failed: {e}") + _stop_audio_stream_internal() + return audio_running = True audio_frequency = frequency @@ -805,34 +871,36 @@ def _stop_audio_stream_internal(): audio_running = False audio_frequency = 0.0 - # Kill the shell process and its children + had_processes = audio_process is not None or audio_rtl_process is not None + + # Kill the pipeline processes and their groups if audio_process: try: - # Kill entire process group (rtl_fm, ffmpeg, shell) + # Kill entire process group (SDR demod + ffmpeg) try: os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL) except (ProcessLookupError, PermissionError): audio_process.kill() audio_process.wait(timeout=0.5) - except: + except Exception: + pass + + if audio_rtl_process: + try: + try: + os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + audio_rtl_process.kill() + audio_rtl_process.wait(timeout=0.5) + except Exception: pass audio_process = None audio_rtl_process = None - # Kill any orphaned rtl_fm, rtl_power, and ffmpeg processes - for proc_pattern in ['rtl_fm', 'rtl_power']: - try: - subprocess.run(['pkill', '-9', proc_pattern], capture_output=True, timeout=0.5) - except Exception: - pass - try: - subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5) - except Exception: - pass - # Pause for SDR device USB interface to be released by kernel - time.sleep(1.0) + if had_processes: + time.sleep(1.0) # ============================================ @@ -891,7 +959,7 @@ def start_scanner() -> Response: scanner_config['start_freq'] = float(data.get('start_freq', 88.0)) scanner_config['end_freq'] = float(data.get('end_freq', 108.0)) scanner_config['step'] = float(data.get('step', 0.1)) - scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower() + scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm')) scanner_config['squelch'] = int(data.get('squelch', 0)) scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0)) scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5)) @@ -1074,8 +1142,14 @@ def update_scanner_config() -> Response: updated.append(f"dwell={data['dwell_time']}s") if 'modulation' in data: - scanner_config['modulation'] = str(data['modulation']).lower() - updated.append(f"mod={data['modulation']}") + try: + scanner_config['modulation'] = normalize_modulation(data['modulation']) + updated.append(f"mod={data['modulation']}") + except (ValueError, TypeError) as e: + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 400 if updated: logger.info(f"Scanner config updated: {', '.join(updated)}") @@ -1107,9 +1181,13 @@ def stream_scanner_events() -> Response: while True: try: - msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - yield format_sse(msg) + msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + try: + process_event('listening_scanner', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: @@ -1161,10 +1239,10 @@ def get_presets() -> Response: # MANUAL AUDIO ENDPOINTS (for direct listening) # ============================================ -@listening_post_bp.route('/audio/start', methods=['POST']) -def start_audio() -> Response: - """Start audio at specific frequency (manual mode).""" - global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread +@listening_post_bp.route('/audio/start', methods=['POST']) +def start_audio() -> Response: + """Start audio at specific frequency (manual mode).""" + global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread # Stop scanner if running if scanner_running: @@ -1193,11 +1271,11 @@ def start_audio() -> Response: pass time.sleep(0.5) - data = request.json or {} + data = request.json or {} try: frequency = float(data.get('frequency', 0)) - modulation = str(data.get('modulation', 'wfm')).lower() + modulation = normalize_modulation(data.get('modulation', 'wfm')) squelch = int(data.get('squelch', 0)) gain = int(data.get('gain', 40)) device = int(data.get('device', 0)) @@ -1208,18 +1286,11 @@ def start_audio() -> Response: 'message': f'Invalid parameter: {e}' }), 400 - if frequency <= 0: - return jsonify({ - 'status': 'error', - 'message': 'frequency is required' - }), 400 - - valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb'] - if modulation not in valid_mods: - return jsonify({ - 'status': 'error', - 'message': f'Invalid modulation. Use: {", ".join(valid_mods)}' - }), 400 + if frequency <= 0: + return jsonify({ + 'status': 'error', + 'message': 'frequency is required' + }), 400 valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] if sdr_type not in valid_sdr_types: @@ -1228,14 +1299,19 @@ def start_audio() -> Response: 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' }), 400 - # Update config for audio - scanner_config['squelch'] = squelch - scanner_config['gain'] = gain - scanner_config['device'] = device - scanner_config['sdr_type'] = sdr_type + # Update config for audio + scanner_config['squelch'] = squelch + scanner_config['gain'] = gain + scanner_config['device'] = device + scanner_config['sdr_type'] = sdr_type + + # Stop waterfall if it's using the same SDR + if waterfall_running and waterfall_active_device == device: + _stop_waterfall_internal() + time.sleep(0.2) - # Claim device for listening audio - if listening_active_device is None or listening_active_device != device: + # Claim device for listening audio + if listening_active_device is None or listening_active_device != device: if listening_active_device is not None: app_module.release_sdr_device(listening_active_device) error = app_module.claim_sdr_device(device, 'listening') @@ -1341,13 +1417,6 @@ def audio_probe() -> Response: @listening_post_bp.route('/audio/stream') def stream_audio() -> Response: """Stream WAV audio.""" - # Optionally restart pipeline so the stream starts with a fresh header - if request.args.get('fresh') == '1' and audio_running: - try: - _start_audio_stream(audio_frequency or 0.0, audio_modulation or 'fm') - except Exception as e: - logger.error(f"Audio stream restart failed: {e}") - # Wait for audio to be ready (up to 2 seconds for modulation/squelch changes) for _ in range(40): if audio_running and audio_process: @@ -1397,3 +1466,362 @@ def stream_audio() -> Response: 'Transfer-Encoding': 'chunked', } ) + + +# ============================================ +# SIGNAL IDENTIFICATION ENDPOINT +# ============================================ + +@listening_post_bp.route('/signal/guess', methods=['POST']) +def guess_signal() -> Response: + """Identify a signal based on frequency, modulation, and other parameters.""" + data = request.json or {} + + freq_mhz = data.get('frequency_mhz') + if freq_mhz is None: + return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400 + + try: + freq_mhz = float(freq_mhz) + except (ValueError, TypeError): + return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400 + + if freq_mhz <= 0: + return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400 + + frequency_hz = int(freq_mhz * 1e6) + + modulation = data.get('modulation') + bandwidth_hz = data.get('bandwidth_hz') + if bandwidth_hz is not None: + try: + bandwidth_hz = int(bandwidth_hz) + except (ValueError, TypeError): + bandwidth_hz = None + + region = data.get('region', 'UK/EU') + + try: + from utils.signal_guess import guess_signal_type_dict + result = guess_signal_type_dict( + frequency_hz=frequency_hz, + modulation=modulation, + bandwidth_hz=bandwidth_hz, + region=region, + ) + return jsonify({'status': 'ok', **result}) + except Exception as e: + logger.error(f"Signal guess error: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# ============================================ +# WATERFALL / SPECTROGRAM ENDPOINTS +# ============================================ + +waterfall_process: Optional[subprocess.Popen] = None +waterfall_thread: Optional[threading.Thread] = None +waterfall_running = False +waterfall_lock = threading.Lock() +waterfall_queue: queue.Queue = queue.Queue(maxsize=200) +waterfall_active_device: Optional[int] = None +waterfall_config = { + 'start_freq': 88.0, + 'end_freq': 108.0, + 'bin_size': 10000, + 'gain': 40, + 'device': 0, + 'max_bins': 1024, + 'interval': 0.4, +} + + +def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]: + """Parse a single rtl_power CSV line into bins.""" + if not line or line.startswith('#'): + return None, None, None, [] + + parts = [p.strip() for p in line.split(',')] + if len(parts) < 6: + return None, None, None, [] + + # Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS) + timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0] + + start_idx = None + for i, tok in enumerate(parts): + try: + val = float(tok) + except ValueError: + continue + if val > 1e5: + start_idx = i + break + if start_idx is None or len(parts) < start_idx + 4: + return timestamp, None, None, [] + + try: + seg_start = float(parts[start_idx]) + seg_end = float(parts[start_idx + 1]) + raw_values = [] + for v in parts[start_idx + 3:]: + try: + raw_values.append(float(v)) + except ValueError: + continue + if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]): + raw_values = raw_values[1:] + return timestamp, seg_start, seg_end, raw_values + except ValueError: + return timestamp, None, None, [] + + +def _waterfall_loop(): + """Continuous rtl_power sweep loop emitting waterfall data.""" + global waterfall_running, waterfall_process + + rtl_power_path = find_rtl_power() + if not rtl_power_path: + logger.error("rtl_power not found for waterfall") + waterfall_running = False + return + + start_hz = int(waterfall_config['start_freq'] * 1e6) + end_hz = int(waterfall_config['end_freq'] * 1e6) + bin_hz = int(waterfall_config['bin_size']) + gain = waterfall_config['gain'] + device = waterfall_config['device'] + interval = float(waterfall_config.get('interval', 0.4)) + + cmd = [ + rtl_power_path, + '-f', f'{start_hz}:{end_hz}:{bin_hz}', + '-i', str(interval), + '-g', str(gain), + '-d', str(device), + ] + + try: + waterfall_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=1, + text=True, + ) + + current_ts = None + all_bins: list[float] = [] + sweep_start_hz = start_hz + sweep_end_hz = end_hz + + if not waterfall_process.stdout: + return + + for line in waterfall_process.stdout: + if not waterfall_running: + break + + ts, seg_start, seg_end, bins = _parse_rtl_power_line(line) + if ts is None or not bins: + continue + + if current_ts is None: + current_ts = ts + + if ts != current_ts and all_bins: + max_bins = int(waterfall_config.get('max_bins') or 0) + bins_to_send = all_bins + if max_bins > 0 and len(bins_to_send) > max_bins: + bins_to_send = _downsample_bins(bins_to_send, max_bins) + msg = { + 'type': 'waterfall_sweep', + 'start_freq': sweep_start_hz / 1e6, + 'end_freq': sweep_end_hz / 1e6, + 'bins': bins_to_send, + 'timestamp': datetime.now().isoformat(), + } + try: + waterfall_queue.put_nowait(msg) + except queue.Full: + try: + waterfall_queue.get_nowait() + except queue.Empty: + pass + try: + waterfall_queue.put_nowait(msg) + except queue.Full: + pass + + all_bins = [] + sweep_start_hz = start_hz + sweep_end_hz = end_hz + current_ts = ts + + all_bins.extend(bins) + if seg_start is not None: + sweep_start_hz = min(sweep_start_hz, seg_start) + if seg_end is not None: + sweep_end_hz = max(sweep_end_hz, seg_end) + + # Flush any remaining bins + if all_bins and waterfall_running: + max_bins = int(waterfall_config.get('max_bins') or 0) + bins_to_send = all_bins + if max_bins > 0 and len(bins_to_send) > max_bins: + bins_to_send = _downsample_bins(bins_to_send, max_bins) + msg = { + 'type': 'waterfall_sweep', + 'start_freq': sweep_start_hz / 1e6, + 'end_freq': sweep_end_hz / 1e6, + 'bins': bins_to_send, + 'timestamp': datetime.now().isoformat(), + } + try: + waterfall_queue.put_nowait(msg) + except queue.Full: + pass + + except Exception as e: + logger.error(f"Waterfall loop error: {e}") + finally: + waterfall_running = False + if waterfall_process and waterfall_process.poll() is None: + try: + waterfall_process.terminate() + waterfall_process.wait(timeout=1) + except Exception: + try: + waterfall_process.kill() + except Exception: + pass + waterfall_process = None + logger.info("Waterfall loop stopped") + + +def _stop_waterfall_internal() -> None: + """Stop the waterfall display and release resources.""" + global waterfall_running, waterfall_process, waterfall_active_device + + waterfall_running = False + if waterfall_process and waterfall_process.poll() is None: + try: + waterfall_process.terminate() + waterfall_process.wait(timeout=1) + except Exception: + try: + waterfall_process.kill() + except Exception: + pass + waterfall_process = None + + if waterfall_active_device is not None: + app_module.release_sdr_device(waterfall_active_device) + waterfall_active_device = None + + +@listening_post_bp.route('/waterfall/start', methods=['POST']) +def start_waterfall() -> Response: + """Start the waterfall/spectrogram display.""" + global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device + + with waterfall_lock: + if waterfall_running: + return jsonify({'status': 'error', 'message': 'Waterfall already running'}), 409 + + if not find_rtl_power(): + return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503 + + data = request.json or {} + + try: + waterfall_config['start_freq'] = float(data.get('start_freq', 88.0)) + waterfall_config['end_freq'] = float(data.get('end_freq', 108.0)) + waterfall_config['bin_size'] = int(data.get('bin_size', 10000)) + waterfall_config['gain'] = int(data.get('gain', 40)) + waterfall_config['device'] = int(data.get('device', 0)) + if data.get('interval') is not None: + interval = float(data.get('interval', waterfall_config['interval'])) + if interval < 0.1 or interval > 5: + return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400 + waterfall_config['interval'] = interval + if data.get('max_bins') is not None: + max_bins = int(data.get('max_bins', waterfall_config['max_bins'])) + if max_bins < 64 or max_bins > 4096: + return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400 + waterfall_config['max_bins'] = max_bins + except (ValueError, TypeError) as e: + return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 + + if waterfall_config['start_freq'] >= waterfall_config['end_freq']: + return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400 + + # Clear stale queue + try: + while True: + waterfall_queue.get_nowait() + except queue.Empty: + pass + + # Claim SDR device + error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall') + if error: + return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 + + waterfall_active_device = waterfall_config['device'] + waterfall_running = True + waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True) + waterfall_thread.start() + + return jsonify({'status': 'started', 'config': waterfall_config}) + + +@listening_post_bp.route('/waterfall/stop', methods=['POST']) +def stop_waterfall() -> Response: + """Stop the waterfall display.""" + _stop_waterfall_internal() + + return jsonify({'status': 'stopped'}) + + +@listening_post_bp.route('/waterfall/stream') +def stream_waterfall() -> Response: + """SSE stream for waterfall data.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + while True: + try: + msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + try: + process_event('waterfall', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) + except queue.Empty: + now = time.time() + if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response +def _downsample_bins(values: list[float], target: int) -> list[float]: + """Downsample bins to a target length using simple averaging.""" + if target <= 0 or len(values) <= target: + return values + + out: list[float] = [] + step = len(values) / target + for i in range(target): + start = int(i * step) + end = int((i + 1) * step) + if end <= start: + end = min(start + 1, len(values)) + chunk = values[start:end] + if not chunk: + continue + out.append(sum(chunk) / len(chunk)) + return out diff --git a/routes/pager.py b/routes/pager.py index 8a94d08..4ee5425 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -22,8 +22,9 @@ from utils.validation import ( validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_rtl_tcp_host, validate_rtl_tcp_port ) -from utils.sse import format_sse -from utils.process import safe_terminate, register_process +from utils.sse import format_sse +from utils.event_pipeline import process_event +from utils.process import safe_terminate, register_process, unregister_process from utils.sdr import SDRFactory, SDRType, SDRValidationError from utils.dependencies import get_tool_path @@ -146,14 +147,32 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: except Exception as e: app_module.output_queue.put({'type': 'error', 'text': str(e)}) finally: + global pager_active_device try: os.close(master_fd) except OSError: pass - process.wait() + # Cleanup companion rtl_fm process and decoder + with app_module.process_lock: + rtl_proc = getattr(app_module.current_process, '_rtl_process', None) + for proc in [rtl_proc, process]: + if proc: + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + unregister_process(proc) app_module.output_queue.put({'type': 'status', 'text': 'stopped'}) with app_module.process_lock: app_module.current_process = None + # Release SDR device + if pager_active_device is not None: + app_module.release_sdr_device(pager_active_device) + pager_active_device = None @pager_bp.route('/start', methods=['POST']) @@ -281,6 +300,7 @@ def start_decoding() -> Response: stdout=subprocess.PIPE, stderr=subprocess.PIPE ) + register_process(rtl_process) # Start a thread to monitor rtl_fm stderr for errors def monitor_rtl_stderr(): @@ -304,6 +324,7 @@ def start_decoding() -> Response: stderr=slave_fd, close_fds=True ) + register_process(multimon_process) os.close(slave_fd) rtl_process.stdout.close() @@ -322,12 +343,30 @@ def start_decoding() -> Response: return jsonify({'status': 'started', 'command': full_cmd}) except FileNotFoundError as e: + # Kill orphaned rtl_fm process + try: + rtl_process.terminate() + rtl_process.wait(timeout=2) + except Exception: + try: + rtl_process.kill() + except Exception: + pass # Release device on failure if pager_active_device is not None: app_module.release_sdr_device(pager_active_device) pager_active_device = None return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) except Exception as e: + # Kill orphaned rtl_fm process if it was started + try: + rtl_process.terminate() + rtl_process.wait(timeout=2) + except Exception: + try: + rtl_process.kill() + except Exception: + pass # Release device on failure if pager_active_device is not None: app_module.release_sdr_device(pager_active_device) @@ -430,10 +469,14 @@ def stream() -> Response: keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second while True: - try: - msg = app_module.output_queue.get(timeout=1) - last_keepalive = time.time() - yield format_sse(msg) + try: + msg = app_module.output_queue.get(timeout=1) + last_keepalive = time.time() + try: + process_event('pager', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= keepalive_interval: diff --git a/routes/recordings.py b/routes/recordings.py new file mode 100644 index 0000000..063fe93 --- /dev/null +++ b/routes/recordings.py @@ -0,0 +1,109 @@ +"""Session recording API endpoints.""" + +from __future__ import annotations + +from pathlib import Path + +from flask import Blueprint, jsonify, request, send_file + +from utils.recording import get_recording_manager, RECORDING_ROOT + +recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings') + + +@recordings_bp.route('/start', methods=['POST']) +def start_recording(): + data = request.get_json() or {} + mode = (data.get('mode') or '').strip() + if not mode: + return jsonify({'status': 'error', 'message': 'mode is required'}), 400 + + label = data.get('label') + metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} + + manager = get_recording_manager() + session = manager.start_recording(mode=mode, label=label, metadata=metadata) + + return jsonify({ + 'status': 'success', + 'session': { + 'id': session.id, + 'mode': session.mode, + 'label': session.label, + 'started_at': session.started_at.isoformat(), + 'file_path': str(session.file_path), + } + }) + + +@recordings_bp.route('/stop', methods=['POST']) +def stop_recording(): + data = request.get_json() or {} + mode = data.get('mode') + session_id = data.get('id') + + manager = get_recording_manager() + session = manager.stop_recording(mode=mode, session_id=session_id) + if not session: + return jsonify({'status': 'error', 'message': 'No active recording found'}), 404 + + return jsonify({ + 'status': 'success', + 'session': { + 'id': session.id, + 'mode': session.mode, + 'label': session.label, + 'started_at': session.started_at.isoformat(), + 'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None, + 'event_count': session.event_count, + 'size_bytes': session.size_bytes, + 'file_path': str(session.file_path), + } + }) + + +@recordings_bp.route('', methods=['GET']) +def list_recordings(): + manager = get_recording_manager() + limit = request.args.get('limit', default=50, type=int) + return jsonify({ + 'status': 'success', + 'recordings': manager.list_recordings(limit=limit), + 'active': manager.get_active(), + }) + + +@recordings_bp.route('/', methods=['GET']) +def get_recording(session_id: str): + manager = get_recording_manager() + rec = manager.get_recording(session_id) + if not rec: + return jsonify({'status': 'error', 'message': 'Recording not found'}), 404 + return jsonify({'status': 'success', 'recording': rec}) + + +@recordings_bp.route('//download', methods=['GET']) +def download_recording(session_id: str): + manager = get_recording_manager() + rec = manager.get_recording(session_id) + if not rec: + return jsonify({'status': 'error', 'message': 'Recording not found'}), 404 + + file_path = Path(rec['file_path']) + try: + resolved_root = RECORDING_ROOT.resolve() + resolved_file = file_path.resolve() + if resolved_root not in resolved_file.parents: + return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 + except Exception: + return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400 + + if not file_path.exists(): + return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404 + + return send_file( + file_path, + mimetype='application/x-ndjson', + as_attachment=True, + download_name=file_path.name, + ) diff --git a/routes/rtlamr.py b/routes/rtlamr.py index a269d67..abbfd1d 100644 --- a/routes/rtlamr.py +++ b/routes/rtlamr.py @@ -18,7 +18,8 @@ from utils.validation import ( validate_frequency, validate_device_index, validate_gain, validate_ppm ) from utils.sse import format_sse -from utils.process import safe_terminate, register_process +from utils.event_pipeline import process_event +from utils.process import safe_terminate, register_process, unregister_process rtlamr_bp = Blueprint('rtlamr', __name__) @@ -61,10 +62,37 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: except Exception as e: app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)}) finally: - process.wait() + global rtl_tcp_process, rtlamr_active_device + # Ensure rtlamr process is terminated + try: + process.terminate() + process.wait(timeout=2) + except Exception: + try: + process.kill() + except Exception: + pass + unregister_process(process) + # Kill companion rtl_tcp process + with rtl_tcp_lock: + if rtl_tcp_process: + try: + rtl_tcp_process.terminate() + rtl_tcp_process.wait(timeout=2) + except Exception: + try: + rtl_tcp_process.kill() + except Exception: + pass + unregister_process(rtl_tcp_process) + rtl_tcp_process = None app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'}) with app_module.rtlamr_lock: app_module.rtlamr_process = None + # Release SDR device + if rtlamr_active_device is not None: + app_module.release_sdr_device(rtlamr_active_device) + rtlamr_active_device = None @rtlamr_bp.route('/start_rtlamr', methods=['POST']) @@ -133,7 +161,8 @@ def start_rtlamr() -> Response: stdout=subprocess.PIPE, stderr=subprocess.PIPE ) - + register_process(rtl_tcp_process) + # Wait a moment for rtl_tcp to start time.sleep(3) @@ -141,6 +170,10 @@ def start_rtlamr() -> Response: app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'}) except Exception as e: logger.error(f"Failed to start rtl_tcp: {e}") + # Release SDR device on rtl_tcp failure + if rtlamr_active_device is not None: + app_module.release_sdr_device(rtlamr_active_device) + rtlamr_active_device = None return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500 # Build rtlamr command @@ -174,6 +207,7 @@ def start_rtlamr() -> Response: stdout=subprocess.PIPE, stderr=subprocess.PIPE ) + register_process(app_module.rtlamr_process) # Start output thread thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,)) @@ -262,6 +296,10 @@ def stream_rtlamr() -> Response: try: msg = app_module.rtlamr_queue.get(timeout=1) last_keepalive = time.time() + try: + process_event('rtlamr', msg, msg.get('type')) + except Exception: + pass yield format_sse(msg) except queue.Empty: now = time.time() diff --git a/routes/sensor.py b/routes/sensor.py index e6dec53..e5a719e 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -18,8 +18,9 @@ from utils.validation import ( validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_rtl_tcp_host, validate_rtl_tcp_port ) -from utils.sse import format_sse -from utils.process import safe_terminate, register_process +from utils.sse import format_sse +from utils.event_pipeline import process_event +from utils.process import safe_terminate, register_process, unregister_process from utils.sdr import SDRFactory, SDRType sensor_bp = Blueprint('sensor', __name__) @@ -59,10 +60,24 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: except Exception as e: app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) finally: - process.wait() + global sensor_active_device + # Ensure process is terminated + try: + process.terminate() + process.wait(timeout=2) + except Exception: + try: + process.kill() + except Exception: + pass + unregister_process(process) app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'}) with app_module.sensor_lock: app_module.sensor_process = None + # Release SDR device + if sensor_active_device is not None: + app_module.release_sdr_device(sensor_active_device) + sensor_active_device = None @sensor_bp.route('/start_sensor', methods=['POST']) @@ -149,6 +164,7 @@ def start_sensor() -> Response: stdout=subprocess.PIPE, stderr=subprocess.PIPE ) + register_process(app_module.sensor_process) # Start output thread thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,)) @@ -216,9 +232,13 @@ def stream_sensor() -> Response: while True: try: - msg = app_module.sensor_queue.get(timeout=1) - last_keepalive = time.time() - yield format_sse(msg) + msg = app_module.sensor_queue.get(timeout=1) + last_keepalive = time.time() + try: + process_event('sensor', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= keepalive_interval: diff --git a/routes/sstv.py b/routes/sstv.py index 1640fb8..ed3676a 100644 --- a/routes/sstv.py +++ b/routes/sstv.py @@ -13,8 +13,10 @@ from typing import Generator from flask import Blueprint, jsonify, request, Response, send_file +import app as app_module from utils.logging import get_logger -from utils.sse import format_sse +from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.sstv import ( get_sstv_decoder, is_sstv_available, @@ -30,6 +32,9 @@ sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv') # Queue for SSE progress streaming _sstv_queue: queue.Queue = queue.Queue(maxsize=100) +# Track which device is being used +sstv_active_device: int | None = None + def _progress_callback(progress: DecodeProgress) -> None: """Callback to queue progress updates for SSE stream.""" @@ -94,7 +99,7 @@ def start_decoder(): if not is_sstv_available(): return jsonify({ 'status': 'error', - 'message': 'SSTV decoder not available. Install slowrx: apt install slowrx' + 'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow' }), 400 decoder = get_sstv_decoder() @@ -158,6 +163,17 @@ def start_decoder(): latitude = None longitude = None + # Claim SDR device + global sstv_active_device + device_int = int(device_index) + error = app_module.claim_sdr_device(device_int, 'sstv') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + # Set callback and start decoder.set_callback(_progress_callback) success = decoder.start( @@ -168,6 +184,8 @@ def start_decoder(): ) if success: + sstv_active_device = device_int + result = { 'status': 'started', 'frequency': frequency, @@ -181,6 +199,8 @@ def start_decoder(): return jsonify(result) else: + # Release device on failure + app_module.release_sdr_device(device_int) return jsonify({ 'status': 'error', 'message': 'Failed to start decoder' @@ -195,8 +215,15 @@ def stop_decoder(): Returns: JSON confirmation. """ + global sstv_active_device decoder = get_sstv_decoder() decoder.stop() + + # Release device from registry + if sstv_active_device is not None: + app_module.release_sdr_device(sstv_active_device) + sstv_active_device = None + return jsonify({'status': 'stopped'}) @@ -287,6 +314,73 @@ def get_image(filename: str): return send_file(image_path, mimetype='image/png') +@sstv_bp.route('/images//download') +def download_image(filename: str): + """ + Download a decoded SSTV image file. + + Args: + filename: Image filename + + Returns: + Image file as attachment or 404. + """ + decoder = get_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + image_path = decoder._output_dir / filename + + if not image_path.exists(): + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename) + + +@sstv_bp.route('/images/', methods=['DELETE']) +def delete_image(filename: str): + """ + Delete a decoded SSTV image. + + Args: + filename: Image filename + + Returns: + JSON confirmation. + """ + decoder = get_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + if decoder.delete_image(filename): + return jsonify({'status': 'ok'}) + else: + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + +@sstv_bp.route('/images', methods=['DELETE']) +def delete_all_images(): + """ + Delete all decoded SSTV images. + + Returns: + JSON with count of deleted images. + """ + decoder = get_sstv_decoder() + count = decoder.delete_all_images() + return jsonify({'status': 'ok', 'deleted': count}) + + @sstv_bp.route('/stream') def stream_progress(): """ @@ -305,10 +399,14 @@ def stream_progress(): keepalive_interval = 30.0 while True: - try: - progress = _sstv_queue.get(timeout=1) - last_keepalive = time.time() - yield format_sse(progress) + try: + progress = _sstv_queue.get(timeout=1) + last_keepalive = time.time() + try: + process_event('sstv', progress, progress.get('type')) + except Exception: + pass + yield format_sse(progress) except queue.Empty: now = time.time() if now - last_keepalive >= keepalive_interval: diff --git a/routes/sstv_general.py b/routes/sstv_general.py new file mode 100644 index 0000000..5ebcbb2 --- /dev/null +++ b/routes/sstv_general.py @@ -0,0 +1,339 @@ +"""General SSTV (Slow-Scan Television) decoder routes. + +Provides endpoints for decoding terrestrial SSTV images on common HF/VHF/UHF +frequencies used by amateur radio operators worldwide. +""" + +from __future__ import annotations + +import queue +import time +from collections.abc import Generator +from pathlib import Path + +from flask import Blueprint, Response, jsonify, request, send_file + +from utils.logging import get_logger +from utils.sse import format_sse +from utils.event_pipeline import process_event +from utils.sstv import ( + DecodeProgress, + get_general_sstv_decoder, +) + +logger = get_logger('intercept.sstv_general') + +sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general') + +# Queue for SSE progress streaming +_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100) + +# Predefined SSTV frequencies +SSTV_FREQUENCIES = [ + {'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'}, + {'band': '80 m', 'frequency': 3.730, 'modulation': 'lsb', 'notes': 'Europe primary (analog/digital variants)', 'type': 'Terrestrial HF'}, + {'band': '40 m', 'frequency': 7.171, 'modulation': 'lsb', 'notes': 'Common international/US/EU SSTV activity', 'type': 'Terrestrial HF'}, + {'band': '40 m', 'frequency': 7.040, 'modulation': 'lsb', 'notes': 'Alternative US/Europe calling', 'type': 'Terrestrial HF'}, + {'band': '30 m', 'frequency': 10.132, 'modulation': 'usb', 'notes': 'Narrowband SSTV (e.g., MP73-N digital)', 'type': 'Terrestrial HF'}, + {'band': '20 m', 'frequency': 14.230, 'modulation': 'usb', 'notes': 'Most popular international SSTV frequency', 'type': 'Terrestrial HF'}, + {'band': '20 m', 'frequency': 14.233, 'modulation': 'usb', 'notes': 'Digital SSTV calling / alternative activity', 'type': 'Terrestrial HF'}, + {'band': '20 m', 'frequency': 14.240, 'modulation': 'usb', 'notes': 'Europe alternative', 'type': 'Terrestrial HF'}, + {'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'}, + {'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'}, + {'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'}, + {'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'}, + {'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'}, +] + +# Build a lookup for auto-detecting modulation from frequency +_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES} + + +def _progress_callback(progress: DecodeProgress) -> None: + """Callback to queue progress updates for SSE stream.""" + try: + _sstv_general_queue.put_nowait(progress.to_dict()) + except queue.Full: + try: + _sstv_general_queue.get_nowait() + _sstv_general_queue.put_nowait(progress.to_dict()) + except queue.Empty: + pass + + +@sstv_general_bp.route('/frequencies') +def get_frequencies(): + """Return the predefined SSTV frequency table.""" + return jsonify({ + 'status': 'ok', + 'frequencies': SSTV_FREQUENCIES, + }) + + +@sstv_general_bp.route('/status') +def get_status(): + """Get general SSTV decoder status.""" + decoder = get_general_sstv_decoder() + + return jsonify({ + 'available': decoder.decoder_available is not None, + 'decoder': decoder.decoder_available, + 'running': decoder.is_running, + 'image_count': len(decoder.get_images()), + }) + + +@sstv_general_bp.route('/start', methods=['POST']) +def start_decoder(): + """ + Start general SSTV decoder. + + JSON body: + { + "frequency": 14.230, // Frequency in MHz (required) + "modulation": "usb", // fm, usb, or lsb (auto-detected from frequency table if omitted) + "device": 0 // RTL-SDR device index + } + """ + decoder = get_general_sstv_decoder() + + if decoder.decoder_available is None: + return jsonify({ + 'status': 'error', + 'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', + }), 400 + + if decoder.is_running: + return jsonify({ + 'status': 'already_running', + }) + + # Clear queue + while not _sstv_general_queue.empty(): + try: + _sstv_general_queue.get_nowait() + except queue.Empty: + break + + data = request.get_json(silent=True) or {} + frequency = data.get('frequency') + modulation = data.get('modulation') + device_index = data.get('device', 0) + + # Validate frequency + if frequency is None: + return jsonify({ + 'status': 'error', + 'message': 'Frequency is required', + }), 400 + + try: + frequency = float(frequency) + if not (1 <= frequency <= 500): + return jsonify({ + 'status': 'error', + 'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', + }), 400 + except (TypeError, ValueError): + return jsonify({ + 'status': 'error', + 'message': 'Invalid frequency', + }), 400 + + # Auto-detect modulation from frequency table if not specified + if not modulation: + modulation = _FREQ_MODULATION_MAP.get(frequency, 'usb') + + # Validate modulation + if modulation not in ('fm', 'usb', 'lsb'): + return jsonify({ + 'status': 'error', + 'message': 'Modulation must be fm, usb, or lsb', + }), 400 + + # Set callback and start + decoder.set_callback(_progress_callback) + success = decoder.start( + frequency=frequency, + device_index=device_index, + modulation=modulation, + ) + + if success: + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'modulation': modulation, + 'device': device_index, + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to start decoder', + }), 500 + + +@sstv_general_bp.route('/stop', methods=['POST']) +def stop_decoder(): + """Stop general SSTV decoder.""" + decoder = get_general_sstv_decoder() + decoder.stop() + return jsonify({'status': 'stopped'}) + + +@sstv_general_bp.route('/images') +def list_images(): + """Get list of decoded SSTV images.""" + decoder = get_general_sstv_decoder() + images = decoder.get_images() + + limit = request.args.get('limit', type=int) + if limit and limit > 0: + images = images[-limit:] + + return jsonify({ + 'status': 'ok', + 'images': [img.to_dict() for img in images], + 'count': len(images), + }) + + +@sstv_general_bp.route('/images/') +def get_image(filename: str): + """Get a decoded SSTV image file.""" + decoder = get_general_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + image_path = decoder._output_dir / filename + + if not image_path.exists(): + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + return send_file(image_path, mimetype='image/png') + + +@sstv_general_bp.route('/images//download') +def download_image(filename: str): + """Download a decoded SSTV image file.""" + decoder = get_general_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + image_path = decoder._output_dir / filename + + if not image_path.exists(): + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename) + + +@sstv_general_bp.route('/images/', methods=['DELETE']) +def delete_image(filename: str): + """Delete a decoded SSTV image.""" + decoder = get_general_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + if decoder.delete_image(filename): + return jsonify({'status': 'ok'}) + else: + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + +@sstv_general_bp.route('/images', methods=['DELETE']) +def delete_all_images(): + """Delete all decoded SSTV images.""" + decoder = get_general_sstv_decoder() + count = decoder.delete_all_images() + return jsonify({'status': 'ok', 'deleted': count}) + + +@sstv_general_bp.route('/stream') +def stream_progress(): + """SSE stream of SSTV decode progress.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + keepalive_interval = 30.0 + + while True: + try: + progress = _sstv_general_queue.get(timeout=1) + last_keepalive = time.time() + try: + process_event('sstv_general', progress, progress.get('type')) + except Exception: + pass + yield format_sse(progress) + except queue.Empty: + now = time.time() + if now - last_keepalive >= keepalive_interval: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + 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 + + +@sstv_general_bp.route('/decode-file', methods=['POST']) +def decode_file(): + """Decode SSTV from an uploaded audio file.""" + if 'audio' not in request.files: + return jsonify({ + 'status': 'error', + 'message': 'No audio file provided', + }), 400 + + audio_file = request.files['audio'] + + if not audio_file.filename: + return jsonify({ + 'status': 'error', + 'message': 'No file selected', + }), 400 + + import tempfile + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: + audio_file.save(tmp.name) + tmp_path = tmp.name + + try: + decoder = get_general_sstv_decoder() + images = decoder.decode_file(tmp_path) + + return jsonify({ + 'status': 'ok', + 'images': [img.to_dict() for img in images], + 'count': len(images), + }) + + except Exception as e: + logger.error(f"Error decoding file: {e}") + return jsonify({ + 'status': 'error', + 'message': str(e), + }), 500 + + finally: + try: + Path(tmp_path).unlink() + except Exception: + pass diff --git a/routes/tscm.py b/routes/tscm.py index 6f9bdab..5a3d31d 100644 --- a/routes/tscm.py +++ b/routes/tscm.py @@ -60,6 +60,7 @@ from utils.tscm.device_identity import ( ingest_ble_dict, ingest_wifi_dict, ) +from utils.event_pipeline import process_event # Import unified Bluetooth scanner helper for TSCM integration try: @@ -627,6 +628,10 @@ def sweep_stream(): try: if tscm_queue: msg = tscm_queue.get(timeout=1) + try: + process_event('tscm', msg, msg.get('type')) + except Exception: + pass yield f"data: {json.dumps(msg)}\n\n" else: time.sleep(1) @@ -1072,6 +1077,32 @@ def _scan_wifi_networks(interface: str) -> list[dict]: return [] +def _scan_wifi_clients(interface: str) -> list[dict]: + """ + Get WiFi client observations from the unified WiFi scanner. + + Clients are only available when monitor-mode scanning is active. + """ + try: + from utils.wifi import get_wifi_scanner + + scanner = get_wifi_scanner() + if interface: + try: + if not scanner._is_monitor_mode_interface(interface): + return [] + except Exception: + return [] + + return [client.to_dict() for client in scanner.clients] + except ImportError as e: + logger.error(f"Failed to import wifi scanner: {e}") + return [] + except Exception as e: + logger.exception(f"WiFi client scan failed: {e}") + return [] + + def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: """ Scan for Bluetooth devices with manufacturer data detection. @@ -1606,6 +1637,7 @@ def _run_sweep( threats_found = 0 severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} all_wifi = {} # Use dict for deduplication by BSSID + all_wifi_clients = {} # Use dict for deduplication by client MAC all_bt = {} # Use dict for deduplication by MAC all_rf = [] @@ -1702,6 +1734,7 @@ def _run_sweep( 'channel': network.get('channel', ''), 'signal': network.get('power', ''), 'security': network.get('privacy', ''), + 'vendor': network.get('vendor'), 'is_threat': is_threat, 'is_new': not classification.get('in_baseline', False), 'classification': profile.risk_level.value, @@ -1715,6 +1748,77 @@ def _run_sweep( }) except Exception as e: logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}") + + # WiFi clients (monitor mode only) + try: + wifi_clients = _scan_wifi_clients(wifi_interface) + for client in wifi_clients: + mac = (client.get('mac') or '').upper() + if not mac or mac in all_wifi_clients: + continue + all_wifi_clients[mac] = client + + rssi_val = client.get('rssi_current') + if rssi_val is None: + 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, + } + + try: + timeline_manager.add_observation( + identifier=mac, + protocol='wifi', + rssi=rssi_val, + name=client_device.get('vendor') or f'WiFi Client {mac[-5:]}', + attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')} + ) + except Exception as e: + logger.debug(f"WiFi client timeline observation error: {e}") + _maybe_store_timeline( + identifier=mac, + protocol='wifi', + rssi=rssi_val, + attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')} + ) + + profile = 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['recommended_action'] = profile.recommended_action + + # Feed to identity engine for MAC-randomization resistant clustering + try: + wifi_obs = { + 'timestamp': datetime.now().isoformat(), + 'src_mac': mac, + 'bssid': client_device.get('associated_bssid'), + 'rssi': rssi_val, + 'frame_type': 'probe_request', + 'probed_ssids': client_device.get('probed_ssids', []), + } + ingest_wifi_dict(wifi_obs) + except Exception as e: + logger.debug(f"Identity engine WiFi client ingest error: {e}") + + _emit_event('wifi_client', client_device) + except Exception as e: + logger.debug(f"WiFi client scan error: {e}") except Exception as e: last_wifi_scan = current_time logger.error(f"WiFi scan error: {e}") @@ -1793,6 +1897,9 @@ def _run_sweep( 'name': device.get('name', 'Unknown'), 'device_type': device.get('type', ''), 'rssi': device.get('rssi', ''), + 'manufacturer': device.get('manufacturer'), + 'tracker': device.get('tracker'), + 'tracker_type': device.get('tracker_type'), 'is_threat': is_threat, 'is_new': not classification.get('in_baseline', False), 'classification': profile.risk_level.value, @@ -1921,6 +2028,7 @@ def _run_sweep( comparator = BaselineComparator(baseline) baseline_comparison = comparator.compare_all( wifi_devices=list(all_wifi.values()), + wifi_clients=list(all_wifi_clients.values()), bt_devices=list(all_bt.values()), rf_signals=all_rf ) @@ -1936,6 +2044,7 @@ def _run_sweep( if verbose_results: wifi_payload = list(all_wifi.values()) + wifi_client_payload = list(all_wifi_clients.values()) bt_payload = list(all_bt.values()) rf_payload = list(all_rf) else: @@ -1951,6 +2060,28 @@ def _run_sweep( } for d in all_wifi.values() ] + wifi_client_payload = [] + for client in all_wifi_clients.values(): + mac = client.get('mac') or client.get('address') + if isinstance(mac, str): + mac = mac.upper() + probed_ssids = client.get('probed_ssids') or [] + rssi = client.get('rssi') + if rssi is None: + rssi = client.get('rssi_current') + if rssi is None: + rssi = client.get('rssi_median') + if rssi is None: + rssi = client.get('rssi_ema') + wifi_client_payload.append({ + 'mac': mac, + 'vendor': client.get('vendor'), + 'rssi': rssi, + 'associated_bssid': client.get('associated_bssid'), + 'is_associated': client.get('is_associated'), + 'probed_ssids': probed_ssids, + 'probe_count': client.get('probe_count', len(probed_ssids)), + }) bt_payload = [ { 'mac': d.get('mac') or d.get('address'), @@ -1975,9 +2106,11 @@ def _run_sweep( status='completed', results={ 'wifi_devices': wifi_payload, + 'wifi_clients': wifi_client_payload, 'bt_devices': bt_payload, 'rf_signals': rf_payload, 'wifi_count': len(all_wifi), + 'wifi_client_count': len(all_wifi_clients), 'bt_count': len(all_bt), 'rf_count': len(all_rf), 'severity_counts': severity_counts, @@ -2005,6 +2138,7 @@ def _run_sweep( 'total_new': baseline_comparison['total_new'], 'total_missing': baseline_comparison['total_missing'], 'wifi': baseline_comparison.get('wifi'), + 'wifi_clients': baseline_comparison.get('wifi_clients'), 'bluetooth': baseline_comparison.get('bluetooth'), 'rf': baseline_comparison.get('rf'), }) @@ -2022,6 +2156,7 @@ def _run_sweep( 'sweep_id': _current_sweep_id, 'threats_found': threats_found, 'wifi_count': len(all_wifi), + 'wifi_client_count': len(all_wifi_clients), 'bt_count': len(all_bt), 'rf_count': len(all_rf), 'severity_counts': severity_counts, @@ -2169,6 +2304,7 @@ def compare_against_baseline(): Expects JSON body with: - wifi_devices: list of WiFi devices (optional) + - wifi_clients: list of WiFi clients (optional) - bt_devices: list of Bluetooth devices (optional) - rf_signals: list of RF signals (optional) @@ -2177,12 +2313,14 @@ def compare_against_baseline(): data = request.get_json() or {} wifi_devices = data.get('wifi_devices') + wifi_clients = data.get('wifi_clients') bt_devices = data.get('bt_devices') rf_signals = data.get('rf_signals') # Use the convenience function that gets active baseline comparison = get_comparison_for_active_baseline( wifi_devices=wifi_devices, + wifi_clients=wifi_clients, bt_devices=bt_devices, rf_signals=rf_signals ) @@ -2276,7 +2414,10 @@ def feed_wifi(): """Feed WiFi device data for baseline recording.""" data = request.get_json() if data: - _baseline_recorder.add_wifi_device(data) + if data.get('is_client'): + _baseline_recorder.add_wifi_client(data) + else: + _baseline_recorder.add_wifi_device(data) return jsonify({'status': 'success'}) @@ -2928,12 +3069,14 @@ def get_baseline_diff(baseline_id: int, sweep_id: int): results = json.loads(results) current_wifi = results.get('wifi_devices', []) + current_wifi_clients = results.get('wifi_clients', []) current_bt = results.get('bt_devices', []) current_rf = results.get('rf_signals', []) diff = calculate_baseline_diff( baseline=baseline, current_wifi=current_wifi, + current_wifi_clients=current_wifi_clients, current_bt=current_bt, current_rf=current_rf, sweep_id=sweep_id diff --git a/routes/websdr.py b/routes/websdr.py new file mode 100644 index 0000000..a93528c --- /dev/null +++ b/routes/websdr.py @@ -0,0 +1,504 @@ +"""HF/Shortwave WebSDR Integration - KiwiSDR network access.""" + +from __future__ import annotations + +import json +import math +import queue +import re +import struct +import threading +import time +from typing import Optional + +from flask import Blueprint, Flask, jsonify, request, Response + +try: + from flask_sock import Sock + WEBSOCKET_AVAILABLE = True +except ImportError: + WEBSOCKET_AVAILABLE = False + +from utils.kiwisdr import KiwiSDRClient, KIWI_SAMPLE_RATE, VALID_MODES, parse_host_port +from utils.logging import get_logger + +logger = get_logger('intercept.websdr') + +websdr_bp = Blueprint('websdr', __name__, url_prefix='/websdr') + +# ============================================ +# RECEIVER CACHE +# ============================================ + +_receiver_cache: list[dict] = [] +_cache_lock = threading.Lock() +_cache_timestamp: float = 0 +CACHE_TTL = 3600 # 1 hour + + +def _parse_gps_coord(coord_str: str) -> Optional[float]: + """Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float.""" + if not coord_str: + return None + # Remove parentheses and whitespace + cleaned = coord_str.strip().strip('()').strip() + try: + return float(cleaned) + except (ValueError, TypeError): + return None + + +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Calculate distance in km between two GPS coordinates.""" + R = 6371 # Earth radius in km + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * + math.sin(dlon / 2) ** 2) + c = 2 * math.asin(math.sqrt(a)) + return R * c + + +KIWI_DATA_URLS = [ + 'https://rx.skywavelinux.com/kiwisdr_com.js', + 'http://rx.linkfanel.net/kiwisdr_com.js', +] + + +def _fetch_kiwi_receivers() -> list[dict]: + """Fetch the KiwiSDR receiver list from the public directory.""" + import urllib.request + import json + + receivers = [] + raw = None + + # Try each data source until one works + for data_url in KIWI_DATA_URLS: + try: + req = urllib.request.Request(data_url, headers={ + 'User-Agent': 'INTERCEPT-SIGINT/1.0', + }) + with urllib.request.urlopen(req, timeout=20) as resp: + raw = resp.read().decode('utf-8', errors='replace') + if raw and len(raw) > 100: + logger.info(f"Fetched KiwiSDR data from {data_url}") + break + raw = None + except Exception as e: + logger.warning(f"Failed to fetch from {data_url}: {e}") + continue + + if not raw: + logger.error("All KiwiSDR data sources failed") + return receivers + + # The JS file contains: var kiwisdr_com = [ {...}, {...}, ... ]; + # Extract the JSON array + match = re.search(r'var\s+kiwisdr_com\s*=\s*(\[.*\])\s*;?', raw, re.DOTALL) + if not match: + # Try bare array + match = re.search(r'(\[\s*\{.*\}\s*\])', raw, re.DOTALL) + if not match: + logger.warning("Could not find receiver array in KiwiSDR data") + return receivers + + arr_str = match.group(1) + + # Parse JSON + try: + raw_list = json.loads(arr_str) + except json.JSONDecodeError: + # Fix common JS → JSON issues (trailing commas) + fixed = re.sub(r',\s*}', '}', arr_str) + fixed = re.sub(r',\s*]', ']', fixed) + try: + raw_list = json.loads(fixed) + except json.JSONDecodeError: + logger.error("Failed to parse KiwiSDR JSON") + return receivers + + for entry in raw_list: + if not isinstance(entry, dict): + continue + + # Skip offline receivers + if entry.get('offline') == 'yes' or entry.get('status') != 'active': + continue + + name = entry.get('name', 'Unknown') + url = entry.get('url', '') + gps = entry.get('gps', '') + antenna = entry.get('antenna', '') + location = entry.get('loc', '') + + # Parse users (strings in actual data) + try: + users = int(entry.get('users', 0)) + except (ValueError, TypeError): + users = 0 + try: + users_max = int(entry.get('users_max', 4)) + except (ValueError, TypeError): + users_max = 4 + + # Parse bands field: "0-30000000" (Hz) → freq_lo/freq_hi in kHz + bands_str = entry.get('bands', '0-30000000') + freq_lo = 0 + freq_hi = 30000 + if bands_str and '-' in str(bands_str): + try: + parts = str(bands_str).split('-') + freq_lo = int(parts[0]) / 1000 # Hz to kHz + freq_hi = int(parts[1]) / 1000 # Hz to kHz + except (ValueError, IndexError): + pass + + # Parse GPS: "(51.317266, -2.950479)" format + lat, lon = None, None + if gps: + parts = str(gps).replace('(', '').replace(')', '').split(',') + if len(parts) >= 2: + lat = _parse_gps_coord(parts[0]) + lon = _parse_gps_coord(parts[1]) + + if not url: + continue + + # Ensure URL has protocol + if not url.startswith('http'): + url = 'http://' + url + + receivers.append({ + 'name': name, + 'url': url.rstrip('/'), + 'lat': lat, + 'lon': lon, + 'location': location, + 'users': users, + 'users_max': users_max, + 'antenna': antenna, + 'bands': bands_str, + 'freq_lo': freq_lo, + 'freq_hi': freq_hi, + 'available': users < users_max, + }) + + return receivers + + +def get_receivers(force_refresh: bool = False) -> list[dict]: + """Get cached receiver list, refreshing if stale.""" + global _receiver_cache, _cache_timestamp + + with _cache_lock: + now = time.time() + if force_refresh or not _receiver_cache or (now - _cache_timestamp) > CACHE_TTL: + logger.info("Refreshing KiwiSDR receiver list...") + _receiver_cache = _fetch_kiwi_receivers() + _cache_timestamp = now + logger.info(f"Loaded {len(_receiver_cache)} KiwiSDR receivers") + + return _receiver_cache + + +# ============================================ +# API ENDPOINTS +# ============================================ + +@websdr_bp.route('/receivers') +def list_receivers() -> Response: + """List KiwiSDR receivers, with optional filters.""" + freq_khz = request.args.get('freq_khz', type=float) + available = request.args.get('available', type=str) + refresh = request.args.get('refresh', type=str) + + receivers = get_receivers(force_refresh=(refresh == 'true')) + + filtered = receivers + if available == 'true': + filtered = [r for r in filtered if r.get('available', True)] + + if freq_khz is not None: + filtered = [ + r for r in filtered + if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) + ] + + return jsonify({ + 'status': 'success', + 'receivers': filtered[:100], + 'total': len(filtered), + 'cached_total': len(receivers), + }) + + +@websdr_bp.route('/receivers/nearest') +def nearest_receivers() -> Response: + """Find receivers nearest to a given location.""" + lat = request.args.get('lat', type=float) + lon = request.args.get('lon', type=float) + freq_khz = request.args.get('freq_khz', type=float) + + if lat is None or lon is None: + return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400 + + receivers = get_receivers() + + # Filter by frequency if specified + if freq_khz is not None: + receivers = [ + r for r in receivers + if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) + ] + + # Calculate distances and sort + with_distance = [] + for r in receivers: + if r.get('lat') is not None and r.get('lon') is not None: + dist = _haversine(lat, lon, r['lat'], r['lon']) + entry = dict(r) + entry['distance_km'] = round(dist, 1) + with_distance.append(entry) + + with_distance.sort(key=lambda x: x['distance_km']) + + return jsonify({ + 'status': 'success', + 'receivers': with_distance[:10], + }) + + +@websdr_bp.route('/spy-station//receivers') +def spy_station_receivers(station_id: str) -> Response: + """Find receivers that can tune to a spy station's frequency.""" + try: + from routes.spy_stations import STATIONS + except ImportError: + return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503 + + # Find the station + station = None + for s in STATIONS: + if s.get('id') == station_id: + station = s + break + + if not station: + return jsonify({'status': 'error', 'message': 'Station not found'}), 404 + + # Get primary frequency + freq_khz = None + for f in station.get('frequencies', []): + if f.get('primary'): + freq_khz = f.get('freq_khz') + break + if freq_khz is None and station.get('frequencies'): + freq_khz = station['frequencies'][0].get('freq_khz') + + if freq_khz is None: + return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404 + + receivers = get_receivers() + + # Filter receivers that cover this frequency and are available + matching = [ + r for r in receivers + if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True) + ] + + return jsonify({ + 'status': 'success', + 'station': { + 'id': station['id'], + 'name': station.get('name', ''), + 'nickname': station.get('nickname', ''), + 'freq_khz': freq_khz, + 'mode': station.get('mode', 'USB'), + }, + 'receivers': matching[:20], + 'total': len(matching), + }) + + +@websdr_bp.route('/status') +def websdr_status() -> Response: + """Get WebSDR connection and cache status.""" + return jsonify({ + 'status': 'ok', + 'cached_receivers': len(_receiver_cache), + 'cache_age_seconds': round(time.time() - _cache_timestamp, 0) if _cache_timestamp > 0 else None, + 'cache_ttl': CACHE_TTL, + 'audio_connected': _kiwi_client is not None and _kiwi_client.connected if _kiwi_client else False, + }) + + +# ============================================ +# KIWISDR AUDIO PROXY +# ============================================ + +_kiwi_client: Optional[KiwiSDRClient] = None +_kiwi_lock = threading.Lock() +_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200) + + +def _disconnect_kiwi() -> None: + """Disconnect active KiwiSDR client.""" + global _kiwi_client + with _kiwi_lock: + if _kiwi_client: + _kiwi_client.disconnect() + _kiwi_client = None + # Drain audio queue + while not _kiwi_audio_queue.empty(): + try: + _kiwi_audio_queue.get_nowait() + except queue.Empty: + break + + +def _handle_kiwi_command(ws, cmd: str, data: dict) -> None: + """Handle a command from the browser client.""" + global _kiwi_client + + if cmd == 'connect': + receiver_url = data.get('url', '') + host = data.get('host', '') + port = int(data.get('port', 8073)) + freq_khz = float(data.get('freq_khz', 7000)) + mode = data.get('mode', 'am').lower() + password = data.get('password', '') + + # Parse host/port from URL if provided + if receiver_url and not host: + host, port = parse_host_port(receiver_url) + + if mode not in VALID_MODES: + ws.send(json.dumps({'type': 'error', 'message': f'Invalid mode: {mode}'})) + return + + if not host or ';' in host or '&' in host or '|' in host: + ws.send(json.dumps({'type': 'error', 'message': 'Invalid host'})) + return + + _disconnect_kiwi() + + def on_audio(pcm_bytes, smeter): + # Package: 2 bytes smeter (big-endian int16) + PCM data + header = struct.pack('>h', smeter) + try: + _kiwi_audio_queue.put_nowait(header + pcm_bytes) + except queue.Full: + try: + _kiwi_audio_queue.get_nowait() + except queue.Empty: + pass + try: + _kiwi_audio_queue.put_nowait(header + pcm_bytes) + except queue.Full: + pass + + def on_error(msg): + try: + ws.send(json.dumps({'type': 'error', 'message': msg})) + except Exception: + pass + + def on_disconnect(): + try: + ws.send(json.dumps({'type': 'disconnected'})) + except Exception: + pass + + with _kiwi_lock: + _kiwi_client = KiwiSDRClient( + host=host, port=port, + on_audio=on_audio, + on_error=on_error, + on_disconnect=on_disconnect, + password=password, + ) + success = _kiwi_client.connect(freq_khz, mode) + + if success: + ws.send(json.dumps({ + 'type': 'connected', + 'host': host, + 'port': port, + 'freq_khz': freq_khz, + 'mode': mode, + 'sample_rate': KIWI_SAMPLE_RATE, + })) + else: + ws.send(json.dumps({'type': 'error', 'message': 'Connection to KiwiSDR failed'})) + _disconnect_kiwi() + + elif cmd == 'tune': + freq_khz = float(data.get('freq_khz', 0)) + mode = data.get('mode', '').lower() or None + + with _kiwi_lock: + if _kiwi_client and _kiwi_client.connected: + success = _kiwi_client.tune( + freq_khz, + mode or _kiwi_client.mode + ) + if success: + ws.send(json.dumps({ + 'type': 'tuned', + 'freq_khz': freq_khz, + 'mode': mode or _kiwi_client.mode, + })) + else: + ws.send(json.dumps({'type': 'error', 'message': 'Retune failed'})) + else: + ws.send(json.dumps({'type': 'error', 'message': 'Not connected'})) + + elif cmd == 'disconnect': + _disconnect_kiwi() + ws.send(json.dumps({'type': 'disconnected'})) + + +def init_websdr_audio(app: Flask) -> None: + """Initialize WebSocket audio proxy for KiwiSDR. Called from app.py.""" + if not WEBSOCKET_AVAILABLE: + logger.warning("flask-sock not installed, KiwiSDR audio proxy disabled") + return + + sock = Sock(app) + + @sock.route('/ws/kiwi-audio') + def kiwi_audio_stream(ws): + """WebSocket endpoint: proxy audio between browser and KiwiSDR.""" + logger.info("KiwiSDR audio client connected") + + try: + while True: + # Check for commands from browser + try: + msg = ws.receive(timeout=0.005) + if msg: + data = json.loads(msg) + cmd = data.get('cmd', '') + _handle_kiwi_command(ws, cmd, data) + except TimeoutError: + pass + except Exception as e: + if 'closed' in str(e).lower(): + break + if 'timed out' not in str(e).lower(): + logger.error(f"KiwiSDR WS receive error: {e}") + + # Forward audio from KiwiSDR to browser + try: + audio_data = _kiwi_audio_queue.get_nowait() + ws.send(audio_data) + except queue.Empty: + time.sleep(0.005) + + except Exception as e: + logger.info(f"KiwiSDR WS closed: {e}") + finally: + _disconnect_kiwi() + logger.info("KiwiSDR audio client disconnected") diff --git a/routes/wifi.py b/routes/wifi.py index c6464b5..3c6018a 100644 --- a/routes/wifi.py +++ b/routes/wifi.py @@ -17,11 +17,12 @@ from flask import Blueprint, jsonify, request, Response import app as app_module from utils.dependencies import check_tool, get_tool_path -from utils.logging import wifi_logger as logger -from utils.process import is_valid_mac, is_valid_channel -from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface -from utils.sse import format_sse -from data.oui import get_manufacturer +from utils.logging import wifi_logger as logger +from utils.process import is_valid_mac, is_valid_channel +from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface +from utils.sse import format_sse +from utils.event_pipeline import process_event +from data.oui import get_manufacturer from utils.constants import ( WIFI_TERMINATE_TIMEOUT, PMKID_TERMINATE_TIMEOUT, @@ -46,8 +47,33 @@ from utils.constants import ( wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi') # PMKID process state -pmkid_process = None -pmkid_lock = threading.Lock() +pmkid_process = None +pmkid_lock = threading.Lock() + + +def _parse_channel_list(raw_channels: Any) -> list[int] | None: + """Parse a channel list from string/list input.""" + if raw_channels in (None, '', []): + return None + + if isinstance(raw_channels, str): + parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()] + elif isinstance(raw_channels, (list, tuple, set)): + parts = list(raw_channels) + else: + parts = [raw_channels] + + channels: list[int] = [] + seen = set() + for part in parts: + if part in (None, ''): + continue + ch = validate_wifi_channel(part) + if ch not in seen: + channels.append(ch) + seen.add(ch) + + return channels or None def detect_wifi_interfaces(): @@ -607,8 +633,9 @@ def start_wifi_scan(): return jsonify({'status': 'error', 'message': 'Scan already running'}) data = request.json - channel = data.get('channel') - band = data.get('band', 'abg') + channel = data.get('channel') + channels = data.get('channels') + band = data.get('band', 'abg') # Use provided interface or fall back to stored monitor interface interface = data.get('interface') @@ -658,8 +685,17 @@ def start_wifi_scan(): interface ] - if channel: - cmd.extend(['-c', str(channel)]) + channel_list = None + if channels: + try: + channel_list = _parse_channel_list(channels) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + if channel_list: + cmd.extend(['-c', ','.join(str(c) for c in channel_list)]) + elif channel: + cmd.extend(['-c', str(channel)]) logger.info(f"Running: {' '.join(cmd)}") @@ -851,32 +887,53 @@ def check_handshake_status(): return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False}) file_size = os.path.getsize(capture_file) - handshake_found = False + handshake_found = False + handshake_valid: bool | None = None + handshake_checked = False + handshake_reason: str | None = None try: - if target_bssid and is_valid_mac(target_bssid): - aircrack_path = get_tool_path('aircrack-ng') - if aircrack_path: - result = subprocess.run( - [aircrack_path, '-a', '2', '-b', target_bssid, capture_file], - capture_output=True, text=True, timeout=10 - ) - output = result.stdout + result.stderr - if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()): - if '0 handshake' not in output: - handshake_found = True + if target_bssid and is_valid_mac(target_bssid): + aircrack_path = get_tool_path('aircrack-ng') + if aircrack_path: + result = subprocess.run( + [aircrack_path, '-a', '2', '-b', target_bssid, capture_file], + capture_output=True, text=True, timeout=10 + ) + output = result.stdout + result.stderr + output_lower = output.lower() + handshake_checked = True + + if 'no valid wpa handshakes found' in output_lower: + handshake_valid = False + handshake_reason = 'No valid WPA handshake found' + elif '0 handshake' in output_lower: + handshake_valid = False + elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower): + handshake_valid = True + else: + handshake_valid = False except subprocess.TimeoutExpired: pass - except Exception as e: - logger.error(f"Error checking handshake: {e}") - - return jsonify({ - 'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped', - 'file_exists': True, - 'file_size': file_size, - 'file': capture_file, - 'handshake_found': handshake_found - }) + except Exception as e: + logger.error(f"Error checking handshake: {e}") + + if handshake_valid: + handshake_found = True + normalized_bssid = target_bssid.upper() if target_bssid else None + if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes: + app_module.wifi_handshakes.append(normalized_bssid) + + return jsonify({ + 'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped', + 'file_exists': True, + 'file_size': file_size, + 'file': capture_file, + 'handshake_found': handshake_found, + 'handshake_valid': handshake_valid, + 'handshake_checked': handshake_checked, + 'handshake_reason': handshake_reason + }) @wifi_bp.route('/pmkid/capture', methods=['POST']) @@ -1084,9 +1141,13 @@ def stream_wifi(): while True: try: - msg = app_module.wifi_queue.get(timeout=1) - last_keepalive = time.time() - yield format_sse(msg) + msg = app_module.wifi_queue.get(timeout=1) + last_keepalive = time.time() + try: + process_event('wifi', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= keepalive_interval: diff --git a/routes/wifi_v2.py b/routes/wifi_v2.py index 07dc6fb..ec9748e 100644 --- a/routes/wifi_v2.py +++ b/routes/wifi_v2.py @@ -16,14 +16,16 @@ from typing import Generator from flask import Blueprint, jsonify, request, Response -from utils.wifi import ( - get_wifi_scanner, - analyze_channels, - get_hidden_correlator, - SCAN_MODE_QUICK, - SCAN_MODE_DEEP, -) -from utils.sse import format_sse +from utils.wifi import ( + get_wifi_scanner, + analyze_channels, + get_hidden_correlator, + SCAN_MODE_QUICK, + SCAN_MODE_DEEP, +) +from utils.sse import format_sse +from utils.validation import validate_wifi_channel +from utils.event_pipeline import process_event logger = logging.getLogger(__name__) @@ -85,28 +87,44 @@ def start_deep_scan(): Requires monitor mode interface and root privileges. - Request body: - interface: Monitor mode interface (e.g., 'wlan0mon') - band: Band to scan ('2.4', '5', 'all') - channel: Optional specific channel to monitor + Request body: + interface: Monitor mode interface (e.g., 'wlan0mon') + band: Band to scan ('2.4', '5', 'all') + channel: Optional specific channel to monitor + channels: Optional list or comma-separated channels to monitor """ data = request.get_json() or {} interface = data.get('interface') band = data.get('band', 'all') - channel = data.get('channel') - - if channel: - try: - channel = int(channel) - except ValueError: - return jsonify({'error': 'Invalid channel'}), 400 + channel = data.get('channel') + channels = data.get('channels') + + channel_list = None + if channels: + if isinstance(channels, str): + channel_list = [c.strip() for c in channels.split(',') if c.strip()] + elif isinstance(channels, (list, tuple, set)): + channel_list = list(channels) + else: + channel_list = [channels] + try: + channel_list = [validate_wifi_channel(c) for c in channel_list] + except (TypeError, ValueError): + return jsonify({'error': 'Invalid channels'}), 400 + + if channel: + try: + channel = validate_wifi_channel(channel) + except ValueError: + return jsonify({'error': 'Invalid channel'}), 400 scanner = get_wifi_scanner() - success = scanner.start_deep_scan( - interface=interface, - band=band, - channel=channel, - ) + success = scanner.start_deep_scan( + interface=interface, + band=band, + channel=channel, + channels=channel_list, + ) if success: return jsonify({ @@ -388,10 +406,14 @@ def event_stream(): - keepalive: Periodic keepalive """ def generate() -> Generator[str, None, None]: - scanner = get_wifi_scanner() - - for event in scanner.get_event_stream(): - yield format_sse(event) + scanner = get_wifi_scanner() + + for event in scanner.get_event_stream(): + try: + process_event('wifi', event, event.get('type')) + except Exception: + pass + yield format_sse(event) response = Response(generate(), mimetype='text/event-stream') response.headers['Cache-Control'] = 'no-cache' diff --git a/setup.sh b/setup.sh index e09e9c7..7cdf6f1 100755 --- a/setup.sh +++ b/setup.sh @@ -204,12 +204,14 @@ check_tools() { check_required "dump1090" "ADS-B decoder" dump1090 check_required "acarsdec" "ACARS decoder" acarsdec check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher - check_optional "slowrx" "SSTV decoder (ISS images)" slowrx - echo info "GPS:" check_required "gpsd" "GPS daemon" gpsd + echo + info "Digital Voice:" + check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme + echo info "Audio:" check_required "ffmpeg" "Audio encoder/decoder" ffmpeg @@ -386,48 +388,6 @@ install_rtlamr_from_source() { fi } -install_slowrx_from_source_macos() { - info "slowrx not available via Homebrew. Building from source..." - - # Ensure build dependencies are installed - brew_install cmake - brew_install fftw - brew_install libsndfile - brew_install gtk+3 - brew_install pkg-config - - ( - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' EXIT - - info "Cloning slowrx..." - git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \ - || { warn "Failed to clone slowrx"; exit 1; } - - cd "$tmp_dir/slowrx" - info "Compiling slowrx..." - mkdir -p build && cd build - local cmake_log make_log - cmake_log=$(cmake .. 2>&1) || { - warn "cmake failed for slowrx:" - echo "$cmake_log" | tail -20 - exit 1 - } - make_log=$(make 2>&1) || { - warn "make failed for slowrx:" - echo "$make_log" | tail -20 - exit 1 - } - - # Install to /usr/local/bin - if [[ -w /usr/local/bin ]]; then - install -m 0755 slowrx /usr/local/bin/slowrx - else - sudo install -m 0755 slowrx /usr/local/bin/slowrx - fi - ok "slowrx installed successfully from source" - ) -} install_multimon_ng_from_source_macos() { info "multimon-ng not available via Homebrew. Building from source..." @@ -460,8 +420,192 @@ install_multimon_ng_from_source_macos() { ) } +install_dsd_from_source() { + info "Building DSD (Digital Speech Decoder) from source..." + info "This requires mbelib (vocoder library) as a prerequisite." + + if [[ "$OS" == "macos" ]]; then + brew_install cmake + brew_install libsndfile + brew_install ncurses + brew_install fftw + brew_install codec2 + brew_install librtlsdr + brew_install pulseaudio || true + else + apt_install build-essential git cmake libsndfile1-dev libpulse-dev \ + libfftw3-dev liblapack-dev libncurses-dev librtlsdr-dev libcodec2-dev + fi + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + # Step 1: Build and install mbelib (required dependency) + info "Building mbelib (vocoder library)..." + git clone https://github.com/lwvmobile/mbelib.git "$tmp_dir/mbelib" >/dev/null 2>&1 \ + || { warn "Failed to clone mbelib"; exit 1; } + + cd "$tmp_dir/mbelib" + git checkout ambe_tones >/dev/null 2>&1 || true + mkdir -p build && cd build + + if cmake .. >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then + if [[ "$OS" == "macos" ]]; then + if [[ -w /usr/local/lib ]]; then + make install >/dev/null 2>&1 + else + sudo make install >/dev/null 2>&1 + fi + else + $SUDO make install >/dev/null 2>&1 + $SUDO ldconfig 2>/dev/null || true + fi + ok "mbelib installed" + else + warn "Failed to build mbelib. Cannot build DSD without it." + exit 1 + fi + + # Step 2: Build dsd-fme (or fall back to original dsd) + info "Building dsd-fme..." + git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \ + || { warn "Failed to clone dsd-fme, trying original DSD..."; + git clone --depth 1 https://github.com/szechyjs/dsd.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \ + || { warn "Failed to clone DSD"; exit 1; }; } + + cd "$tmp_dir/dsd-fme" + mkdir -p build && cd build + + # On macOS, help cmake find Homebrew ncurses + local cmake_flags="" + if [[ "$OS" == "macos" ]]; then + local ncurses_prefix + ncurses_prefix="$(brew --prefix ncurses 2>/dev/null || echo /opt/homebrew/opt/ncurses)" + cmake_flags="-DCMAKE_PREFIX_PATH=$ncurses_prefix" + fi + + info "Compiling DSD..." + if cmake .. $cmake_flags >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then + if [[ "$OS" == "macos" ]]; then + if [[ -w /usr/local/bin ]]; then + install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true + else + sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true + fi + else + $SUDO make install >/dev/null 2>&1 \ + || $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null \ + || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null \ + || true + $SUDO ldconfig 2>/dev/null || true + fi + ok "DSD installed successfully" + else + warn "Failed to build DSD from source. DMR/P25 decoding will not be available." + fi + ) +} + +install_dump1090_from_source_macos() { + info "dump1090 not available via Homebrew. Building from source..." + + brew_install cmake + brew_install librtlsdr + brew_install pkg-config + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning FlightAware dump1090..." + git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \ + || { warn "Failed to clone dump1090"; exit 1; } + + cd "$tmp_dir/dump1090" + sed -i '' 's/-Werror//g' Makefile 2>/dev/null || true + info "Compiling dump1090..." + if make BLADERF=no RTLSDR=yes 2>&1 | tail -5; then + if [[ -w /usr/local/bin ]]; then + install -m 0755 dump1090 /usr/local/bin/dump1090 + else + sudo install -m 0755 dump1090 /usr/local/bin/dump1090 + fi + ok "dump1090 installed successfully from source" + else + warn "Failed to build dump1090. ADS-B decoding will not be available." + fi + ) +} + +install_acarsdec_from_source_macos() { + info "acarsdec not available via Homebrew. Building from source..." + + brew_install cmake + brew_install librtlsdr + brew_install libsndfile + brew_install pkg-config + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning acarsdec..." + git clone --depth 1 https://github.com/TLeconte/acarsdec.git "$tmp_dir/acarsdec" >/dev/null 2>&1 \ + || { warn "Failed to clone acarsdec"; exit 1; } + + cd "$tmp_dir/acarsdec" + mkdir -p build && cd build + + info "Compiling acarsdec..." + if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then + if [[ -w /usr/local/bin ]]; then + install -m 0755 acarsdec /usr/local/bin/acarsdec + else + sudo install -m 0755 acarsdec /usr/local/bin/acarsdec + fi + ok "acarsdec installed successfully from source" + else + warn "Failed to build acarsdec. ACARS decoding will not be available." + fi + ) +} + +install_aiscatcher_from_source_macos() { + info "AIS-catcher not available via Homebrew. Building from source..." + + brew_install cmake + brew_install librtlsdr + brew_install curl + brew_install pkg-config + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning AIS-catcher..." + git clone --depth 1 https://github.com/jvde-github/AIS-catcher.git "$tmp_dir/AIS-catcher" >/dev/null 2>&1 \ + || { warn "Failed to clone AIS-catcher"; exit 1; } + + cd "$tmp_dir/AIS-catcher" + mkdir -p build && cd build + + info "Compiling AIS-catcher..." + if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then + if [[ -w /usr/local/bin ]]; then + install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher + else + sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher + fi + ok "AIS-catcher installed successfully from source" + else + warn "Failed to build AIS-catcher. AIS vessel tracking will not be available." + fi + ) +} + install_macos_packages() { - TOTAL_STEPS=16 + TOTAL_STEPS=17 CURRENT_STEP=0 progress "Checking Homebrew" @@ -481,11 +625,20 @@ install_macos_packages() { progress "Installing direwolf (APRS decoder)" (brew_install direwolf) || warn "direwolf not available via Homebrew" - progress "Installing slowrx (SSTV decoder)" - if ! cmd_exists slowrx; then - install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available" + progress "SSTV decoder" + ok "SSTV uses built-in pure Python decoder (no external tools needed)" + + progress "Installing DSD (Digital Speech Decoder, optional)" + if ! cmd_exists dsd && ! cmd_exists dsd-fme; then + echo + info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding." + if ask_yes_no "Do you want to install DSD?"; then + install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available." + else + warn "Skipping DSD installation. DMR/P25 decoding will not be available." + fi else - ok "slowrx already installed" + ok "DSD already installed" fi progress "Installing ffmpeg" @@ -509,14 +662,22 @@ install_macos_packages() { fi progress "Installing dump1090" - (brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew" + if ! cmd_exists dump1090; then + (brew_install dump1090-mutability) || install_dump1090_from_source_macos || warn "dump1090 not available" + else + ok "dump1090 already installed" + fi progress "Installing acarsdec" - (brew_install acarsdec) || warn "acarsdec not available via Homebrew" + if ! cmd_exists acarsdec; then + (brew_install acarsdec) || install_acarsdec_from_source_macos || warn "acarsdec not available" + else + ok "acarsdec already installed" + fi progress "Installing AIS-catcher" if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then - (brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew" + (brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available" else ok "AIS-catcher already installed" fi @@ -683,37 +844,6 @@ install_aiscatcher_from_source_debian() { ) } -install_slowrx_from_source_debian() { - info "slowrx not available via APT. Building from source..." - - # slowrx uses a simple Makefile, not CMake - apt_install build-essential git pkg-config \ - libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev - - # Run in subshell to isolate EXIT trap - ( - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' EXIT - - info "Cloning slowrx..." - git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \ - || { warn "Failed to clone slowrx"; exit 1; } - - cd "$tmp_dir/slowrx" - - info "Compiling slowrx..." - local make_log - make_log=$(make 2>&1) || { - warn "make failed for slowrx:" - echo "$make_log" | tail -20 - warn "ISS SSTV decoding will not be available." - exit 1 - } - $SUDO install -m 0755 slowrx /usr/local/bin/slowrx - ok "slowrx installed successfully." - ) -} - install_ubertooth_from_source_debian() { info "Building Ubertooth from source..." @@ -849,7 +979,7 @@ install_debian_packages() { export NEEDRESTART_MODE=a fi - TOTAL_STEPS=21 + TOTAL_STEPS=22 CURRENT_STEP=0 progress "Updating APT package lists" @@ -905,8 +1035,21 @@ install_debian_packages() { progress "Installing direwolf (APRS decoder)" apt_install direwolf || true - progress "Installing slowrx (SSTV decoder)" - apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian + progress "SSTV decoder" + ok "SSTV uses built-in pure Python decoder (no external tools needed)" + + progress "Installing DSD (Digital Speech Decoder, optional)" + if ! cmd_exists dsd && ! cmd_exists dsd-fme; then + echo + info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding." + if ask_yes_no "Do you want to install DSD?"; then + install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available." + else + warn "Skipping DSD installation. DMR/P25 decoding will not be available." + fi + else + ok "DSD already installed" + fi progress "Installing ffmpeg" apt_install ffmpeg diff --git a/static/css/index.css b/static/css/index.css index b68145c..ab517b2 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -4201,6 +4201,12 @@ header h1 .tagline { color: #000; } +.bt-detail-btn.active { + background: rgba(34, 197, 94, 0.2); + border-color: rgba(34, 197, 94, 0.6); + color: #9fffd1; +} + /* Selected device highlight */ .bt-device-row.selected { background: rgba(0, 212, 255, 0.1); @@ -4392,6 +4398,17 @@ header h1 .tagline { border: 1px solid rgba(139, 92, 246, 0.3); } +.bt-history-badge { + display: inline-block; + padding: 1px 4px; + border-radius: 3px; + font-size: 8px; + font-weight: 600; + letter-spacing: 0.2px; + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + .bt-device-name { font-size: 13px; font-weight: 600; diff --git a/static/css/modes/sstv-general.css b/static/css/modes/sstv-general.css new file mode 100644 index 0000000..34f5bbc --- /dev/null +++ b/static/css/modes/sstv-general.css @@ -0,0 +1,668 @@ +/** + * SSTV General Mode Styles + * Terrestrial Slow-Scan Television decoder interface + */ + +/* ============================================ + MODE VISIBILITY + ============================================ */ +#sstvGeneralMode.active { + display: block !important; +} + +/* ============================================ + VISUALS CONTAINER + ============================================ */ +.sstv-general-visuals-container { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + min-height: 0; + flex: 1; + height: 100%; + overflow: hidden; +} + +/* ============================================ + STATS STRIP + ============================================ */ +.sstv-general-stats-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.sstv-general-strip-group { + display: flex; + align-items: center; + gap: 12px; +} + +.sstv-general-strip-status { + display: flex; + align-items: center; + gap: 6px; +} + +.sstv-general-strip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.sstv-general-strip-dot.idle { + background: var(--text-dim); +} + +.sstv-general-strip-dot.listening { + background: var(--accent-yellow); + animation: sstv-general-pulse 1s infinite; +} + +.sstv-general-strip-dot.decoding { + background: var(--accent-cyan); + box-shadow: 0 0 6px var(--accent-cyan); + animation: sstv-general-pulse 0.5s infinite; +} + +.sstv-general-strip-status-text { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + text-transform: uppercase; +} + +.sstv-general-strip-btn { + font-family: var(--font-mono); + font-size: 10px; + padding: 5px 12px; + border: none; + border-radius: 4px; + cursor: pointer; + text-transform: uppercase; + font-weight: 600; + transition: all 0.15s ease; +} + +.sstv-general-strip-btn.start { + background: var(--accent-cyan); + color: var(--bg-primary); +} + +.sstv-general-strip-btn.start:hover { + background: var(--accent-cyan-bright, #00d4ff); +} + +.sstv-general-strip-btn.stop { + background: var(--accent-red, #ff3366); + color: white; +} + +.sstv-general-strip-btn.stop:hover { + background: #ff1a53; +} + +.sstv-general-strip-divider { + width: 1px; + height: 24px; + background: var(--border-color); +} + +.sstv-general-strip-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 50px; +} + +.sstv-general-strip-value { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-general-strip-value.accent-cyan { + color: var(--accent-cyan); +} + +.sstv-general-strip-label { + font-family: var(--font-mono); + font-size: 8px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ============================================ + MAIN ROW (Live Decode + Gallery) + ============================================ */ +.sstv-general-main-row { + display: flex; + flex-direction: row; + gap: 12px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ============================================ + LIVE DECODE SECTION + ============================================ */ +.sstv-general-live-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + min-width: 300px; +} + +.sstv-general-live-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); +} + +.sstv-general-live-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-general-live-title svg { + color: var(--accent-cyan); +} + +.sstv-general-live-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px; + min-height: 0; +} + +.sstv-general-canvas-container { + position: relative; + background: #000; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +.sstv-general-decode-info { + width: 100%; + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sstv-general-mode-label { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent-cyan); + text-align: center; +} + +.sstv-general-progress-bar { + width: 100%; + height: 4px; + background: var(--bg-secondary); + border-radius: 2px; + overflow: hidden; +} + +.sstv-general-progress-bar .progress { + height: 100%; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); + border-radius: 2px; + transition: width 0.3s ease; +} + +.sstv-general-status-message { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-align: center; +} + +/* Idle state */ +.sstv-general-idle-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 40px 20px; + color: var(--text-dim); +} + +.sstv-general-idle-state svg { + width: 64px; + height: 64px; + opacity: 0.3; + margin-bottom: 16px; +} + +.sstv-general-idle-state h4 { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.sstv-general-idle-state p { + font-size: 12px; + max-width: 250px; +} + +/* ============================================ + GALLERY SECTION + ============================================ */ +.sstv-general-gallery-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1.5; + min-width: 300px; +} + +.sstv-general-gallery-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); +} + +.sstv-general-gallery-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.sstv-general-gallery-count { + font-family: var(--font-mono); + font-size: 10px; + color: var(--accent-cyan); + background: var(--bg-secondary); + padding: 2px 8px; + border-radius: 10px; +} + +.sstv-general-gallery-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + padding: 12px; + overflow-y: auto; + align-content: start; +} + +.sstv-general-image-card { + position: relative; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + transition: all 0.15s ease; +} + +.sstv-general-image-card:hover { + border-color: var(--accent-cyan); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); +} + +.sstv-general-image-card-inner { + cursor: pointer; +} + +.sstv-general-image-preview { + width: 100%; + aspect-ratio: 4/3; + object-fit: cover; + background: #000; + display: block; +} + +.sstv-general-image-actions { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: flex-end; + gap: 4px; + padding: 6px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + opacity: 0; + transition: opacity 0.15s; +} + +.sstv-general-image-card:hover .sstv-general-image-actions { + opacity: 1; +} + +.sstv-general-image-actions button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.15s; +} + +.sstv-general-image-actions button:hover { + background: rgba(255, 255, 255, 0.25); +} + +.sstv-general-image-actions button:last-child:hover { + background: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + +.sstv-general-image-info { + padding: 8px 10px; + border-top: 1px solid var(--border-color); +} + +.sstv-general-image-mode { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--accent-cyan); + margin-bottom: 4px; +} + +.sstv-general-image-timestamp { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); +} + +/* Empty gallery state */ +.sstv-general-gallery-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + color: var(--text-dim); + grid-column: 1 / -1; +} + +.sstv-general-gallery-empty svg { + width: 48px; + height: 48px; + opacity: 0.3; + margin-bottom: 12px; +} + +/* ============================================ + SIGNAL MONITOR + ============================================ */ +.sstv-general-signal-monitor { + width: 100%; + max-width: 320px; + padding: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.sstv-general-signal-monitor-header { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 14px; +} + +.sstv-general-signal-monitor-header svg { + color: var(--accent-cyan); +} + +.sstv-general-signal-level-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.sstv-general-signal-level-label { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.sstv-general-signal-bar-track { + flex: 1; + height: 6px; + background: var(--bg-primary); + border-radius: 3px; + overflow: hidden; +} + +.sstv-general-signal-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease, background 0.3s ease; + background: var(--text-dim); +} + +.sstv-general-signal-level-value { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + min-width: 24px; + text-align: right; +} + +.sstv-general-signal-status-text { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-align: center; +} + +.sstv-general-signal-vis-state { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + text-align: center; + margin-top: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sstv-general-signal-vis-state.active { + color: var(--accent-cyan); +} + +/* ============================================ + IMAGE MODAL + ============================================ */ +.sstv-general-image-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: none; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 40px; +} + +.sstv-general-image-modal.show { + display: flex; +} + +.sstv-general-image-modal img { + max-width: 100%; + max-height: 100%; + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.sstv-general-modal-toolbar { + position: absolute; + top: 20px; + right: 60px; + display: flex; + gap: 8px; + z-index: 1; +} + +.sstv-general-modal-btn { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.15s; + text-transform: uppercase; +} + +.sstv-general-modal-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.sstv-general-modal-btn.delete:hover { + background: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + +.sstv-general-modal-close { + position: absolute; + top: 20px; + right: 20px; + background: none; + border: none; + color: white; + font-size: 32px; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s; + z-index: 1; +} + +.sstv-general-modal-close:hover { + opacity: 1; +} + +/* Clear All button */ +.sstv-general-gallery-clear-btn { + font-family: var(--font-mono); + font-size: 9px; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 4px; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-dim); + cursor: pointer; + transition: all 0.15s; + margin-left: 8px; +} + +.sstv-general-gallery-clear-btn:hover { + color: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + +/* ============================================ + RESPONSIVE + ============================================ */ +@media (max-width: 1024px) { + .sstv-general-main-row { + flex-direction: column; + overflow-y: auto; + } + + .sstv-general-live-section { + max-width: none; + min-height: 350px; + } + + .sstv-general-gallery-section { + min-height: 300px; + } +} + +@media (max-width: 768px) { + .sstv-general-stats-strip { + padding: 8px 12px; + gap: 8px; + flex-wrap: wrap; + } + + .sstv-general-strip-divider { + display: none; + } + + .sstv-general-gallery-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px; + padding: 8px; + } +} + +@keyframes sstv-general-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} diff --git a/static/css/modes/sstv.css b/static/css/modes/sstv.css index 6af1ac9..9c987ce 100644 --- a/static/css/modes/sstv.css +++ b/static/css/modes/sstv.css @@ -388,12 +388,12 @@ } .sstv-image-card { + position: relative; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 6px; overflow: hidden; transition: all 0.15s ease; - cursor: pointer; } .sstv-image-card:hover { @@ -402,6 +402,10 @@ box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); } +.sstv-image-card-inner { + cursor: pointer; +} + .sstv-image-preview { width: 100%; aspect-ratio: 4/3; @@ -410,6 +414,48 @@ display: block; } +.sstv-image-actions { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: flex-end; + gap: 4px; + padding: 6px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + opacity: 0; + transition: opacity 0.15s; +} + +.sstv-image-card:hover .sstv-image-actions { + opacity: 1; +} + +.sstv-image-actions button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.15s; +} + +.sstv-image-actions button:hover { + background: rgba(255, 255, 255, 0.25); +} + +.sstv-image-actions button:last-child:hover { + background: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + .sstv-image-info { padding: 8px 10px; border-top: 1px solid var(--border-color); @@ -736,6 +782,96 @@ animation: pulse 0.5s infinite; } +/* ============================================ + SIGNAL MONITOR + ============================================ */ +.sstv-signal-monitor { + width: 100%; + max-width: 320px; + padding: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.sstv-signal-monitor-header { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 14px; +} + +.sstv-signal-monitor-header svg { + color: var(--accent-cyan); +} + +.sstv-signal-level-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.sstv-signal-level-label { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.sstv-signal-bar-track { + flex: 1; + height: 6px; + background: var(--bg-primary); + border-radius: 3px; + overflow: hidden; +} + +.sstv-signal-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease, background 0.3s ease; + background: var(--text-dim); +} + +.sstv-signal-level-value { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + min-width: 24px; + text-align: right; +} + +.sstv-signal-status-text { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-align: center; +} + +.sstv-signal-vis-state { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + text-align: center; + margin-top: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sstv-signal-vis-state.active { + color: var(--accent-cyan); +} + /* ============================================ IMAGE MODAL ============================================ */ @@ -764,6 +900,40 @@ border-radius: 4px; } +.sstv-modal-toolbar { + position: absolute; + top: 20px; + right: 60px; + display: flex; + gap: 8px; + z-index: 1; +} + +.sstv-modal-btn { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.15s; + text-transform: uppercase; +} + +.sstv-modal-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.sstv-modal-btn.delete:hover { + background: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + .sstv-modal-close { position: absolute; top: 20px; @@ -775,12 +945,33 @@ cursor: pointer; opacity: 0.7; transition: opacity 0.15s; + z-index: 1; } .sstv-modal-close:hover { opacity: 1; } +/* Clear All button */ +.sstv-gallery-clear-btn { + font-family: var(--font-mono); + font-size: 9px; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 4px; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-dim); + cursor: pointer; + transition: all 0.15s; + margin-left: 8px; +} + +.sstv-gallery-clear-btn:hover { + color: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + /* ============================================ RESPONSIVE ============================================ */ diff --git a/static/css/modes/tscm.css b/static/css/modes/tscm.css index 0335c0c..883faf1 100644 --- a/static/css/modes/tscm.css +++ b/static/css/modes/tscm.css @@ -196,6 +196,28 @@ margin-left: 6px; font-size: 10px; } +.tracker-badge { + margin-left: 6px; + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + background: rgba(255, 51, 102, 0.2); + color: #ff3366; + border: 1px solid rgba(255, 51, 102, 0.4); + text-transform: uppercase; + letter-spacing: 0.4px; +} +.client-badge { + margin-left: 6px; + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + background: rgba(74, 158, 255, 0.2); + color: #4a9eff; + border: 1px solid rgba(74, 158, 255, 0.4); + text-transform: uppercase; + letter-spacing: 0.4px; +} .known-badge { margin-left: 6px; font-size: 9px; diff --git a/static/css/settings.css b/static/css/settings.css index 9641b2b..41c6618 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -163,6 +163,47 @@ color: var(--text-muted, #666); } +/* Settings Feed Lists */ +.settings-feed { + background: var(--bg-tertiary, #12121f); + border: 1px solid var(--border-color, #1a1a2e); + border-radius: 6px; + padding: 8px; + max-height: 240px; + overflow-y: auto; +} + +.settings-feed-item { + padding: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + font-size: 11px; +} + +.settings-feed-item:last-child { + border-bottom: none; +} + +.settings-feed-title { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin-bottom: 4px; +} + +.settings-feed-meta { + color: var(--text-muted, #666); + font-size: 10px; +} + +.settings-feed-empty { + color: var(--text-dim, #666); + text-align: center; + padding: 20px 10px; + font-size: 11px; +} + /* Toggle Switch */ .toggle-switch { position: relative; diff --git a/static/js/core/alerts.js b/static/js/core/alerts.js new file mode 100644 index 0000000..52dcb84 --- /dev/null +++ b/static/js/core/alerts.js @@ -0,0 +1,194 @@ +const AlertCenter = (function() { + 'use strict'; + + let alerts = []; + let rules = []; + let eventSource = null; + + const TRACKER_RULE_NAME = 'Tracker Detected'; + + function init() { + loadRules(); + loadFeed(); + connect(); + } + + function connect() { + if (eventSource) { + eventSource.close(); + } + eventSource = new EventSource('/alerts/stream'); + eventSource.onmessage = function(e) { + try { + const data = JSON.parse(e.data); + if (data.type === 'keepalive') return; + handleAlert(data); + } catch (err) { + console.error('[Alerts] SSE parse error', err); + } + }; + eventSource.onerror = function() { + console.warn('[Alerts] SSE connection error'); + }; + } + + function handleAlert(alert) { + alerts.unshift(alert); + alerts = alerts.slice(0, 50); + updateFeedUI(); + + if (typeof showNotification === 'function') { + const severity = (alert.severity || '').toLowerCase(); + if (['high', 'critical'].includes(severity)) { + showNotification(alert.title || 'Alert', alert.message || 'Alert triggered'); + } + } + } + + function updateFeedUI() { + const list = document.getElementById('alertsFeedList'); + const countEl = document.getElementById('alertsFeedCount'); + if (countEl) countEl.textContent = `(${alerts.length})`; + if (!list) return; + + if (alerts.length === 0) { + list.innerHTML = '
No alerts yet
'; + return; + } + + list.innerHTML = alerts.map(alert => { + const title = escapeHtml(alert.title || 'Alert'); + const message = escapeHtml(alert.message || ''); + const severity = escapeHtml(alert.severity || 'medium'); + const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : ''; + return ` +
+
+ ${title} + ${severity.toUpperCase()} +
+
${message}
+
${createdAt}
+
+ `; + }).join(''); + } + + function loadFeed() { + fetch('/alerts/events?limit=20') + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + alerts = data.events || []; + updateFeedUI(); + } + }) + .catch(err => console.error('[Alerts] Load feed failed', err)); + } + + function loadRules() { + fetch('/alerts/rules?all=1') + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + rules = data.rules || []; + } + }) + .catch(err => console.error('[Alerts] Load rules failed', err)); + } + + function enableTrackerAlerts() { + ensureTrackerRule(true); + } + + function disableTrackerAlerts() { + ensureTrackerRule(false); + } + + function ensureTrackerRule(enabled) { + loadRules(); + setTimeout(() => { + const existing = rules.find(r => r.name === TRACKER_RULE_NAME); + if (existing) { + fetch(`/alerts/rules/${existing.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }) + }).then(() => loadRules()); + } else if (enabled) { + fetch('/alerts/rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: TRACKER_RULE_NAME, + mode: 'bluetooth', + event_type: 'device_update', + match: { is_tracker: true }, + severity: 'high', + enabled: true, + notify: { webhook: true } + }) + }).then(() => loadRules()); + } + }, 150); + } + + function addBluetoothWatchlist(address, name) { + if (!address) return; + const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address); + if (existing) { + return; + } + fetch('/alerts/rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name ? `Watchlist ${name}` : `Watchlist ${address}`, + mode: 'bluetooth', + event_type: 'device_update', + match: { address: address }, + severity: 'medium', + enabled: true, + notify: { webhook: true } + }) + }).then(() => loadRules()); + } + + function removeBluetoothWatchlist(address) { + if (!address) return; + const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address); + if (!existing) return; + fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' }) + .then(() => loadRules()); + } + + function isWatchlisted(address) { + return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled); + } + + function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + return { + init, + loadFeed, + enableTrackerAlerts, + disableTrackerAlerts, + addBluetoothWatchlist, + removeBluetoothWatchlist, + isWatchlisted, + }; +})(); + +document.addEventListener('DOMContentLoaded', () => { + if (typeof AlertCenter !== 'undefined') { + AlertCenter.init(); + } +}); diff --git a/static/js/core/recordings.js b/static/js/core/recordings.js new file mode 100644 index 0000000..d4188b7 --- /dev/null +++ b/static/js/core/recordings.js @@ -0,0 +1,136 @@ +const RecordingUI = (function() { + 'use strict'; + + let recordings = []; + let active = []; + + function init() { + refresh(); + } + + function refresh() { + fetch('/recordings') + .then(r => r.json()) + .then(data => { + if (data.status !== 'success') return; + recordings = data.recordings || []; + active = data.active || []; + renderActive(); + renderRecordings(); + }) + .catch(err => console.error('[Recording] Load failed', err)); + } + + function start() { + const modeSelect = document.getElementById('recordingModeSelect'); + const labelInput = document.getElementById('recordingLabelInput'); + const mode = modeSelect ? modeSelect.value : ''; + const label = labelInput ? labelInput.value : ''; + if (!mode) return; + + fetch('/recordings/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode, label }) + }) + .then(r => r.json()) + .then(() => { + refresh(); + }) + .catch(err => console.error('[Recording] Start failed', err)); + } + + function stop() { + const modeSelect = document.getElementById('recordingModeSelect'); + const mode = modeSelect ? modeSelect.value : ''; + if (!mode) return; + + fetch('/recordings/stop', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }) + }) + .then(r => r.json()) + .then(() => refresh()) + .catch(err => console.error('[Recording] Stop failed', err)); + } + + function stopById(sessionId) { + fetch('/recordings/stop', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: sessionId }) + }).then(() => refresh()); + } + + function renderActive() { + const container = document.getElementById('recordingActiveList'); + if (!container) return; + if (!active.length) { + container.innerHTML = '
No active recordings
'; + return; + } + container.innerHTML = active.map(session => { + return ` +
+
+ ${escapeHtml(session.mode)} + +
+
Started: ${new Date(session.started_at).toLocaleString()}
+
Events: ${session.event_count || 0}
+
+ `; + }).join(''); + } + + function renderRecordings() { + const container = document.getElementById('recordingList'); + if (!container) return; + if (!recordings.length) { + container.innerHTML = '
No recordings yet
'; + return; + } + container.innerHTML = recordings.map(rec => { + return ` +
+
+ ${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''} + +
+
${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}
+
Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}
+
+ `; + }).join(''); + } + + function download(sessionId) { + window.open(`/recordings/${sessionId}/download`, '_blank'); + } + + function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + return { + init, + refresh, + start, + stop, + stopById, + download, + }; +})(); + +document.addEventListener('DOMContentLoaded', () => { + if (typeof RecordingUI !== 'undefined') { + RecordingUI.init(); + } +}); diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index efd61da..3d2cb8b 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -922,5 +922,13 @@ function switchSettingsTab(tabName) { loadUpdateStatus(); } else if (tabName === 'location') { loadObserverLocation(); + } else if (tabName === 'alerts') { + if (typeof AlertCenter !== 'undefined') { + AlertCenter.loadFeed(); + } + } else if (tabName === 'recording') { + if (typeof RecordingUI !== 'undefined') { + RecordingUI.refresh(); + } } } diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index 2972c0c..16aa152 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -366,7 +366,10 @@ const BluetoothMode = (function() { // Badges const badgesEl = document.getElementById('btDetailBadges'); let badgesHtml = `${protocol.toUpperCase()}`; - badgesHtml += `${device.in_baseline ? '✓ KNOWN' : '● NEW'}`; + badgesHtml += `${device.in_baseline ? '✓ KNOWN' : '● NEW'}`; + if (device.seen_before) { + badgesHtml += `SEEN BEFORE`; + } // Tracker badge if (device.is_tracker) { @@ -448,12 +451,14 @@ const BluetoothMode = (function() { ? minMax[0] + '/' + minMax[1] : '--'; - document.getElementById('btDetailFirstSeen').textContent = device.first_seen - ? new Date(device.first_seen).toLocaleTimeString() - : '--'; - document.getElementById('btDetailLastSeen').textContent = device.last_seen - ? new Date(device.last_seen).toLocaleTimeString() - : '--'; + document.getElementById('btDetailFirstSeen').textContent = device.first_seen + ? new Date(device.first_seen).toLocaleTimeString() + : '--'; + document.getElementById('btDetailLastSeen').textContent = device.last_seen + ? new Date(device.last_seen).toLocaleTimeString() + : '--'; + + updateWatchlistButton(device); // Services const servicesContainer = document.getElementById('btDetailServices'); @@ -465,13 +470,29 @@ const BluetoothMode = (function() { servicesContainer.style.display = 'none'; } - // Show content, hide placeholder - placeholder.style.display = 'none'; - content.style.display = 'block'; + // Show content, hide placeholder + placeholder.style.display = 'none'; + content.style.display = 'block'; // Highlight selected device in list highlightSelectedDevice(deviceId); - } + } + + /** + * Update watchlist button state + */ + function updateWatchlistButton(device) { + const btn = document.getElementById('btDetailWatchBtn'); + if (!btn) return; + if (typeof AlertCenter === 'undefined') { + btn.style.display = 'none'; + return; + } + btn.style.display = ''; + const watchlisted = AlertCenter.isWatchlisted(device.address); + btn.textContent = watchlisted ? 'Watching' : 'Watchlist'; + btn.classList.toggle('active', watchlisted); + } /** * Clear device selection @@ -525,24 +546,43 @@ const BluetoothMode = (function() { /** * Copy selected device address to clipboard */ - function copyAddress() { - if (!selectedDeviceId) return; - const device = devices.get(selectedDeviceId); - if (!device) return; + function copyAddress() { + if (!selectedDeviceId) return; + const device = devices.get(selectedDeviceId); + if (!device) return; - navigator.clipboard.writeText(device.address).then(() => { - const btn = document.querySelector('.bt-detail-btn'); - if (btn) { - const originalText = btn.textContent; - btn.textContent = 'Copied!'; - btn.style.background = '#22c55e'; + navigator.clipboard.writeText(device.address).then(() => { + const btn = document.getElementById('btDetailCopyBtn'); + if (btn) { + const originalText = btn.textContent; + btn.textContent = 'Copied!'; + btn.style.background = '#22c55e'; setTimeout(() => { btn.textContent = originalText; btn.style.background = ''; }, 1500); } - }); - } + }); + } + + /** + * Toggle Bluetooth watchlist for selected device + */ + function toggleWatchlist() { + if (!selectedDeviceId) return; + const device = devices.get(selectedDeviceId); + if (!device || typeof AlertCenter === 'undefined') return; + + if (AlertCenter.isWatchlisted(device.address)) { + AlertCenter.removeBluetoothWatchlist(device.address); + showInfo('Removed from watchlist'); + } else { + AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address); + showInfo('Added to watchlist'); + } + + setTimeout(() => updateWatchlistButton(device), 200); + } /** * Select a device - opens modal with details @@ -1090,10 +1130,11 @@ const BluetoothMode = (function() { const isNew = !inBaseline; const hasName = !!device.name; const isTracker = device.is_tracker === true; - const trackerType = device.tracker_type; - const trackerConfidence = device.tracker_confidence; - const riskScore = device.risk_score || 0; - const agentName = device._agent || 'Local'; + const trackerType = device.tracker_type; + const trackerConfidence = device.tracker_confidence; + const riskScore = device.risk_score || 0; + const agentName = device._agent || 'Local'; + const seenBefore = device.seen_before === true; // Calculate RSSI bar width (0-100%) // RSSI typically ranges from -100 (weak) to -30 (very strong) @@ -1145,8 +1186,9 @@ const BluetoothMode = (function() { // Build secondary info line let secondaryParts = [addr]; - if (mfr) secondaryParts.push(mfr); - secondaryParts.push('Seen ' + seenCount + '×'); + if (mfr) secondaryParts.push(mfr); + secondaryParts.push('Seen ' + seenCount + '×'); + if (seenBefore) secondaryParts.push('SEEN BEFORE'); // Add agent name if not Local if (agentName !== 'Local') { secondaryParts.push('' + escapeHtml(agentName) + ''); @@ -1358,9 +1400,10 @@ const BluetoothMode = (function() { setBaseline, clearBaseline, exportData, - selectDevice, - clearSelection, - copyAddress, + selectDevice, + clearSelection, + copyAddress, + toggleWatchlist, // Agent handling handleAgentChange, diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js new file mode 100644 index 0000000..447afaa --- /dev/null +++ b/static/js/modes/dmr.js @@ -0,0 +1,504 @@ +/** + * Intercept - DMR / Digital Voice Mode + * Decoding DMR, P25, NXDN, D-STAR digital voice protocols + */ + +// ============== STATE ============== +let isDmrRunning = false; +let dmrEventSource = null; +let dmrCallCount = 0; +let dmrSyncCount = 0; +let dmrCallHistory = []; +let dmrCurrentProtocol = '--'; + +// ============== SYNTHESIZER STATE ============== +let dmrSynthCanvas = null; +let dmrSynthCtx = null; +let dmrSynthBars = []; +let dmrSynthAnimationId = null; +let dmrSynthInitialized = false; +let dmrActivityLevel = 0; +let dmrActivityTarget = 0; +let dmrEventType = 'idle'; +let dmrLastEventTime = 0; +const DMR_BAR_COUNT = 48; +const DMR_DECAY_RATE = 0.015; +const DMR_BURST_SYNC = 0.6; +const DMR_BURST_CALL = 0.85; +const DMR_BURST_VOICE = 0.95; + +// ============== TOOLS CHECK ============== + +function checkDmrTools() { + fetch('/dmr/tools') + .then(r => r.json()) + .then(data => { + const warning = document.getElementById('dmrToolsWarning'); + const warningText = document.getElementById('dmrToolsWarningText'); + if (!warning) return; + + const missing = []; + if (!data.dsd) missing.push('dsd (Digital Speech Decoder)'); + if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)'); + + if (missing.length > 0) { + warning.style.display = 'block'; + if (warningText) warningText.textContent = missing.join(', '); + } else { + warning.style.display = 'none'; + } + }) + .catch(() => {}); +} + +// ============== START / STOP ============== + +function startDmr() { + const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625); + const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; + const gain = parseInt(document.getElementById('dmrGain')?.value || 40); + const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; + + // Check device availability before starting + if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('dmr')) { + return; + } + + fetch('/dmr/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frequency, protocol, gain, device }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + isDmrRunning = true; + dmrCallCount = 0; + dmrSyncCount = 0; + dmrCallHistory = []; + updateDmrUI(); + connectDmrSSE(); + dmrEventType = 'idle'; + dmrActivityTarget = 0.1; + dmrLastEventTime = Date.now(); + if (!dmrSynthInitialized) initDmrSynthesizer(); + updateDmrSynthStatus(); + const statusEl = document.getElementById('dmrStatus'); + if (statusEl) statusEl.textContent = 'DECODING'; + if (typeof reserveDevice === 'function') { + reserveDevice(parseInt(device), 'dmr'); + } + if (typeof showNotification === 'function') { + showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`); + } + } else { + if (typeof showNotification === 'function') { + showNotification('Error', data.message || 'Failed to start DMR'); + } + } + }) + .catch(err => console.error('[DMR] Start error:', err)); +} + +function stopDmr() { + fetch('/dmr/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + isDmrRunning = false; + if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } + updateDmrUI(); + dmrEventType = 'stopped'; + dmrActivityTarget = 0; + updateDmrSynthStatus(); + const statusEl = document.getElementById('dmrStatus'); + if (statusEl) statusEl.textContent = 'STOPPED'; + if (typeof releaseDevice === 'function') { + releaseDevice('dmr'); + } + }) + .catch(err => console.error('[DMR] Stop error:', err)); +} + +// ============== SSE STREAMING ============== + +function connectDmrSSE() { + if (dmrEventSource) dmrEventSource.close(); + dmrEventSource = new EventSource('/dmr/stream'); + + dmrEventSource.onmessage = function(event) { + const msg = JSON.parse(event.data); + handleDmrMessage(msg); + }; + + dmrEventSource.onerror = function() { + if (isDmrRunning) { + setTimeout(connectDmrSSE, 2000); + } + }; +} + +function handleDmrMessage(msg) { + if (dmrSynthInitialized) dmrSynthPulse(msg.type); + + if (msg.type === 'sync') { + dmrCurrentProtocol = msg.protocol || '--'; + const protocolEl = document.getElementById('dmrActiveProtocol'); + if (protocolEl) protocolEl.textContent = dmrCurrentProtocol; + const mainProtocolEl = document.getElementById('dmrMainProtocol'); + if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol; + dmrSyncCount++; + const syncCountEl = document.getElementById('dmrSyncCount'); + if (syncCountEl) syncCountEl.textContent = dmrSyncCount; + } else if (msg.type === 'call') { + dmrCallCount++; + const countEl = document.getElementById('dmrCallCount'); + if (countEl) countEl.textContent = dmrCallCount; + const mainCountEl = document.getElementById('dmrMainCallCount'); + if (mainCountEl) mainCountEl.textContent = dmrCallCount; + + // Update current call display + const slotInfo = msg.slot != null ? ` +
+ Slot + ${msg.slot} +
` : ''; + const callEl = document.getElementById('dmrCurrentCall'); + if (callEl) { + callEl.innerHTML = ` +
+ Talkgroup + ${msg.talkgroup} +
+
+ Source ID + ${msg.source_id} +
${slotInfo} +
+ Time + ${msg.timestamp} +
+ `; + } + + // Add to history + dmrCallHistory.unshift({ + talkgroup: msg.talkgroup, + source_id: msg.source_id, + protocol: dmrCurrentProtocol, + time: msg.timestamp, + }); + if (dmrCallHistory.length > 50) dmrCallHistory.length = 50; + renderDmrHistory(); + + } else if (msg.type === 'slot') { + // Update slot info in current call + } else if (msg.type === 'raw') { + // Raw DSD output — triggers synthesizer activity via dmrSynthPulse + } else if (msg.type === 'heartbeat') { + // Decoder is alive and listening — keep synthesizer in listening state + if (isDmrRunning && dmrSynthInitialized) { + if (dmrEventType === 'idle' || dmrEventType === 'raw') { + dmrEventType = 'raw'; + dmrActivityTarget = Math.max(dmrActivityTarget, 0.15); + dmrLastEventTime = Date.now(); + updateDmrSynthStatus(); + } + } + } else if (msg.type === 'status') { + const statusEl = document.getElementById('dmrStatus'); + if (msg.text === 'started') { + if (statusEl) statusEl.textContent = 'DECODING'; + } else if (msg.text === 'crashed') { + isDmrRunning = false; + updateDmrUI(); + dmrEventType = 'stopped'; + dmrActivityTarget = 0; + updateDmrSynthStatus(); + if (statusEl) statusEl.textContent = 'CRASHED'; + if (typeof releaseDevice === 'function') releaseDevice('dmr'); + const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`; + if (typeof showNotification === 'function') { + showNotification('DMR Error', detail); + } + } else if (msg.text === 'stopped') { + isDmrRunning = false; + updateDmrUI(); + dmrEventType = 'stopped'; + dmrActivityTarget = 0; + updateDmrSynthStatus(); + if (statusEl) statusEl.textContent = 'STOPPED'; + if (typeof releaseDevice === 'function') releaseDevice('dmr'); + } + } +} + +// ============== UI ============== + +function updateDmrUI() { + const startBtn = document.getElementById('startDmrBtn'); + const stopBtn = document.getElementById('stopDmrBtn'); + if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block'; + if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none'; +} + +function renderDmrHistory() { + const container = document.getElementById('dmrHistoryBody'); + if (!container) return; + + const historyCountEl = document.getElementById('dmrHistoryCount'); + if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`; + + if (dmrCallHistory.length === 0) { + container.innerHTML = 'No calls recorded'; + return; + } + + container.innerHTML = dmrCallHistory.slice(0, 20).map(call => ` + + ${call.time} + ${call.talkgroup} + ${call.source_id} + ${call.protocol} + + `).join(''); +} + +// ============== SYNTHESIZER ============== + +function initDmrSynthesizer() { + dmrSynthCanvas = document.getElementById('dmrSynthCanvas'); + if (!dmrSynthCanvas) return; + + // Use the canvas element's own rendered size for the backing buffer + const rect = dmrSynthCanvas.getBoundingClientRect(); + const w = Math.round(rect.width) || 600; + const h = Math.round(rect.height) || 70; + dmrSynthCanvas.width = w; + dmrSynthCanvas.height = h; + + dmrSynthCtx = dmrSynthCanvas.getContext('2d'); + + dmrSynthBars = []; + for (let i = 0; i < DMR_BAR_COUNT; i++) { + dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 }; + } + + dmrActivityLevel = 0; + dmrActivityTarget = 0; + dmrEventType = isDmrRunning ? 'idle' : 'stopped'; + dmrSynthInitialized = true; + + updateDmrSynthStatus(); + + if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId); + drawDmrSynthesizer(); +} + +function drawDmrSynthesizer() { + if (!dmrSynthCtx || !dmrSynthCanvas) return; + + const width = dmrSynthCanvas.width; + const height = dmrSynthCanvas.height; + const barWidth = (width / DMR_BAR_COUNT) - 2; + const now = Date.now(); + + // Clear canvas + dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + dmrSynthCtx.fillRect(0, 0, width, height); + + // Decay activity toward target + const timeSinceEvent = now - dmrLastEventTime; + if (timeSinceEvent > 2000) { + // No events for 2s — decay target toward idle + dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE); + if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') { + dmrEventType = 'idle'; + updateDmrSynthStatus(); + } + } + + // Smooth approach to target + dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08; + + // Determine effective activity (idle breathing when stopped/idle) + let effectiveActivity = dmrActivityLevel; + if (dmrEventType === 'stopped') { + effectiveActivity = 0; + } else if (effectiveActivity < 0.1 && isDmrRunning) { + // Visible idle breathing — shows decoder is alive and listening + effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06; + } + + // Ripple timing for sync events + const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0; + // Voice ripple overlay + const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0; + + // Update bar targets and physics + for (let i = 0; i < DMR_BAR_COUNT; i++) { + const time = now / 200; + const wave1 = Math.sin(time + i * 0.3) * 0.2; + const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15; + const randomAmount = 0.05 + effectiveActivity * 0.25; + const random = (Math.random() - 0.5) * randomAmount; + + // Bell curve — center bars taller + const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2); + const centerBoost = 1 - centerDist * 0.5; + + // Sync ripple: center-outward wave burst + let rippleBoost = 0; + if (syncRippleAge > 0) { + const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2; + const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos; + rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4; + } + + const baseHeight = 0.1 + effectiveActivity * 0.55; + dmrSynthBars[i].targetHeight = Math.max(2, + (baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) * + effectiveActivity * centerBoost * height + ); + + // Spring physics + const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1; + const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height; + dmrSynthBars[i].velocity += diff * springStrength; + dmrSynthBars[i].velocity *= 0.78; + dmrSynthBars[i].height += dmrSynthBars[i].velocity; + dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height)); + } + + // Draw bars + for (let i = 0; i < DMR_BAR_COUNT; i++) { + const x = i * (barWidth + 2) + 1; + const barHeight = dmrSynthBars[i].height; + const y = (height - barHeight) / 2; + + // HSL color by event type + let hue, saturation, lightness; + if (dmrEventType === 'voice' && timeSinceEvent < 3000) { + hue = 30; // Orange + saturation = 85; + lightness = 40 + (barHeight / height) * 25; + } else if (dmrEventType === 'call' && timeSinceEvent < 3000) { + hue = 120; // Green + saturation = 80; + lightness = 35 + (barHeight / height) * 30; + } else if (dmrEventType === 'sync' && timeSinceEvent < 2000) { + hue = 185; // Cyan + saturation = 85; + lightness = 38 + (barHeight / height) * 25; + } else if (dmrEventType === 'stopped') { + hue = 220; + saturation = 20; + lightness = 18 + (barHeight / height) * 8; + } else { + // Idle / decayed + hue = 210; + saturation = 40; + lightness = 25 + (barHeight / height) * 15; + } + + // Vertical gradient per bar + const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight); + gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`); + gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`); + gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`); + + dmrSynthCtx.fillStyle = gradient; + dmrSynthCtx.fillRect(x, y, barWidth, barHeight); + + // Glow on tall bars + if (barHeight > height * 0.5 && effectiveActivity > 0.4) { + dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`; + dmrSynthCtx.shadowBlur = 8; + dmrSynthCtx.fillRect(x, y, barWidth, barHeight); + dmrSynthCtx.shadowBlur = 0; + } + } + + // Center line + dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)'; + dmrSynthCtx.lineWidth = 1; + dmrSynthCtx.beginPath(); + dmrSynthCtx.moveTo(0, height / 2); + dmrSynthCtx.lineTo(width, height / 2); + dmrSynthCtx.stroke(); + + dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer); +} + +function dmrSynthPulse(type) { + dmrLastEventTime = Date.now(); + + if (type === 'sync') { + dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC); + dmrEventType = 'sync'; + } else if (type === 'call') { + dmrActivityTarget = DMR_BURST_CALL; + dmrEventType = 'call'; + } else if (type === 'voice') { + dmrActivityTarget = DMR_BURST_VOICE; + dmrEventType = 'voice'; + } else if (type === 'slot' || type === 'nac') { + dmrActivityTarget = Math.max(dmrActivityTarget, 0.5); + } else if (type === 'raw') { + // Any DSD output means the decoder is alive and processing + dmrActivityTarget = Math.max(dmrActivityTarget, 0.25); + if (dmrEventType === 'idle') dmrEventType = 'raw'; + } + // keepalive and status don't change visuals + + updateDmrSynthStatus(); +} + +function updateDmrSynthStatus() { + const el = document.getElementById('dmrSynthStatus'); + if (!el) return; + + const labels = { + stopped: 'STOPPED', + idle: 'IDLE', + raw: 'LISTENING', + sync: 'SYNC', + call: 'CALL', + voice: 'VOICE' + }; + const colors = { + stopped: 'var(--text-muted)', + idle: 'var(--text-muted)', + raw: '#607d8b', + sync: '#00e5ff', + call: '#4caf50', + voice: '#ff9800' + }; + + el.textContent = labels[dmrEventType] || 'IDLE'; + el.style.color = colors[dmrEventType] || 'var(--text-muted)'; +} + +function resizeDmrSynthesizer() { + if (!dmrSynthCanvas) return; + const rect = dmrSynthCanvas.getBoundingClientRect(); + if (rect.width > 0) { + dmrSynthCanvas.width = Math.round(rect.width); + dmrSynthCanvas.height = Math.round(rect.height) || 70; + } +} + +function stopDmrSynthesizer() { + if (dmrSynthAnimationId) { + cancelAnimationFrame(dmrSynthAnimationId); + dmrSynthAnimationId = null; + } +} + +window.addEventListener('resize', resizeDmrSynthesizer); + +// ============== EXPORTS ============== + +window.startDmr = startDmr; +window.stopDmr = stopDmr; +window.checkDmrTools = checkDmrTools; +window.initDmrSynthesizer = initDmrSynthesizer; diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index e703cc6..afcea11 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -319,7 +319,7 @@ function stopScanner() { ? `/controller/agents/${listeningPostCurrentAgent}/listening_post/stop` : '/listening/scanner/stop'; - fetch(endpoint, { method: 'POST' }) + return fetch(endpoint, { method: 'POST' }) .then(() => { if (!isAgentMode && typeof releaseDevice === 'function') releaseDevice('scanner'); listeningPostCurrentAgent = null; @@ -830,6 +830,11 @@ function handleSignalFound(data) { if (typeof showNotification === 'function') { showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`); } + + // Auto-trigger signal identification + if (typeof guessSignal === 'function') { + guessSignal(data.frequency, data.modulation); + } } function handleSignalLost(data) { @@ -2240,9 +2245,14 @@ async function _startDirectListenInternal() { try { if (isScannerRunning) { - stopScanner(); + await stopScanner(); } + if (isWaterfallRunning && waterfallMode === 'rf') { + resumeRfWaterfallAfterListening = true; + await stopWaterfall(); + } + const freqInput = document.getElementById('radioScanStart'); const freq = freqInput ? parseFloat(freqInput.value) : 118.0; const squelchValue = parseInt(document.getElementById('radioSquelchValue')?.textContent); @@ -2301,6 +2311,10 @@ async function _startDirectListenInternal() { addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error'); isDirectListening = false; updateDirectListenUI(false); + if (resumeRfWaterfallAfterListening) { + resumeRfWaterfallAfterListening = false; + setTimeout(() => startWaterfall(), 200); + } return; } @@ -2347,6 +2361,15 @@ async function _startDirectListenInternal() { initAudioVisualizer(); isDirectListening = true; + + if (resumeRfWaterfallAfterListening) { + isWaterfallRunning = true; + const waterfallPanel = document.getElementById('waterfallPanel'); + if (waterfallPanel) waterfallPanel.style.display = 'block'; + document.getElementById('startWaterfallBtn').style.display = 'none'; + document.getElementById('stopWaterfallBtn').style.display = 'block'; + startAudioWaterfall(); + } updateDirectListenUI(true, freq); addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal'); @@ -2355,6 +2378,10 @@ async function _startDirectListenInternal() { addScannerLogEntry('Error: ' + e.message, '', 'error'); isDirectListening = false; updateDirectListenUI(false); + if (resumeRfWaterfallAfterListening) { + resumeRfWaterfallAfterListening = false; + setTimeout(() => startWaterfall(), 200); + } } finally { isRestarting = false; } @@ -2551,6 +2578,20 @@ function stopDirectListen() { currentSignalLevel = 0; updateDirectListenUI(false); addScannerLogEntry('Listening stopped'); + + if (waterfallMode === 'audio') { + stopAudioWaterfall(); + } + + if (resumeRfWaterfallAfterListening) { + resumeRfWaterfallAfterListening = false; + isWaterfallRunning = false; + setTimeout(() => startWaterfall(), 200); + } else if (waterfallMode === 'audio' && isWaterfallRunning) { + isWaterfallRunning = false; + document.getElementById('startWaterfallBtn').style.display = 'block'; + document.getElementById('stopWaterfallBtn').style.display = 'none'; + } } /** @@ -2937,6 +2978,505 @@ window.updateListenButtonState = updateListenButtonState; // Export functions for HTML onclick handlers window.toggleDirectListen = toggleDirectListen; window.startDirectListen = startDirectListen; +// ============== SIGNAL IDENTIFICATION ============== + +function guessSignal(frequencyMhz, modulation) { + const body = { frequency_mhz: frequencyMhz }; + if (modulation) body.modulation = modulation; + + return fetch('/listening/signal/guess', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'ok') { + renderSignalGuess(data); + } + return data; + }) + .catch(err => console.error('[SIGNAL-ID] Error:', err)); +} + +function renderSignalGuess(result) { + const panel = document.getElementById('signalGuessPanel'); + if (!panel) return; + panel.style.display = 'block'; + + const label = document.getElementById('signalGuessLabel'); + const badge = document.getElementById('signalGuessBadge'); + const explanation = document.getElementById('signalGuessExplanation'); + const tagsEl = document.getElementById('signalGuessTags'); + const altsEl = document.getElementById('signalGuessAlternatives'); + + if (label) label.textContent = result.primary_label || 'Unknown'; + + if (badge) { + badge.textContent = result.confidence || ''; + const colors = { 'HIGH': '#00e676', 'MEDIUM': '#ff9800', 'LOW': '#9e9e9e' }; + badge.style.background = colors[result.confidence] || '#9e9e9e'; + badge.style.color = '#000'; + } + + if (explanation) explanation.textContent = result.explanation || ''; + + if (tagsEl) { + tagsEl.innerHTML = (result.tags || []).map(tag => + `${tag}` + ).join(''); + } + + if (altsEl) { + if (result.alternatives && result.alternatives.length > 0) { + altsEl.innerHTML = 'Also: ' + result.alternatives.map(a => + `${a.label} (${a.confidence})` + ).join(', '); + } else { + altsEl.innerHTML = ''; + } + } +} + +function manualSignalGuess() { + const input = document.getElementById('signalGuessFreqInput'); + if (!input || !input.value) return; + const freq = parseFloat(input.value); + if (isNaN(freq) || freq <= 0) return; + guessSignal(freq, currentModulation); +} + + +// ============== WATERFALL / SPECTROGRAM ============== + +let isWaterfallRunning = false; +let waterfallEventSource = null; +let waterfallCanvas = null; +let waterfallCtx = null; +let spectrumCanvas = null; +let spectrumCtx = null; +let waterfallStartFreq = 88; +let waterfallEndFreq = 108; +let waterfallRowImage = null; +let waterfallPalette = null; +let lastWaterfallDraw = 0; +const WATERFALL_MIN_INTERVAL_MS = 50; +let waterfallInteractionBound = false; +let waterfallResizeObserver = null; +let waterfallMode = 'rf'; +let audioWaterfallAnimId = null; +let lastAudioWaterfallDraw = 0; +let resumeRfWaterfallAfterListening = false; + +function resizeCanvasToDisplaySize(canvas) { + if (!canvas) return false; + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return false; + const width = Math.max(1, Math.round(rect.width * dpr)); + const height = Math.max(1, Math.round(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + return true; + } + return false; +} + +function getWaterfallRowHeight() { + const dpr = window.devicePixelRatio || 1; + return Math.max(1, Math.round(dpr)); +} + +function initWaterfallCanvas() { + waterfallCanvas = document.getElementById('waterfallCanvas'); + spectrumCanvas = document.getElementById('spectrumCanvas'); + if (waterfallCanvas) { + resizeCanvasToDisplaySize(waterfallCanvas); + waterfallCtx = waterfallCanvas.getContext('2d'); + if (waterfallCtx) { + waterfallCtx.imageSmoothingEnabled = false; + waterfallRowImage = waterfallCtx.createImageData( + waterfallCanvas.width, + getWaterfallRowHeight() + ); + } + } + if (spectrumCanvas) { + resizeCanvasToDisplaySize(spectrumCanvas); + spectrumCtx = spectrumCanvas.getContext('2d'); + if (spectrumCtx) { + spectrumCtx.imageSmoothingEnabled = false; + } + } + if (!waterfallPalette) waterfallPalette = buildWaterfallPalette(); + + if (!waterfallInteractionBound) { + bindWaterfallInteraction(); + waterfallInteractionBound = true; + } + + if (!waterfallResizeObserver && waterfallCanvas) { + const observerTarget = waterfallCanvas.parentElement; + if (observerTarget && typeof ResizeObserver !== 'undefined') { + waterfallResizeObserver = new ResizeObserver(() => { + const resizedWaterfall = resizeCanvasToDisplaySize(waterfallCanvas); + const resizedSpectrum = spectrumCanvas ? resizeCanvasToDisplaySize(spectrumCanvas) : false; + if (resizedWaterfall && waterfallCtx) { + waterfallRowImage = waterfallCtx.createImageData( + waterfallCanvas.width, + getWaterfallRowHeight() + ); + } + if (resizedWaterfall || resizedSpectrum) { + lastWaterfallDraw = 0; + } + }); + waterfallResizeObserver.observe(observerTarget); + } + } +} + +function setWaterfallMode(mode) { + waterfallMode = mode; + const header = document.getElementById('waterfallFreqRange'); + if (!header) return; + if (mode === 'audio') { + header.textContent = 'Audio Spectrum (0 - 22 kHz)'; + } +} + +function startAudioWaterfall() { + if (audioWaterfallAnimId) return; + if (!visualizerAnalyser) { + initAudioVisualizer(); + } + if (!visualizerAnalyser) return; + + setWaterfallMode('audio'); + initWaterfallCanvas(); + + const sampleRate = visualizerContext ? visualizerContext.sampleRate : 44100; + const maxFreqKhz = (sampleRate / 2) / 1000; + const dataArray = new Uint8Array(visualizerAnalyser.frequencyBinCount); + + const drawFrame = (ts) => { + if (!isDirectListening || waterfallMode !== 'audio') { + stopAudioWaterfall(); + return; + } + if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) { + lastAudioWaterfallDraw = ts; + visualizerAnalyser.getByteFrequencyData(dataArray); + const bins = Array.from(dataArray, v => v); + drawWaterfallRow(bins); + drawSpectrumLine(bins, 0, maxFreqKhz, 'kHz'); + } + audioWaterfallAnimId = requestAnimationFrame(drawFrame); + }; + + audioWaterfallAnimId = requestAnimationFrame(drawFrame); +} + +function stopAudioWaterfall() { + if (audioWaterfallAnimId) { + cancelAnimationFrame(audioWaterfallAnimId); + audioWaterfallAnimId = null; + } + if (waterfallMode === 'audio') { + waterfallMode = 'rf'; + } +} + +function dBmToRgb(normalized) { + // Viridis-inspired: dark blue -> cyan -> green -> yellow + const n = Math.max(0, Math.min(1, normalized)); + let r, g, b; + if (n < 0.25) { + const t = n / 0.25; + r = Math.round(20 + t * 20); + g = Math.round(10 + t * 60); + b = Math.round(80 + t * 100); + } else if (n < 0.5) { + const t = (n - 0.25) / 0.25; + r = Math.round(40 - t * 20); + g = Math.round(70 + t * 130); + b = Math.round(180 - t * 30); + } else if (n < 0.75) { + const t = (n - 0.5) / 0.25; + r = Math.round(20 + t * 180); + g = Math.round(200 + t * 55); + b = Math.round(150 - t * 130); + } else { + const t = (n - 0.75) / 0.25; + r = Math.round(200 + t * 55); + g = Math.round(255 - t * 55); + b = Math.round(20 - t * 20); + } + return [r, g, b]; +} + +function buildWaterfallPalette() { + const palette = new Array(256); + for (let i = 0; i < 256; i++) { + palette[i] = dBmToRgb(i / 255); + } + return palette; +} + +function drawWaterfallRow(bins) { + if (!waterfallCtx || !waterfallCanvas) return; + const w = waterfallCanvas.width; + const h = waterfallCanvas.height; + const rowHeight = waterfallRowImage ? waterfallRowImage.height : 1; + + // Scroll existing content down by 1 pixel (GPU-accelerated) + waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - rowHeight, 0, rowHeight, w, h - rowHeight); + + // Find min/max for normalization + let minVal = Infinity, maxVal = -Infinity; + for (let i = 0; i < bins.length; i++) { + if (bins[i] < minVal) minVal = bins[i]; + if (bins[i] > maxVal) maxVal = bins[i]; + } + const range = maxVal - minVal || 1; + + // Draw new row at top using ImageData + if (!waterfallRowImage || waterfallRowImage.width !== w || waterfallRowImage.height !== rowHeight) { + waterfallRowImage = waterfallCtx.createImageData(w, rowHeight); + } + const rowData = waterfallRowImage.data; + const palette = waterfallPalette || buildWaterfallPalette(); + const binCount = bins.length; + for (let x = 0; x < w; x++) { + const pos = (x / (w - 1)) * (binCount - 1); + const i0 = Math.floor(pos); + const i1 = Math.min(binCount - 1, i0 + 1); + const t = pos - i0; + const val = (bins[i0] * (1 - t)) + (bins[i1] * t); + const normalized = (val - minVal) / range; + const color = palette[Math.max(0, Math.min(255, Math.floor(normalized * 255)))] || [0, 0, 0]; + for (let y = 0; y < rowHeight; y++) { + const offset = (y * w + x) * 4; + rowData[offset] = color[0]; + rowData[offset + 1] = color[1]; + rowData[offset + 2] = color[2]; + rowData[offset + 3] = 255; + } + } + waterfallCtx.putImageData(waterfallRowImage, 0, 0); +} + +function drawSpectrumLine(bins, startFreq, endFreq, labelUnit) { + if (!spectrumCtx || !spectrumCanvas) return; + const w = spectrumCanvas.width; + const h = spectrumCanvas.height; + + spectrumCtx.clearRect(0, 0, w, h); + + // Background + spectrumCtx.fillStyle = 'rgba(0, 0, 0, 0.8)'; + spectrumCtx.fillRect(0, 0, w, h); + + // Grid lines + spectrumCtx.strokeStyle = 'rgba(0, 200, 255, 0.1)'; + spectrumCtx.lineWidth = 0.5; + for (let i = 0; i < 5; i++) { + const y = (h / 5) * i; + spectrumCtx.beginPath(); + spectrumCtx.moveTo(0, y); + spectrumCtx.lineTo(w, y); + spectrumCtx.stroke(); + } + + // Frequency labels + const dpr = window.devicePixelRatio || 1; + spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)'; + spectrumCtx.font = `${9 * dpr}px monospace`; + const freqRange = endFreq - startFreq; + for (let i = 0; i <= 4; i++) { + const freq = startFreq + (freqRange / 4) * i; + const x = (w / 4) * i; + const label = labelUnit === 'kHz' ? freq.toFixed(0) : freq.toFixed(1); + spectrumCtx.fillText(label, x + 2, h - 2); + } + + if (bins.length === 0) return; + + // Find min/max for scaling + let minVal = Infinity, maxVal = -Infinity; + for (let i = 0; i < bins.length; i++) { + if (bins[i] < minVal) minVal = bins[i]; + if (bins[i] > maxVal) maxVal = bins[i]; + } + const range = maxVal - minVal || 1; + + // Draw spectrum line + spectrumCtx.strokeStyle = 'rgba(0, 255, 255, 0.9)'; + spectrumCtx.lineWidth = 1.5; + spectrumCtx.beginPath(); + for (let i = 0; i < bins.length; i++) { + const x = (i / (bins.length - 1)) * w; + const normalized = (bins[i] - minVal) / range; + const y = h - 12 - normalized * (h - 16); + if (i === 0) spectrumCtx.moveTo(x, y); + else spectrumCtx.lineTo(x, y); + } + spectrumCtx.stroke(); + + // Fill under line + const lastX = w; + const lastY = h - 12 - ((bins[bins.length - 1] - minVal) / range) * (h - 16); + spectrumCtx.lineTo(lastX, h); + spectrumCtx.lineTo(0, h); + spectrumCtx.closePath(); + spectrumCtx.fillStyle = 'rgba(0, 255, 255, 0.08)'; + spectrumCtx.fill(); +} + +function startWaterfall() { + const startFreq = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88); + const endFreq = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108); + const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000); + const gain = parseInt(document.getElementById('waterfallGain')?.value || 40); + const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; + initWaterfallCanvas(); + const maxBins = Math.min(4096, Math.max(128, waterfallCanvas ? waterfallCanvas.width : 800)); + + if (startFreq >= endFreq) { + if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start'); + return; + } + + waterfallStartFreq = startFreq; + waterfallEndFreq = endFreq; + const rangeLabel = document.getElementById('waterfallFreqRange'); + if (rangeLabel) { + rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`; + } + + if (isDirectListening) { + isWaterfallRunning = true; + const waterfallPanel = document.getElementById('waterfallPanel'); + if (waterfallPanel) waterfallPanel.style.display = 'block'; + document.getElementById('startWaterfallBtn').style.display = 'none'; + document.getElementById('stopWaterfallBtn').style.display = 'block'; + startAudioWaterfall(); + return; + } + + setWaterfallMode('rf'); + const spanMhz = Math.max(0.1, waterfallEndFreq - waterfallStartFreq); + const segments = Math.max(1, Math.ceil(spanMhz / 2.4)); + const targetSweepSeconds = 0.8; + const interval = Math.max(0.1, Math.min(0.3, targetSweepSeconds / segments)); + + fetch('/listening/waterfall/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + start_freq: startFreq, + end_freq: endFreq, + bin_size: binSize, + gain: gain, + device: device, + max_bins: maxBins, + interval: interval, + }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + isWaterfallRunning = true; + document.getElementById('startWaterfallBtn').style.display = 'none'; + document.getElementById('stopWaterfallBtn').style.display = 'block'; + const waterfallPanel = document.getElementById('waterfallPanel'); + if (waterfallPanel) waterfallPanel.style.display = 'block'; + lastWaterfallDraw = 0; + initWaterfallCanvas(); + connectWaterfallSSE(); + } else { + if (typeof showNotification === 'function') showNotification('Error', data.message || 'Failed to start waterfall'); + } + }) + .catch(err => console.error('[WATERFALL] Start error:', err)); +} + +async function stopWaterfall() { + if (waterfallMode === 'audio') { + stopAudioWaterfall(); + isWaterfallRunning = false; + document.getElementById('startWaterfallBtn').style.display = 'block'; + document.getElementById('stopWaterfallBtn').style.display = 'none'; + return; + } + + try { + await fetch('/listening/waterfall/stop', { method: 'POST' }); + isWaterfallRunning = false; + if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; } + document.getElementById('startWaterfallBtn').style.display = 'block'; + document.getElementById('stopWaterfallBtn').style.display = 'none'; + } catch (err) { + console.error('[WATERFALL] Stop error:', err); + } +} + +function connectWaterfallSSE() { + if (waterfallEventSource) waterfallEventSource.close(); + waterfallEventSource = new EventSource('/listening/waterfall/stream'); + waterfallMode = 'rf'; + + waterfallEventSource.onmessage = function(event) { + const msg = JSON.parse(event.data); + if (msg.type === 'waterfall_sweep') { + if (typeof msg.start_freq === 'number') waterfallStartFreq = msg.start_freq; + if (typeof msg.end_freq === 'number') waterfallEndFreq = msg.end_freq; + const rangeLabel = document.getElementById('waterfallFreqRange'); + if (rangeLabel) { + rangeLabel.textContent = `${waterfallStartFreq.toFixed(1)} - ${waterfallEndFreq.toFixed(1)} MHz`; + } + const now = Date.now(); + if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return; + lastWaterfallDraw = now; + drawWaterfallRow(msg.bins); + drawSpectrumLine(msg.bins, msg.start_freq, msg.end_freq); + } + }; + + waterfallEventSource.onerror = function() { + if (isWaterfallRunning) { + setTimeout(connectWaterfallSSE, 2000); + } + }; +} + +function bindWaterfallInteraction() { + const handler = (event) => { + if (waterfallMode === 'audio') { + return; + } + const canvas = event.currentTarget; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const ratio = Math.max(0, Math.min(1, x / rect.width)); + const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq); + if (typeof tuneToFrequency === 'function') { + tuneToFrequency(freq, typeof currentModulation !== 'undefined' ? currentModulation : undefined); + } + }; + + if (waterfallCanvas) { + waterfallCanvas.style.cursor = 'crosshair'; + waterfallCanvas.addEventListener('click', handler); + } + if (spectrumCanvas) { + spectrumCanvas.style.cursor = 'crosshair'; + spectrumCanvas.addEventListener('click', handler); + } +} + + window.stopDirectListen = stopDirectListen; window.toggleScanner = toggleScanner; window.startScanner = startScanner; @@ -2953,3 +3493,7 @@ window.removeBookmark = removeBookmark; window.tuneToFrequency = tuneToFrequency; window.clearScannerLog = clearScannerLog; window.exportScannerLog = exportScannerLog; +window.manualSignalGuess = manualSignalGuess; +window.guessSignal = guessSignal; +window.startWaterfall = startWaterfall; +window.stopWaterfall = stopWaterfall; diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js new file mode 100644 index 0000000..0b89efe --- /dev/null +++ b/static/js/modes/sstv-general.js @@ -0,0 +1,601 @@ +/** + * SSTV General Mode + * Terrestrial Slow-Scan Television decoder interface + */ + +const SSTVGeneral = (function() { + // State + let isRunning = false; + let eventSource = null; + let images = []; + let currentMode = null; + let progress = 0; + + /** + * Initialize the SSTV General mode + */ + function init() { + checkStatus(); + loadImages(); + } + + /** + * Select a preset frequency from the dropdown + */ + function selectPreset(value) { + if (!value) return; + + const parts = value.split('|'); + const freq = parseFloat(parts[0]); + const mod = parts[1]; + + const freqInput = document.getElementById('sstvGeneralFrequency'); + const modSelect = document.getElementById('sstvGeneralModulation'); + + if (freqInput) freqInput.value = freq; + if (modSelect) modSelect.value = mod; + + // Update strip display + const stripFreq = document.getElementById('sstvGeneralStripFreq'); + const stripMod = document.getElementById('sstvGeneralStripMod'); + if (stripFreq) stripFreq.textContent = freq.toFixed(3); + if (stripMod) stripMod.textContent = mod.toUpperCase(); + } + + /** + * Check current decoder status + */ + async function checkStatus() { + try { + const response = await fetch('/sstv-general/status'); + const data = await response.json(); + + if (!data.available) { + updateStatusUI('unavailable', 'Decoder not installed'); + showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning'); + return; + } + + if (data.running) { + isRunning = true; + updateStatusUI('listening', 'Listening...'); + startStream(); + } else { + updateStatusUI('idle', 'Idle'); + } + + updateImageCount(data.image_count || 0); + } catch (err) { + console.error('Failed to check SSTV General status:', err); + } + } + + /** + * Start SSTV decoder + */ + async function start() { + const freqInput = document.getElementById('sstvGeneralFrequency'); + const modSelect = document.getElementById('sstvGeneralModulation'); + const deviceSelect = document.getElementById('deviceSelect'); + + const frequency = parseFloat(freqInput?.value || '14.230'); + const modulation = modSelect?.value || 'usb'; + const device = parseInt(deviceSelect?.value || '0', 10); + + updateStatusUI('connecting', 'Starting...'); + + try { + const response = await fetch('/sstv-general/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frequency, modulation, device }) + }); + + const data = await response.json(); + + if (data.status === 'started' || data.status === 'already_running') { + isRunning = true; + updateStatusUI('listening', `${frequency} MHz ${modulation.toUpperCase()}`); + startStream(); + showNotification('SSTV', `Listening on ${frequency} MHz ${modulation.toUpperCase()}`); + + // Update strip + const stripFreq = document.getElementById('sstvGeneralStripFreq'); + const stripMod = document.getElementById('sstvGeneralStripMod'); + if (stripFreq) stripFreq.textContent = frequency.toFixed(3); + if (stripMod) stripMod.textContent = modulation.toUpperCase(); + } else { + updateStatusUI('idle', 'Start failed'); + showStatusMessage(data.message || 'Failed to start decoder', 'error'); + } + } catch (err) { + console.error('Failed to start SSTV General:', err); + updateStatusUI('idle', 'Error'); + showStatusMessage('Connection error: ' + err.message, 'error'); + } + } + + /** + * Stop SSTV decoder + */ + async function stop() { + try { + await fetch('/sstv-general/stop', { method: 'POST' }); + isRunning = false; + stopStream(); + updateStatusUI('idle', 'Stopped'); + showNotification('SSTV', 'Decoder stopped'); + } catch (err) { + console.error('Failed to stop SSTV General:', err); + } + } + + /** + * Update status UI elements + */ + function updateStatusUI(status, text) { + const dot = document.getElementById('sstvGeneralStripDot'); + const statusText = document.getElementById('sstvGeneralStripStatus'); + const startBtn = document.getElementById('sstvGeneralStartBtn'); + const stopBtn = document.getElementById('sstvGeneralStopBtn'); + + if (dot) { + dot.className = 'sstv-general-strip-dot'; + if (status === 'listening' || status === 'detecting') { + dot.classList.add('listening'); + } else if (status === 'decoding') { + dot.classList.add('decoding'); + } else { + dot.classList.add('idle'); + } + } + + if (statusText) { + statusText.textContent = text || status; + } + + if (startBtn && stopBtn) { + if (status === 'listening' || status === 'decoding') { + startBtn.style.display = 'none'; + stopBtn.style.display = 'inline-block'; + } else { + startBtn.style.display = 'inline-block'; + stopBtn.style.display = 'none'; + } + } + + // Update live content area + const liveContent = document.getElementById('sstvGeneralLiveContent'); + if (liveContent) { + if (status === 'idle' || status === 'unavailable') { + liveContent.innerHTML = renderIdleState(); + } + } + } + + /** + * Render idle state HTML + */ + function renderIdleState() { + return ` +
+ + + + + +

SSTV Decoder

+

Select a frequency and click Start to listen for SSTV transmissions

+
+ `; + } + + /** + * Start SSE stream + */ + function startStream() { + if (eventSource) { + eventSource.close(); + } + + eventSource = new EventSource('/sstv-general/stream'); + + eventSource.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + if (data.type === 'sstv_progress') { + handleProgress(data); + } + } catch (err) { + console.error('Failed to parse SSE message:', err); + } + }; + + eventSource.onerror = () => { + console.warn('SSTV General SSE error, will reconnect...'); + setTimeout(() => { + if (isRunning) startStream(); + }, 3000); + }; + } + + /** + * Stop SSE stream + */ + function stopStream() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + } + + /** + * Handle progress update + */ + function handleProgress(data) { + currentMode = data.mode || currentMode; + progress = data.progress || 0; + + if (data.status === 'decoding') { + updateStatusUI('decoding', `Decoding ${currentMode || 'image'}...`); + renderDecodeProgress(data); + } else if (data.status === 'complete' && data.image) { + images.unshift(data.image); + updateImageCount(images.length); + renderGallery(); + showNotification('SSTV', 'New image decoded!'); + updateStatusUI('listening', 'Listening...'); + // Clear decode progress so signal monitor can take over + const liveContent = document.getElementById('sstvGeneralLiveContent'); + if (liveContent) liveContent.innerHTML = ''; + } else if (data.status === 'detecting') { + // Ignore detecting events if currently decoding (e.g. Doppler updates) + const dot = document.getElementById('sstvGeneralStripDot'); + if (dot && dot.classList.contains('decoding')) return; + + updateStatusUI('listening', data.message || 'Listening...'); + if (data.signal_level !== undefined) { + renderSignalMonitor(data); + } + } + } + + /** + * Render signal monitor in live area during detecting mode + */ + function renderSignalMonitor(data) { + const container = document.getElementById('sstvGeneralLiveContent'); + if (!container) return; + + const level = data.signal_level || 0; + const tone = data.sstv_tone; + + let barColor, statusText; + if (tone === 'leader') { + barColor = 'var(--accent-green)'; + statusText = 'SSTV leader tone detected'; + } else if (tone === 'sync') { + barColor = 'var(--accent-cyan)'; + statusText = 'SSTV sync pulse detected'; + } else if (tone === 'noise') { + barColor = 'var(--text-dim)'; + statusText = 'Audio signal present'; + } else if (level > 10) { + barColor = 'var(--text-dim)'; + statusText = 'Audio signal present'; + } else { + barColor = 'var(--text-dim)'; + statusText = 'No signal'; + } + + let monitor = container.querySelector('.sstv-general-signal-monitor'); + if (!monitor) { + container.innerHTML = ` +
+
+ + + + + + Signal Monitor +
+
+ LEVEL +
+
+
+ 0 +
+
No signal
+
VIS: idle
+
`; + monitor = container.querySelector('.sstv-general-signal-monitor'); + } + + const fill = monitor.querySelector('.sstv-general-signal-bar-fill'); + fill.style.width = level + '%'; + fill.style.background = barColor; + monitor.querySelector('.sstv-general-signal-status-text').textContent = statusText; + monitor.querySelector('.sstv-general-signal-level-value').textContent = level; + + const visStateEl = monitor.querySelector('.sstv-general-signal-vis-state'); + if (visStateEl && data.vis_state) { + const stateLabels = { + 'idle': 'Idle', + 'leader_1': 'Leader', + 'break': 'Break', + 'leader_2': 'Leader 2', + 'start_bit': 'Start bit', + 'data_bits': 'Data bits', + 'parity': 'Parity', + 'stop_bit': 'Stop bit', + }; + const label = stateLabels[data.vis_state] || data.vis_state; + visStateEl.textContent = 'VIS: ' + label; + visStateEl.className = 'sstv-general-signal-vis-state' + + (data.vis_state !== 'idle' ? ' active' : ''); + } + } + + /** + * Render decode progress in live area + */ + function renderDecodeProgress(data) { + const liveContent = document.getElementById('sstvGeneralLiveContent'); + if (!liveContent) return; + + let container = liveContent.querySelector('.sstv-general-decode-container'); + if (!container) { + liveContent.innerHTML = ` +
+
+ Decoding... +
+
+
+
+
+
+
+
+
+ `; + container = liveContent.querySelector('.sstv-general-decode-container'); + } + + container.querySelector('.sstv-general-mode-label').textContent = data.mode || 'Detecting mode...'; + container.querySelector('.progress').style.width = (data.progress || 0) + '%'; + container.querySelector('.sstv-general-status-message').textContent = data.message || 'Decoding...'; + + if (data.partial_image) { + const img = container.querySelector('#sstvGeneralDecodeImg'); + if (img) img.src = data.partial_image; + } + } + + /** + * Load decoded images + */ + async function loadImages() { + try { + const response = await fetch('/sstv-general/images'); + const data = await response.json(); + + if (data.status === 'ok') { + images = data.images || []; + updateImageCount(images.length); + renderGallery(); + } + } catch (err) { + console.error('Failed to load SSTV General images:', err); + } + } + + /** + * Update image count display + */ + function updateImageCount(count) { + const countEl = document.getElementById('sstvGeneralImageCount'); + const stripCount = document.getElementById('sstvGeneralStripImageCount'); + + if (countEl) countEl.textContent = count; + if (stripCount) stripCount.textContent = count; + } + + /** + * Render image gallery + */ + function renderGallery() { + const gallery = document.getElementById('sstvGeneralGallery'); + if (!gallery) return; + + if (images.length === 0) { + gallery.innerHTML = ` + + `; + return; + } + + gallery.innerHTML = images.map(img => ` +
+
+ SSTV Image +
+
+
${escapeHtml(img.mode || 'Unknown')}
+
${formatTimestamp(img.timestamp)}
+
+
+ + +
+
+ `).join(''); + } + + /** + * Show full-size image in modal + */ + let currentModalUrl = null; + let currentModalFilename = null; + + function showImage(url, filename) { + currentModalUrl = url; + currentModalFilename = filename || null; + + let modal = document.getElementById('sstvGeneralImageModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'sstvGeneralImageModal'; + modal.className = 'sstv-general-image-modal'; + modal.innerHTML = ` +
+ + +
+ + SSTV Image + `; + modal.addEventListener('click', (e) => { + if (e.target === modal) closeImage(); + }); + modal.querySelector('#sstvGeneralModalDownload').addEventListener('click', () => { + if (currentModalUrl && currentModalFilename) { + downloadImage(currentModalUrl, currentModalFilename); + } + }); + modal.querySelector('#sstvGeneralModalDelete').addEventListener('click', () => { + if (currentModalFilename) { + deleteImage(currentModalFilename); + } + }); + document.body.appendChild(modal); + } + + modal.querySelector('img').src = url; + modal.classList.add('show'); + } + + /** + * Close image modal + */ + function closeImage() { + const modal = document.getElementById('sstvGeneralImageModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Format timestamp for display + */ + function formatTimestamp(isoString) { + if (!isoString) return '--'; + try { + const date = new Date(isoString); + return date.toLocaleString(); + } catch { + return isoString; + } + } + + /** + * Escape HTML for safe display + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + /** + * Delete a single image + */ + async function deleteImage(filename) { + if (!confirm('Delete this image?')) return; + try { + const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.status === 'ok') { + images = images.filter(img => img.filename !== filename); + updateImageCount(images.length); + renderGallery(); + closeImage(); + showNotification('SSTV', 'Image deleted'); + } + } catch (err) { + console.error('Failed to delete image:', err); + } + } + + /** + * Delete all images + */ + async function deleteAllImages() { + if (!confirm('Delete all decoded images?')) return; + try { + const response = await fetch('/sstv-general/images', { method: 'DELETE' }); + const data = await response.json(); + if (data.status === 'ok') { + images = []; + updateImageCount(0); + renderGallery(); + showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`); + } + } catch (err) { + console.error('Failed to delete images:', err); + } + } + + /** + * Download an image + */ + function downloadImage(url, filename) { + const a = document.createElement('a'); + a.href = url + '/download'; + a.download = filename; + a.click(); + } + + /** + * Show status message + */ + function showStatusMessage(message, type) { + if (typeof showNotification === 'function') { + showNotification('SSTV', message); + } else { + console.log(`[SSTV General ${type}] ${message}`); + } + } + + // Public API + return { + init, + start, + stop, + loadImages, + showImage, + closeImage, + deleteImage, + deleteAllImages, + downloadImage, + selectPreset + }; +})(); diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index fa28af2..6bafdb0 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -183,11 +183,11 @@ const SSTV = (function() { Settings.registerMap(issMap); } else { // Fallback to dark theme tiles - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - maxZoom: 19, - className: 'tile-layer-cyan' - }).addTo(issMap); - } + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + maxZoom: 19, + className: 'tile-layer-cyan' + }).addTo(issMap); + } // Create ISS icon const issIcon = L.divIcon({ @@ -491,7 +491,7 @@ const SSTV = (function() { if (!data.available) { updateStatusUI('unavailable', 'Decoder not installed'); - showStatusMessage('SSTV decoder not available. Install slowrx: apt install slowrx', 'warning'); + showStatusMessage('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 'warning'); return; } @@ -521,6 +521,11 @@ const SSTV = (function() { const frequency = parseFloat(freqInput?.value || ISS_FREQ); const device = parseInt(deviceSelect?.value || '0', 10); + // Check if device is available + if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('sstv')) { + return; + } + updateStatusUI('connecting', 'Starting...'); try { @@ -534,6 +539,9 @@ const SSTV = (function() { if (data.status === 'started' || data.status === 'already_running') { isRunning = true; + if (typeof reserveDevice === 'function') { + reserveDevice(device, 'sstv'); + } updateStatusUI('listening', `${frequency} MHz`); startStream(); showNotification('SSTV', `Listening on ${frequency} MHz`); @@ -555,6 +563,9 @@ const SSTV = (function() { try { await fetch('/sstv/stop', { method: 'POST' }); isRunning = false; + if (typeof releaseDevice === 'function') { + releaseDevice('sstv'); + } stopStream(); updateStatusUI('idle', 'Stopped'); showNotification('SSTV', 'Decoder stopped'); @@ -680,8 +691,96 @@ const SSTV = (function() { renderGallery(); showNotification('SSTV', 'New image decoded!'); updateStatusUI('listening', 'Listening...'); + // Clear decode progress so signal monitor can take over + const liveContent = document.getElementById('sstvLiveContent'); + if (liveContent) liveContent.innerHTML = ''; } else if (data.status === 'detecting') { + // Ignore detecting events if currently decoding (e.g. Doppler updates) + const dot = document.getElementById('sstvStripDot'); + if (dot && dot.classList.contains('decoding')) return; + updateStatusUI('listening', data.message || 'Listening...'); + if (data.signal_level !== undefined) { + renderSignalMonitor(data); + } + } + } + + /** + * Render signal monitor in live area during detecting mode + */ + function renderSignalMonitor(data) { + const container = document.getElementById('sstvLiveContent'); + if (!container) return; + + const level = data.signal_level || 0; + const tone = data.sstv_tone; + + let barColor, statusText; + if (tone === 'leader') { + barColor = 'var(--accent-green)'; + statusText = 'SSTV leader tone detected'; + } else if (tone === 'sync') { + barColor = 'var(--accent-cyan)'; + statusText = 'SSTV sync pulse detected'; + } else if (tone === 'noise') { + barColor = 'var(--text-dim)'; + statusText = 'Audio signal present'; + } else if (level > 10) { + barColor = 'var(--text-dim)'; + statusText = 'Audio signal present'; + } else { + barColor = 'var(--text-dim)'; + statusText = 'No signal'; + } + + let monitor = container.querySelector('.sstv-signal-monitor'); + if (!monitor) { + container.innerHTML = ` +
+
+ + + + + + Signal Monitor +
+
+ LEVEL +
+
+
+ 0 +
+
No signal
+
VIS: idle
+
`; + monitor = container.querySelector('.sstv-signal-monitor'); + } + + const fill = monitor.querySelector('.sstv-signal-bar-fill'); + fill.style.width = level + '%'; + fill.style.background = barColor; + monitor.querySelector('.sstv-signal-status-text').textContent = statusText; + monitor.querySelector('.sstv-signal-level-value').textContent = level; + + const visStateEl = monitor.querySelector('.sstv-signal-vis-state'); + if (visStateEl && data.vis_state) { + const stateLabels = { + 'idle': 'Idle', + 'leader_1': 'Leader', + 'break': 'Break', + 'leader_2': 'Leader 2', + 'start_bit': 'Start bit', + 'data_bits': 'Data bits', + 'parity': 'Parity', + 'stop_bit': 'Stop bit', + }; + const label = stateLabels[data.vis_state] || data.vis_state; + visStateEl.textContent = 'VIS: ' + label; + visStateEl.className = 'sstv-signal-vis-state' + + (data.vis_state !== 'idle' ? ' active' : ''); } } @@ -692,18 +791,33 @@ const SSTV = (function() { const liveContent = document.getElementById('sstvLiveContent'); if (!liveContent) return; - liveContent.innerHTML = ` -
- -
-
-
${data.mode || 'Detecting mode...'}
-
-
+ let container = liveContent.querySelector('.sstv-decode-container'); + if (!container) { + liveContent.innerHTML = ` +
+
+ Decoding... +
+
+
+
+
+
+
+
-
${data.message || 'Decoding...'}
-
- `; + `; + container = liveContent.querySelector('.sstv-decode-container'); + } + + container.querySelector('.sstv-mode-label').textContent = data.mode || 'Detecting mode...'; + container.querySelector('.progress').style.width = (data.progress || 0) + '%'; + container.querySelector('.sstv-status-message').textContent = data.message || 'Decoding...'; + + if (data.partial_image) { + const img = container.querySelector('#sstvDecodeImg'); + if (img) img.src = data.partial_image; + } } /** @@ -757,12 +871,22 @@ const SSTV = (function() { } gallery.innerHTML = images.map(img => ` -
- SSTV Image +
+
+ SSTV Image +
${escapeHtml(img.mode || 'Unknown')}
${formatTimestamp(img.timestamp)}
+
+ + +
`).join(''); } @@ -894,19 +1018,45 @@ const SSTV = (function() { /** * Show full-size image in modal */ - function showImage(url) { + let currentModalUrl = null; + let currentModalFilename = null; + + function showImage(url, filename) { + currentModalUrl = url; + currentModalFilename = filename || null; + let modal = document.getElementById('sstvImageModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'sstvImageModal'; modal.className = 'sstv-image-modal'; modal.innerHTML = ` +
+ + +
SSTV Image `; modal.addEventListener('click', (e) => { if (e.target === modal) closeImage(); }); + modal.querySelector('#sstvModalDownload').addEventListener('click', () => { + if (currentModalUrl && currentModalFilename) { + downloadImage(currentModalUrl, currentModalFilename); + } + }); + modal.querySelector('#sstvModalDelete').addEventListener('click', () => { + if (currentModalFilename) { + deleteImage(currentModalFilename); + } + }); document.body.appendChild(modal); } @@ -945,6 +1095,55 @@ const SSTV = (function() { return div.innerHTML; } + /** + * Delete a single image + */ + async function deleteImage(filename) { + if (!confirm('Delete this image?')) return; + try { + const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.status === 'ok') { + images = images.filter(img => img.filename !== filename); + updateImageCount(images.length); + renderGallery(); + closeImage(); + showNotification('SSTV', 'Image deleted'); + } + } catch (err) { + console.error('Failed to delete image:', err); + } + } + + /** + * Delete all images + */ + async function deleteAllImages() { + if (!confirm('Delete all decoded images?')) return; + try { + const response = await fetch('/sstv/images', { method: 'DELETE' }); + const data = await response.json(); + if (data.status === 'ok') { + images = []; + updateImageCount(0); + renderGallery(); + showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`); + } + } catch (err) { + console.error('Failed to delete images:', err); + } + } + + /** + * Download an image + */ + function downloadImage(url, filename) { + const a = document.createElement('a'); + a.href = url + '/download'; + a.download = filename; + a.click(); + } + /** * Show status message */ @@ -965,6 +1164,9 @@ const SSTV = (function() { loadIssSchedule, showImage, closeImage, + deleteImage, + deleteAllImages, + downloadImage, useGPS, updateTLE, stopIssTracking, diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js new file mode 100644 index 0000000..d265ea3 --- /dev/null +++ b/static/js/modes/websdr.js @@ -0,0 +1,581 @@ +/** + * Intercept - WebSDR Mode + * HF/Shortwave KiwiSDR Network Integration with In-App Audio + */ + +// ============== STATE ============== +let websdrMap = null; +let websdrMarkers = []; +let websdrReceivers = []; +let websdrInitialized = false; +let websdrSpyStationsLoaded = false; + +// KiwiSDR audio state +let kiwiWebSocket = null; +let kiwiAudioContext = null; +let kiwiScriptProcessor = null; +let kiwiGainNode = null; +let kiwiAudioBuffer = []; +let kiwiConnected = false; +let kiwiCurrentFreq = 0; +let kiwiCurrentMode = 'am'; +let kiwiSmeter = 0; +let kiwiSmeterInterval = null; +let kiwiReceiverName = ''; + +const KIWI_SAMPLE_RATE = 12000; + +// ============== INITIALIZATION ============== + +function initWebSDR() { + if (websdrInitialized) { + if (websdrMap) { + setTimeout(() => websdrMap.invalidateSize(), 100); + } + return; + } + + const mapEl = document.getElementById('websdrMap'); + if (!mapEl || typeof L === 'undefined') return; + + // Calculate minimum zoom so tiles fill the container vertically + const mapHeight = mapEl.clientHeight || 500; + const minZoom = Math.ceil(Math.log2(mapHeight / 256)); + + websdrMap = L.map('websdrMap', { + center: [20, 0], + zoom: Math.max(minZoom, 2), + minZoom: Math.max(minZoom, 2), + zoomControl: true, + maxBounds: [[-85, -360], [85, 360]], + maxBoundsViscosity: 1.0, + }); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 19, + }).addTo(websdrMap); + + // Match background to tile ocean color so any remaining edge is seamless + mapEl.style.background = '#1a1d29'; + + websdrInitialized = true; + + if (!websdrSpyStationsLoaded) { + loadSpyStationPresets(); + } + + [100, 300, 600, 1000].forEach(delay => { + setTimeout(() => { + if (websdrMap) websdrMap.invalidateSize(); + }, delay); + }); +} + +// ============== RECEIVER SEARCH ============== + +function searchReceivers(refresh) { + const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 0); + + let url = '/websdr/receivers?available=true'; + if (freqKhz > 0) url += `&freq_khz=${freqKhz}`; + if (refresh) url += '&refresh=true'; + + fetch(url) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + websdrReceivers = data.receivers || []; + renderReceiverList(websdrReceivers); + plotReceiversOnMap(websdrReceivers); + + const countEl = document.getElementById('websdrReceiverCount'); + if (countEl) countEl.textContent = `${websdrReceivers.length} found`; + } + }) + .catch(err => console.error('[WEBSDR] Search error:', err)); +} + +// ============== MAP ============== + +function plotReceiversOnMap(receivers) { + if (!websdrMap) return; + + websdrMarkers.forEach(m => websdrMap.removeLayer(m)); + websdrMarkers = []; + + receivers.forEach((rx, idx) => { + if (rx.lat == null || rx.lon == null) return; + + const marker = L.circleMarker([rx.lat, rx.lon], { + radius: 6, + fillColor: rx.available ? '#00d4ff' : '#666', + color: rx.available ? '#00d4ff' : '#666', + weight: 1, + opacity: 0.8, + fillOpacity: 0.6, + }); + + marker.bindPopup(` +
+ ${escapeHtmlWebsdr(rx.name)}
+ ${rx.location ? `${escapeHtmlWebsdr(rx.location)}
` : ''} + Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}
+ Users: ${rx.users}/${rx.users_max}
+ +
+ `); + + marker.addTo(websdrMap); + websdrMarkers.push(marker); + }); + + if (websdrMarkers.length > 0) { + const group = L.featureGroup(websdrMarkers); + websdrMap.fitBounds(group.getBounds(), { padding: [30, 30] }); + } +} + +// ============== RECEIVER LIST ============== + +function renderReceiverList(receivers) { + const container = document.getElementById('websdrReceiverList'); + if (!container) return; + + if (receivers.length === 0) { + container.innerHTML = '
No receivers found
'; + return; + } + + container.innerHTML = receivers.slice(0, 50).map((rx, idx) => ` +
+
+ ${escapeHtmlWebsdr(rx.name)} + ${rx.users}/${rx.users_max} +
+
+ ${rx.location ? escapeHtmlWebsdr(rx.location) + ' · ' : ''}${escapeHtmlWebsdr(rx.antenna || '')} + ${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''} +
+
+ `).join(''); +} + +// ============== SELECT RECEIVER ============== + +function selectReceiver(index) { + const rx = websdrReceivers[index]; + if (!rx) return; + + const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000); + const mode = document.getElementById('websdrMode_select')?.value || 'am'; + + kiwiReceiverName = rx.name; + + // Connect via backend proxy + connectToReceiver(rx.url, freqKhz, mode); + + // Highlight on map + if (websdrMap && rx.lat != null && rx.lon != null) { + websdrMap.setView([rx.lat, rx.lon], 6); + } +} + +// ============== KIWISDR AUDIO CONNECTION ============== + +function connectToReceiver(receiverUrl, freqKhz, mode) { + // Disconnect if already connected + if (kiwiWebSocket) { + disconnectFromReceiver(); + } + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/kiwi-audio`; + + kiwiWebSocket = new WebSocket(wsUrl); + kiwiWebSocket.binaryType = 'arraybuffer'; + + kiwiWebSocket.onopen = () => { + kiwiWebSocket.send(JSON.stringify({ + cmd: 'connect', + url: receiverUrl, + freq_khz: freqKhz, + mode: mode, + })); + updateKiwiUI('connecting'); + }; + + kiwiWebSocket.onmessage = (event) => { + if (typeof event.data === 'string') { + const msg = JSON.parse(event.data); + handleKiwiStatus(msg); + } else { + handleKiwiAudio(event.data); + } + }; + + kiwiWebSocket.onclose = () => { + kiwiConnected = false; + updateKiwiUI('disconnected'); + }; + + kiwiWebSocket.onerror = () => { + updateKiwiUI('disconnected'); + }; +} + +function handleKiwiStatus(msg) { + switch (msg.type) { + case 'connected': + kiwiConnected = true; + kiwiCurrentFreq = msg.freq_khz; + kiwiCurrentMode = msg.mode; + initKiwiAudioContext(msg.sample_rate || KIWI_SAMPLE_RATE); + updateKiwiUI('connected'); + break; + case 'tuned': + kiwiCurrentFreq = msg.freq_khz; + kiwiCurrentMode = msg.mode; + updateKiwiUI('connected'); + break; + case 'error': + console.error('[KIWI] Error:', msg.message); + if (typeof showNotification === 'function') { + showNotification('WebSDR', msg.message); + } + updateKiwiUI('error'); + break; + case 'disconnected': + kiwiConnected = false; + cleanupKiwiAudio(); + updateKiwiUI('disconnected'); + break; + } +} + +function handleKiwiAudio(arrayBuffer) { + if (arrayBuffer.byteLength < 4) return; + + // First 2 bytes: S-meter (big-endian int16) + const view = new DataView(arrayBuffer); + kiwiSmeter = view.getInt16(0, false); + + // Remaining bytes: PCM 16-bit signed LE + const pcmData = new Int16Array(arrayBuffer, 2); + + // Convert to float32 [-1, 1] for Web Audio API + const float32 = new Float32Array(pcmData.length); + for (let i = 0; i < pcmData.length; i++) { + float32[i] = pcmData[i] / 32768.0; + } + + // Add to playback buffer (limit buffer size to ~2s) + kiwiAudioBuffer.push(float32); + const maxChunks = Math.ceil((KIWI_SAMPLE_RATE * 2) / 512); + while (kiwiAudioBuffer.length > maxChunks) { + kiwiAudioBuffer.shift(); + } +} + +function initKiwiAudioContext(sampleRate) { + cleanupKiwiAudio(); + + kiwiAudioContext = new (window.AudioContext || window.webkitAudioContext)({ + sampleRate: sampleRate, + }); + + // Resume if suspended (autoplay policy) + if (kiwiAudioContext.state === 'suspended') { + kiwiAudioContext.resume(); + } + + // ScriptProcessorNode: pulls audio from buffer + kiwiScriptProcessor = kiwiAudioContext.createScriptProcessor(2048, 0, 1); + kiwiScriptProcessor.onaudioprocess = (e) => { + const output = e.outputBuffer.getChannelData(0); + let offset = 0; + + while (offset < output.length && kiwiAudioBuffer.length > 0) { + const chunk = kiwiAudioBuffer[0]; + const needed = output.length - offset; + const available = chunk.length; + + if (available <= needed) { + output.set(chunk, offset); + offset += available; + kiwiAudioBuffer.shift(); + } else { + output.set(chunk.subarray(0, needed), offset); + kiwiAudioBuffer[0] = chunk.subarray(needed); + offset += needed; + } + } + + // Fill remaining with silence + while (offset < output.length) { + output[offset++] = 0; + } + }; + + // Volume control + kiwiGainNode = kiwiAudioContext.createGain(); + const savedVol = localStorage.getItem('kiwiVolume'); + kiwiGainNode.gain.value = savedVol !== null ? parseFloat(savedVol) / 100 : 0.8; + const volValue = Math.round(kiwiGainNode.gain.value * 100); + ['kiwiVolume', 'kiwiBarVolume'].forEach(id => { + const el = document.getElementById(id); + if (el) el.value = volValue; + }); + + kiwiScriptProcessor.connect(kiwiGainNode); + kiwiGainNode.connect(kiwiAudioContext.destination); + + // S-meter display updates + if (kiwiSmeterInterval) clearInterval(kiwiSmeterInterval); + kiwiSmeterInterval = setInterval(updateSmeterDisplay, 200); +} + +function disconnectFromReceiver() { + if (kiwiWebSocket && kiwiWebSocket.readyState === WebSocket.OPEN) { + kiwiWebSocket.send(JSON.stringify({ cmd: 'disconnect' })); + } + cleanupKiwiAudio(); + if (kiwiWebSocket) { + kiwiWebSocket.close(); + kiwiWebSocket = null; + } + kiwiConnected = false; + kiwiReceiverName = ''; + updateKiwiUI('disconnected'); +} + +function cleanupKiwiAudio() { + if (kiwiSmeterInterval) { + clearInterval(kiwiSmeterInterval); + kiwiSmeterInterval = null; + } + if (kiwiScriptProcessor) { + kiwiScriptProcessor.disconnect(); + kiwiScriptProcessor = null; + } + if (kiwiGainNode) { + kiwiGainNode.disconnect(); + kiwiGainNode = null; + } + if (kiwiAudioContext) { + kiwiAudioContext.close().catch(() => {}); + kiwiAudioContext = null; + } + kiwiAudioBuffer = []; + kiwiSmeter = 0; +} + +function tuneKiwi(freqKhz, mode) { + if (!kiwiWebSocket || !kiwiConnected) return; + kiwiWebSocket.send(JSON.stringify({ + cmd: 'tune', + freq_khz: freqKhz, + mode: mode || kiwiCurrentMode, + })); +} + +function tuneFromBar() { + const freq = parseFloat(document.getElementById('kiwiBarFrequency')?.value || 0); + const mode = document.getElementById('kiwiBarMode')?.value || kiwiCurrentMode; + if (freq > 0) { + tuneKiwi(freq, mode); + // Also update sidebar frequency + const freqInput = document.getElementById('websdrFrequency'); + if (freqInput) freqInput.value = freq; + } +} + +function setKiwiVolume(value) { + if (kiwiGainNode) { + kiwiGainNode.gain.value = value / 100; + localStorage.setItem('kiwiVolume', value); + } + // Sync both volume sliders + ['kiwiVolume', 'kiwiBarVolume'].forEach(id => { + const el = document.getElementById(id); + if (el && el.value !== String(value)) el.value = value; + }); +} + +// ============== S-METER ============== + +function updateSmeterDisplay() { + // KiwiSDR S-meter: value in 0.1 dBm units (e.g., -730 = -73 dBm = S9) + const dbm = kiwiSmeter / 10; + let sUnit; + if (dbm >= -73) { + const over = Math.round((dbm + 73)); + sUnit = over > 0 ? `S9+${over}` : 'S9'; + } else { + sUnit = `S${Math.max(0, Math.round((dbm + 127) / 6))}`; + } + + const pct = Math.min(100, Math.max(0, (dbm + 127) / 1.27)); + + // Update both sidebar and bar S-meter displays + ['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.width = pct + '%'; + }); + ['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => { + const el = document.getElementById(id); + if (el) el.textContent = sUnit; + }); +} + +// ============== UI UPDATES ============== + +function updateKiwiUI(state) { + const statusEl = document.getElementById('kiwiStatus'); + const controlsBar = document.getElementById('kiwiAudioControls'); + const disconnectBtn = document.getElementById('kiwiDisconnectBtn'); + const receiverNameEl = document.getElementById('kiwiReceiverName'); + const freqDisplay = document.getElementById('kiwiFreqDisplay'); + const barReceiverName = document.getElementById('kiwiBarReceiverName'); + const barFreq = document.getElementById('kiwiBarFrequency'); + const barMode = document.getElementById('kiwiBarMode'); + + if (state === 'connected') { + if (statusEl) { + statusEl.textContent = 'CONNECTED'; + statusEl.style.color = 'var(--accent-green)'; + } + if (controlsBar) controlsBar.style.display = 'block'; + if (disconnectBtn) disconnectBtn.style.display = 'block'; + if (receiverNameEl) { + receiverNameEl.textContent = kiwiReceiverName; + receiverNameEl.style.display = 'block'; + } + if (freqDisplay) freqDisplay.textContent = kiwiCurrentFreq + ' kHz'; + if (barReceiverName) barReceiverName.textContent = kiwiReceiverName; + if (barFreq) barFreq.value = kiwiCurrentFreq; + if (barMode) barMode.value = kiwiCurrentMode; + } else if (state === 'connecting') { + if (statusEl) { + statusEl.textContent = 'CONNECTING...'; + statusEl.style.color = 'var(--accent-orange)'; + } + } else if (state === 'error') { + if (statusEl) { + statusEl.textContent = 'ERROR'; + statusEl.style.color = 'var(--accent-red)'; + } + } else { + // disconnected + if (statusEl) { + statusEl.textContent = 'DISCONNECTED'; + statusEl.style.color = 'var(--text-muted)'; + } + if (controlsBar) controlsBar.style.display = 'none'; + if (disconnectBtn) disconnectBtn.style.display = 'none'; + if (receiverNameEl) receiverNameEl.style.display = 'none'; + if (freqDisplay) freqDisplay.textContent = '--- kHz'; + // Reset both S-meter displays (sidebar + bar) + ['kiwiSmeterBar', 'kiwiBarSmeter'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.width = '0%'; + }); + ['kiwiSmeterValue', 'kiwiBarSmeterValue'].forEach(id => { + const el = document.getElementById(id); + if (el) el.textContent = 'S0'; + }); + } +} + +// ============== SPY STATION PRESETS ============== + +function loadSpyStationPresets() { + fetch('/spy-stations/stations') + .then(r => r.json()) + .then(data => { + websdrSpyStationsLoaded = true; + const container = document.getElementById('websdrSpyPresets'); + if (!container) return; + + const stations = data.stations || data || []; + if (!Array.isArray(stations) || stations.length === 0) { + container.innerHTML = '
No stations available
'; + return; + } + + container.innerHTML = stations.slice(0, 30).map(s => { + const primaryFreq = s.frequencies?.find(f => f.primary) || s.frequencies?.[0]; + const freqKhz = primaryFreq?.freq_khz || 0; + return ` +
+
+ ${escapeHtmlWebsdr(s.name)} + ${escapeHtmlWebsdr(s.nickname || '')} +
+ ${freqKhz} kHz +
+ `; + }).join(''); + }) + .catch(err => { + console.error('[WEBSDR] Failed to load spy station presets:', err); + }); +} + +function tuneToSpyStation(stationId, freqKhz) { + const freqInput = document.getElementById('websdrFrequency'); + if (freqInput) freqInput.value = freqKhz; + + // If already connected, just retune + if (kiwiConnected) { + const mode = document.getElementById('websdrMode_select')?.value || kiwiCurrentMode; + tuneKiwi(freqKhz, mode); + return; + } + + // Otherwise, search for receivers at this frequency + fetch(`/websdr/spy-station/${encodeURIComponent(stationId)}/receivers`) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + websdrReceivers = data.receivers || []; + renderReceiverList(websdrReceivers); + plotReceiversOnMap(websdrReceivers); + + const countEl = document.getElementById('websdrReceiverCount'); + if (countEl) countEl.textContent = `${websdrReceivers.length} for ${data.station?.name || stationId}`; + + if (typeof showNotification === 'function' && data.station) { + showNotification('WebSDR', `Found ${websdrReceivers.length} receivers for ${data.station.name} at ${freqKhz} kHz`); + } + } + }) + .catch(err => console.error('[WEBSDR] Spy station receivers error:', err)); +} + +// ============== UTILITIES ============== + +function escapeHtmlWebsdr(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// ============== EXPORTS ============== + +window.initWebSDR = initWebSDR; +window.searchReceivers = searchReceivers; +window.selectReceiver = selectReceiver; +window.tuneToSpyStation = tuneToSpyStation; +window.loadSpyStationPresets = loadSpyStationPresets; +window.connectToReceiver = connectToReceiver; +window.disconnectFromReceiver = disconnectFromReceiver; +window.tuneKiwi = tuneKiwi; +window.tuneFromBar = tuneFromBar; +window.setKiwiVolume = setKiwiVolume; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 026cfa8..53cf450 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -28,9 +28,9 @@ const WiFiMode = (function() { maxProbes: 1000, }; - // ========================================================================== - // Agent Support - // ========================================================================== + // ========================================================================== + // Agent Support + // ========================================================================== /** * Get the API base URL, routing through agent proxy if agent is selected. @@ -59,15 +59,49 @@ const WiFiMode = (function() { /** * Check for agent mode conflicts before starting WiFi scan. */ - function checkAgentConflicts() { - if (typeof currentAgent === 'undefined' || currentAgent === 'local') { - return true; - } - if (typeof checkAgentModeConflict === 'function') { - return checkAgentModeConflict('wifi'); - } - return true; - } + function checkAgentConflicts() { + if (typeof currentAgent === 'undefined' || currentAgent === 'local') { + return true; + } + if (typeof checkAgentModeConflict === 'function') { + return checkAgentModeConflict('wifi'); + } + return true; + } + + function getChannelPresetList(preset) { + switch (preset) { + case '2.4-common': + return '1,6,11'; + case '2.4-all': + return '1,2,3,4,5,6,7,8,9,10,11,12,13'; + case '5-low': + return '36,40,44,48'; + case '5-mid': + return '52,56,60,64'; + case '5-high': + return '149,153,157,161,165'; + default: + return ''; + } + } + + function buildChannelConfig() { + const preset = document.getElementById('wifiChannelPreset')?.value || ''; + const listInput = document.getElementById('wifiChannelList')?.value || ''; + const singleInput = document.getElementById('wifiChannel')?.value || ''; + + const listValue = listInput.trim(); + const presetValue = getChannelPresetList(preset); + + const channels = listValue || presetValue || ''; + const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); + + return { + channels: channels || null, + channel: Number.isFinite(channel) ? channel : null, + }; + } // ========================================================================== // State @@ -461,10 +495,10 @@ const WiFiMode = (function() { setScanning(true, 'deep'); try { - const iface = elements.interfaceSelect?.value || null; - const band = document.getElementById('wifiBand')?.value || 'all'; - const channel = document.getElementById('wifiChannel')?.value || null; - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const iface = elements.interfaceSelect?.value || null; + const band = document.getElementById('wifiBand')?.value || 'all'; + const channelConfig = buildChannelConfig(); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; let response; if (isAgentMode) { @@ -473,23 +507,25 @@ const WiFiMode = (function() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - interface: iface, - scan_type: 'deep', - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channel ? parseInt(channel) : null, - }), - }); - } else { - response = await fetch(`${CONFIG.apiBase}/scan/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - interface: iface, - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channel ? parseInt(channel) : null, - }), - }); - } + interface: iface, + scan_type: 'deep', + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } else { + response = await fetch(`${CONFIG.apiBase}/scan/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + interface: iface, + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } if (!response.ok) { const error = await response.json(); diff --git a/static/vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js b/static/vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js new file mode 100644 index 0000000..f9b611c --- /dev/null +++ b/static/vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js @@ -0,0 +1,124 @@ +/*! + * chartjs-adapter-date-fns v3.0.0 - Lightweight date adapter for Chart.js + * Uses native Date parsing (no external dependencies) + */ +(function() { + 'use strict'; + const FORMATS = { + datetime: 'MMM d, yyyy, h:mm:ss a', + millisecond: 'h:mm:ss.SSS a', + second: 'h:mm:ss a', + minute: 'h:mm a', + hour: 'ha', + day: 'MMM d', + week: 'PP', + month: 'MMM yyyy', + quarter: "'Q'Q - yyyy", + year: 'yyyy' + }; + + function formatDate(date, fmt) { + const d = new Date(date); + if (isNaN(d.getTime())) return ''; + const h = d.getHours(); + const m = d.getMinutes(); + const s = d.getSeconds(); + const ms = d.getMilliseconds(); + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const ampm = h >= 12 ? 'PM' : 'AM'; + const h12 = h % 12 || 12; + + switch(fmt) { + case 'h:mm:ss.SSS a': + return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(ms).padStart(3,'0')} ${ampm}`; + case 'h:mm:ss a': + return `${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`; + case 'h:mm a': + return `${h12}:${String(m).padStart(2,'0')} ${ampm}`; + case 'ha': + return `${h12}${ampm}`; + case 'MMM d': + return `${months[d.getMonth()]} ${d.getDate()}`; + case 'MMM yyyy': + return `${months[d.getMonth()]} ${d.getFullYear()}`; + case 'yyyy': + return `${d.getFullYear()}`; + default: + return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}, ${h12}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ${ampm}`; + } + } + + const UNITS = ['millisecond','second','minute','hour','day','week','month','quarter','year']; + const UNIT_MS = { + millisecond: 1, + second: 1000, + minute: 60000, + hour: 3600000, + day: 86400000, + week: 604800000, + month: 2592000000, + quarter: 7776000000, + year: 31536000000 + }; + + if (typeof Chart !== 'undefined' && Chart._adapters && Chart._adapters._date) { + const adapter = Chart._adapters._date; + adapter.override({ + _id: 'date-fns-lite', + formats: function() { return FORMATS; }, + parse: function(value) { + if (value === null || value === undefined) return null; + if (typeof value === 'number') return value; + const d = new Date(value); + return isNaN(d.getTime()) ? null : d.getTime(); + }, + format: function(time, fmt) { + return formatDate(time, fmt); + }, + add: function(time, amount, unit) { + const d = new Date(time); + switch(unit) { + case 'millisecond': d.setTime(d.getTime() + amount); break; + case 'second': d.setSeconds(d.getSeconds() + amount); break; + case 'minute': d.setMinutes(d.getMinutes() + amount); break; + case 'hour': d.setHours(d.getHours() + amount); break; + case 'day': d.setDate(d.getDate() + amount); break; + case 'week': d.setDate(d.getDate() + amount * 7); break; + case 'month': d.setMonth(d.getMonth() + amount); break; + case 'quarter': d.setMonth(d.getMonth() + amount * 3); break; + case 'year': d.setFullYear(d.getFullYear() + amount); break; + } + return d.getTime(); + }, + diff: function(max, min, unit) { + return (max - min) / (UNIT_MS[unit] || 1); + }, + startOf: function(time, unit) { + const d = new Date(time); + switch(unit) { + case 'second': d.setMilliseconds(0); break; + case 'minute': d.setSeconds(0,0); break; + case 'hour': d.setMinutes(0,0,0); break; + case 'day': d.setHours(0,0,0,0); break; + case 'week': d.setHours(0,0,0,0); d.setDate(d.getDate() - d.getDay()); break; + case 'month': d.setHours(0,0,0,0); d.setDate(1); break; + case 'quarter': d.setHours(0,0,0,0); d.setMonth(d.getMonth() - d.getMonth() % 3, 1); break; + case 'year': d.setHours(0,0,0,0); d.setMonth(0,1); break; + } + return d.getTime(); + }, + endOf: function(time, unit) { + const d = new Date(time); + switch(unit) { + case 'second': d.setMilliseconds(999); break; + case 'minute': d.setSeconds(59,999); break; + case 'hour': d.setMinutes(59,59,999); break; + case 'day': d.setHours(23,59,59,999); break; + case 'month': d.setMonth(d.getMonth()+1,0); d.setHours(23,59,59,999); break; + case 'year': d.setMonth(11,31); d.setHours(23,59,59,999); break; + } + return d.getTime(); + } + }); + } +})(); diff --git a/templates/index.html b/templates/index.html index e6dd575..49f306e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -45,6 +45,8 @@ {% else %} {% endif %} + + @@ -60,6 +62,7 @@ + @@ -186,6 +189,14 @@ Meshtastic + +
@@ -231,6 +242,10 @@ Weather Sat + @@ -515,6 +530,8 @@ {% include 'partials/modes/weather-satellite.html' %} + {% include 'partials/modes/sstv-general.html' %} + {% include 'partials/modes/listening-post.html' %} {% include 'partials/modes/tscm.html' %} @@ -525,6 +542,10 @@ {% include 'partials/modes/meshtastic.html' %} + {% include 'partials/modes/dmr.html' %} + + {% include 'partials/modes/websdr.html' %} + + + @@ -1381,6 +1403,16 @@
+ + + @@ -1543,6 +1575,17 @@ + +
+
+ WiFi Clients + 0 +
+
+
Start a sweep to scan for WiFi clients
+
+
+
@@ -1579,6 +1622,138 @@
+ + +
+
+ Device Timelines + +
+
+
Run a sweep to see device timelines
+
+
+ + + + + + + @@ -1873,7 +2048,10 @@ Decoded Images - 0 +
+ 0 + +
+ + + + +
+

Signal Identification

+
+ + +
+ +
+ + +
+

Waterfall

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+

Antenna Guide

@@ -118,4 +164,5 @@
+ diff --git a/templates/partials/modes/sstv-general.html b/templates/partials/modes/sstv-general.html new file mode 100644 index 0000000..89b2ed5 --- /dev/null +++ b/templates/partials/modes/sstv-general.html @@ -0,0 +1,86 @@ + +
+
+

SSTV Decoder

+

+ Decode Slow-Scan Television images on common amateur radio HF/VHF/UHF frequencies. + Select a predefined frequency or enter a custom one. +

+

+ Note: HF frequencies (below 30 MHz) require an upconverter with RTL-SDR. +

+
+ +
+

Frequency

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Resources

+ +
+ +
+

About Terrestrial SSTV

+

+ Amateur radio operators transmit SSTV images on HF bands worldwide. + The most popular frequency is 14.230 MHz USB on the 20m band. +

+

+ Common modes: PD120, PD180, Martin1, Scottie1, Robot36 +

+
+
diff --git a/templates/partials/modes/websdr.html b/templates/partials/modes/websdr.html new file mode 100644 index 0000000..f0a81eb --- /dev/null +++ b/templates/partials/modes/websdr.html @@ -0,0 +1,70 @@ + +
+
+

WebSDR

+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+

Audio Player

+
+
+ Status + DISCONNECTED +
+ +
+ Frequency + --- kHz +
+ +
+ S-Meter +
+
+
+
+ S0 +
+
+ +
+ VOL + +
+ +
+
+ + +
+

Spy Station Presets

+
+
Loading...
+
+
+
diff --git a/templates/partials/modes/wifi.html b/templates/partials/modes/wifi.html index fa7d677..ba23ec6 100644 --- a/templates/partials/modes/wifi.html +++ b/templates/partials/modes/wifi.html @@ -69,7 +69,22 @@
- + + +
+
+ + +
+
+
diff --git a/templates/partials/nav.html b/templates/partials/nav.html index 0eb3be2..79e1cb4 100644 --- a/templates/partials/nav.html +++ b/templates/partials/nav.html @@ -71,6 +71,8 @@ {{ mode_item('listening', 'Listening Post', '') }} {{ mode_item('spystations', 'Spy Stations', '') }} {{ mode_item('meshtastic', 'Meshtastic', '') }} + {{ mode_item('dmr', 'Digital Voice', '') }} + {{ mode_item('websdr', 'WebSDR', '') }} @@ -117,6 +119,7 @@ {% endif %} {{ mode_item('sstv', 'ISS SSTV', '') }} {{ mode_item('weathersat', 'Weather Sat', '') }} + {{ mode_item('sstv_general', 'HF SSTV', '') }} @@ -184,9 +187,12 @@ {% endif %} {{ mobile_item('sstv', 'SSTV', '') }} {{ mobile_item('weathersat', 'WxSat', '') }} + {{ mobile_item('sstv_general', 'HF SSTV', '') }} {{ mobile_item('listening', 'Scanner', '') }} {{ mobile_item('spystations', 'Spy', '') }} {{ mobile_item('meshtastic', 'Mesh', '') }} + {{ mobile_item('dmr', 'DMR', '') }} + {{ mobile_item('websdr', 'WebSDR', '') }} {# JavaScript stub for pages that don't have switchMode defined #} diff --git a/templates/partials/settings-modal.html b/templates/partials/settings-modal.html index 3448563..b9c951b 100644 --- a/templates/partials/settings-modal.html +++ b/templates/partials/settings-modal.html @@ -15,6 +15,8 @@ + + @@ -280,6 +282,83 @@ + +
+
+
Alert Feed
+
+
No alerts yet
+
+
+ +
+
Quick Rules
+
+ + +
+
+ Use Bluetooth device details to add specific device watchlist alerts. +
+
+
+ + +
+
+
Start Recording
+
+
+ Mode + Record live events for a mode +
+ +
+
+
+ Label + Optional note for the session +
+ +
+
+ + +
+
+ +
+
Active Sessions
+
+
No active recordings
+
+
+ +
+
Recent Recordings
+
+
No recordings yet
+
+
+
+
diff --git a/tests/conftest.py b/tests/conftest.py index d91c1f0..b29adfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,11 +5,13 @@ from app import app as flask_app from routes import register_blueprints -@pytest.fixture +@pytest.fixture(scope='session') def app(): """Create application for testing.""" - register_blueprints(flask_app) flask_app.config['TESTING'] = True + # Register blueprints only if not already registered + if 'pager' not in flask_app.blueprints: + register_blueprints(flask_app) return flask_app diff --git a/tests/test_dmr.py b/tests/test_dmr.py new file mode 100644 index 0000000..953d661 --- /dev/null +++ b/tests/test_dmr.py @@ -0,0 +1,175 @@ +"""Tests for the DMR / Digital Voice decoding module.""" + +from unittest.mock import patch, MagicMock +import pytest +from routes.dmr import parse_dsd_output + + +# ============================================ +# parse_dsd_output() tests +# ============================================ + +def test_parse_sync_dmr(): + """Should parse DMR sync line.""" + result = parse_dsd_output('Sync: +DMR (data)') + assert result is not None + assert result['type'] == 'sync' + assert 'DMR' in result['protocol'] + + +def test_parse_sync_p25(): + """Should parse P25 sync line.""" + result = parse_dsd_output('Sync: +P25 Phase 1') + assert result is not None + assert result['type'] == 'sync' + assert 'P25' in result['protocol'] + + +def test_parse_talkgroup_and_source(): + """Should parse talkgroup and source ID.""" + result = parse_dsd_output('TG: 12345 Src: 67890') + assert result is not None + assert result['type'] == 'call' + assert result['talkgroup'] == 12345 + assert result['source_id'] == 67890 + + +def test_parse_slot(): + """Should parse slot info.""" + result = parse_dsd_output('Slot 1') + assert result is not None + assert result['type'] == 'slot' + assert result['slot'] == 1 + + +def test_parse_voice(): + """Should parse voice frame info.""" + result = parse_dsd_output('Voice Frame 1') + assert result is not None + assert result['type'] == 'voice' + + +def test_parse_nac(): + """Should parse P25 NAC.""" + result = parse_dsd_output('NAC: 293') + assert result is not None + assert result['type'] == 'nac' + assert result['nac'] == '293' + + +def test_parse_talkgroup_dsd_fme_format(): + """Should parse dsd-fme comma-separated TG/Src format.""" + result = parse_dsd_output('TG: 12345, Src: 67890') + assert result is not None + assert result['type'] == 'call' + assert result['talkgroup'] == 12345 + assert result['source_id'] == 67890 + + +def test_parse_talkgroup_with_slot(): + """TG line with slot info should capture both.""" + result = parse_dsd_output('Slot 1 Voice LC, TG: 100, Src: 200') + assert result is not None + assert result['type'] == 'call' + assert result['talkgroup'] == 100 + assert result['source_id'] == 200 + assert result['slot'] == 1 + + +def test_parse_voice_with_slot(): + """Voice frame with slot info should be voice, not slot.""" + result = parse_dsd_output('Slot 2 Voice Frame') + assert result is not None + assert result['type'] == 'voice' + assert result['slot'] == 2 + + +def test_parse_empty_line(): + """Empty lines should return None.""" + assert parse_dsd_output('') is None + assert parse_dsd_output(' ') is None + + +def test_parse_unrecognized(): + """Unrecognized lines should return raw event for diagnostics.""" + result = parse_dsd_output('some random text') + assert result is not None + assert result['type'] == 'raw' + assert result['text'] == 'some random text' + + +# ============================================ +# Endpoint tests +# ============================================ + +@pytest.fixture +def auth_client(client): + """Client with logged-in session.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + return client + + +def test_dmr_tools(auth_client): + """Tools endpoint should return availability info.""" + resp = auth_client.get('/dmr/tools') + assert resp.status_code == 200 + data = resp.get_json() + assert 'dsd' in data + assert 'rtl_fm' in data + assert 'protocols' in data + + +def test_dmr_status(auth_client): + """Status endpoint should work.""" + resp = auth_client.get('/dmr/status') + assert resp.status_code == 200 + data = resp.get_json() + assert 'running' in data + + +def test_dmr_start_no_dsd(auth_client): + """Start should fail gracefully when dsd is not installed.""" + with patch('routes.dmr.find_dsd', return_value=(None, False)): + resp = auth_client.post('/dmr/start', json={ + 'frequency': 462.5625, + 'protocol': 'auto', + }) + assert resp.status_code == 503 + data = resp.get_json() + assert 'dsd' in data['message'] + + +def test_dmr_start_no_rtl_fm(auth_client): + """Start should fail when rtl_fm is missing.""" + with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \ + patch('routes.dmr.find_rtl_fm', return_value=None): + resp = auth_client.post('/dmr/start', json={ + 'frequency': 462.5625, + }) + assert resp.status_code == 503 + + +def test_dmr_start_invalid_protocol(auth_client): + """Start should reject invalid protocol.""" + with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \ + patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'): + resp = auth_client.post('/dmr/start', json={ + 'frequency': 462.5625, + 'protocol': 'invalid', + }) + assert resp.status_code == 400 + + +def test_dmr_stop(auth_client): + """Stop should succeed.""" + resp = auth_client.post('/dmr/stop') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'stopped' + + +def test_dmr_stream_mimetype(auth_client): + """Stream should return event-stream content type.""" + resp = auth_client.get('/dmr/stream') + assert resp.content_type.startswith('text/event-stream') diff --git a/tests/test_kiwisdr.py b/tests/test_kiwisdr.py new file mode 100644 index 0000000..ea01173 --- /dev/null +++ b/tests/test_kiwisdr.py @@ -0,0 +1,321 @@ +"""Tests for the KiwiSDR WebSocket audio client.""" + +import struct +from unittest.mock import patch, MagicMock + +import pytest + +from utils.kiwisdr import ( + KiwiSDRClient, + KIWI_SAMPLE_RATE, + KIWI_SND_HEADER_SIZE, + KIWI_DEFAULT_PORT, + MODE_FILTERS, + VALID_MODES, + parse_host_port, +) + + +# ============================================ +# parse_host_port tests +# ============================================ + +def test_parse_host_port_basic(): + """Should parse host:port from a simple URL.""" + assert parse_host_port('http://kiwi.example.com:8073') == ('kiwi.example.com', 8073) + + +def test_parse_host_port_no_port(): + """Should default to 8073 when port is missing.""" + assert parse_host_port('http://kiwi.example.com') == ('kiwi.example.com', KIWI_DEFAULT_PORT) + + +def test_parse_host_port_https(): + """Should strip https:// prefix.""" + assert parse_host_port('https://secure.kiwi.com:9090') == ('secure.kiwi.com', 9090) + + +def test_parse_host_port_ws(): + """Should strip ws:// prefix.""" + assert parse_host_port('ws://kiwi.local:8074') == ('kiwi.local', 8074) + + +def test_parse_host_port_with_path(): + """Should strip trailing path from URL.""" + assert parse_host_port('http://kiwi.com:8073/some/path') == ('kiwi.com', 8073) + + +def test_parse_host_port_bare_host(): + """Should handle bare hostname without protocol.""" + assert parse_host_port('kiwi.local') == ('kiwi.local', KIWI_DEFAULT_PORT) + + +def test_parse_host_port_bare_host_with_port(): + """Should handle bare hostname with port.""" + assert parse_host_port('kiwi.local:8074') == ('kiwi.local', 8074) + + +def test_parse_host_port_empty(): + """Should handle empty/None input.""" + assert parse_host_port('') == ('', KIWI_DEFAULT_PORT) + + +def test_parse_host_port_invalid_port(): + """Should default port for non-numeric port.""" + assert parse_host_port('http://kiwi.com:abc') == ('kiwi.com', KIWI_DEFAULT_PORT) + + +# ============================================ +# SND frame parsing tests +# ============================================ + +def _make_snd_frame(smeter_raw: int, pcm_samples: list[int]) -> bytes: + """Build a mock KiwiSDR SND binary frame.""" + header = b'SND' # 3 bytes: magic + header += b'\x00' # 1 byte: flags + header += struct.pack('>I', 42) # 4 bytes: sequence number + header += struct.pack('>h', smeter_raw) # 2 bytes: S-meter + # PCM data: 16-bit signed LE + pcm = b''.join(struct.pack(' 0 + assert high > low + + +def test_mode_filter_lsb_negative(): + """LSB filter should be in negative passband.""" + low, high = MODE_FILTERS['lsb'] + assert low < 0 + assert high < 0 + + +# ============================================ +# Connection tests with mocked WebSocket +# ============================================ + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_connect_success(mock_ws_module): + """Connect should establish a WebSocket connection.""" + mock_ws = MagicMock() + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='kiwi.local', port=8073) + result = client.connect(7000, 'am') + + assert result is True + assert client.connected is True + assert client.frequency_khz == 7000 + assert client.mode == 'am' + + # Verify WebSocket was created and connected + mock_ws_module.WebSocket.assert_called_once() + mock_ws.connect.assert_called_once() + + # Verify protocol messages were sent + calls = [str(c) for c in mock_ws.send.call_args_list] + auth_sent = any('SET auth' in c for c in calls) + compression_sent = any('SET compression=0' in c for c in calls) + mod_sent = any('SET mod=am' in c and 'freq=7000' in c for c in calls) + assert auth_sent, "Auth message not sent" + assert compression_sent, "Compression message not sent" + assert mod_sent, "Tune message not sent" + + # Cleanup + client.disconnect() + + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_connect_failure(mock_ws_module): + """Connect should handle connection failures.""" + mock_ws = MagicMock() + mock_ws.connect.side_effect = ConnectionRefusedError("Connection refused") + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='unreachable.local', port=8073) + result = client.connect(7000, 'am') + + assert result is False + assert client.connected is False + + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_tune_success(mock_ws_module): + """Tune should send the correct SET mod command.""" + mock_ws = MagicMock() + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='kiwi.local', port=8073) + client.connect(7000, 'am') + + mock_ws.send.reset_mock() + result = client.tune(14000, 'usb') + + assert result is True + assert client.frequency_khz == 14000 + assert client.mode == 'usb' + + tune_calls = [str(c) for c in mock_ws.send.call_args_list] + assert any('SET mod=usb' in c and 'freq=14000' in c for c in tune_calls) + + client.disconnect() + + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_invalid_mode_fallback(mock_ws_module): + """Connect with invalid mode should fall back to AM.""" + mock_ws = MagicMock() + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='kiwi.local', port=8073) + client.connect(7000, 'invalid_mode') + + assert client.mode == 'am' + client.disconnect() + + +@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) +@patch('utils.kiwisdr.websocket') +def test_client_ws_url_format(mock_ws_module): + """WebSocket URL should follow KiwiSDR format.""" + mock_ws = MagicMock() + mock_ws_module.WebSocket.return_value = mock_ws + + client = KiwiSDRClient(host='test.kiwi.com', port=8074) + client.connect(7000, 'am') + + ws_url = mock_ws.connect.call_args[0][0] + assert ws_url.startswith('ws://test.kiwi.com:8074/') + assert ws_url.endswith('/SND') + + client.disconnect() diff --git a/tests/test_signal_guess_api.py b/tests/test_signal_guess_api.py new file mode 100644 index 0000000..affcf61 --- /dev/null +++ b/tests/test_signal_guess_api.py @@ -0,0 +1,100 @@ +"""Tests for the Signal Identification (guess) API endpoint.""" + +import pytest + + +@pytest.fixture +def auth_client(client): + """Client with logged-in session.""" + with client.session_transaction() as sess: + 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', + }) + assert resp.status_code == 200 + data = resp.get_json() + 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', + }) + assert resp.status_code == 200 + data = resp.get_json() + 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, + }) + assert resp.status_code == 200 + data = resp.get_json() + 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={}) + assert resp.status_code == 400 + data = resp.get_json() + 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', + }) + 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, + }) + 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', + }) + assert resp.status_code == 200 + data = resp.get_json() + 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', + }) + 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) diff --git a/tests/test_sstv_decoder.py b/tests/test_sstv_decoder.py new file mode 100644 index 0000000..ead653d --- /dev/null +++ b/tests/test_sstv_decoder.py @@ -0,0 +1,798 @@ +"""Tests for the pure-Python SSTV decoder. + +Covers VIS detection, Goertzel accuracy, mode specs, synthetic image +decoding, and integration with the SSTVDecoder orchestrator. +""" + +from __future__ import annotations + +import math +import tempfile +import wave +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from utils.sstv.constants import ( + FREQ_BLACK, + FREQ_LEADER, + FREQ_PIXEL_HIGH, + FREQ_PIXEL_LOW, + FREQ_SYNC, + FREQ_VIS_BIT_0, + FREQ_VIS_BIT_1, + FREQ_WHITE, + SAMPLE_RATE, +) +from utils.sstv.dsp import ( + estimate_frequency, + freq_to_pixel, + goertzel, + goertzel_batch, + goertzel_mag, + normalize_audio, + samples_for_duration, +) +from utils.sstv.modes import ( + ALL_MODES, + MARTIN_1, + PD_120, + PD_180, + ROBOT_36, + ROBOT_72, + SCOTTIE_1, + ColorModel, + SyncPosition, + get_mode, + get_mode_by_name, +) +from utils.sstv.sstv_decoder import ( + DecodeProgress, + DopplerInfo, + SSTVDecoder, + SSTVImage, + get_sstv_decoder, + is_sstv_available, +) +from utils.sstv.vis import VISDetector, VISState + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def generate_tone(freq: float, duration_s: float, + sample_rate: int = SAMPLE_RATE, + amplitude: float = 0.8) -> np.ndarray: + """Generate a pure sine tone.""" + t = np.arange(int(duration_s * sample_rate)) / sample_rate + return amplitude * np.sin(2 * np.pi * freq * t) + + +def generate_vis_header(vis_code: int, sample_rate: int = SAMPLE_RATE) -> np.ndarray: + """Generate a synthetic VIS header for a given code. + + Structure: leader1 (300ms) + break (10ms) + leader2 (300ms) + + start_bit (30ms) + 8 data bits (30ms each) + + parity bit (30ms) + stop_bit (30ms) + """ + parts = [] + + # Leader 1 (1900 Hz, 300ms) + parts.append(generate_tone(FREQ_LEADER, 0.300, sample_rate)) + + # Break (1200 Hz, 10ms) + parts.append(generate_tone(FREQ_SYNC, 0.010, sample_rate)) + + # Leader 2 (1900 Hz, 300ms) + parts.append(generate_tone(FREQ_LEADER, 0.300, sample_rate)) + + # Start bit (1200 Hz, 30ms) + parts.append(generate_tone(FREQ_SYNC, 0.030, sample_rate)) + + # 8 data bits (LSB first) + ones_count = 0 + for i in range(8): + bit = (vis_code >> i) & 1 + if bit: + ones_count += 1 + parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030, sample_rate)) + else: + parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030, sample_rate)) + + # Even parity bit + parity = ones_count % 2 + if parity: + parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030, sample_rate)) + else: + parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030, sample_rate)) + + # Stop bit (1200 Hz, 30ms) + parts.append(generate_tone(FREQ_SYNC, 0.030, sample_rate)) + + return np.concatenate(parts) + + +# --------------------------------------------------------------------------- +# Goertzel / DSP tests +# --------------------------------------------------------------------------- + +class TestGoertzel: + """Tests for the Goertzel algorithm.""" + + def test_detects_exact_frequency(self): + """Goertzel should have peak energy at the generated frequency.""" + tone = generate_tone(1200.0, 0.01) + energy_1200 = goertzel(tone, 1200.0) + energy_1500 = goertzel(tone, 1500.0) + energy_1900 = goertzel(tone, 1900.0) + + assert energy_1200 > energy_1500 * 5 + assert energy_1200 > energy_1900 * 5 + + def test_different_frequencies(self): + """Each candidate frequency should produce peak at its own freq.""" + for freq in [1100, 1200, 1300, 1500, 1900, 2300]: + tone = generate_tone(float(freq), 0.01) + energy = goertzel(tone, float(freq)) + # Should have significant energy at the target + assert energy > 0 + + def test_empty_samples(self): + """Goertzel on empty array should return 0.""" + assert goertzel(np.array([], dtype=np.float64), 1200.0) == 0.0 + + def test_goertzel_mag(self): + """goertzel_mag should return sqrt of energy.""" + tone = generate_tone(1200.0, 0.01) + energy = goertzel(tone, 1200.0) + mag = goertzel_mag(tone, 1200.0) + assert abs(mag - math.sqrt(energy)) < 1e-10 + + +class TestEstimateFrequency: + """Tests for frequency estimation.""" + + def test_estimates_known_frequency(self): + """Should accurately estimate a known tone frequency.""" + tone = generate_tone(1900.0, 0.02) + estimated = estimate_frequency(tone, 1000.0, 2500.0) + assert abs(estimated - 1900.0) <= 30.0 + + def test_estimates_black_level(self): + """Should detect the black level frequency.""" + tone = generate_tone(FREQ_BLACK, 0.02) + estimated = estimate_frequency(tone, 1400.0, 1600.0) + assert abs(estimated - FREQ_BLACK) <= 30.0 + + def test_estimates_white_level(self): + """Should detect the white level frequency.""" + tone = generate_tone(FREQ_WHITE, 0.02) + estimated = estimate_frequency(tone, 2200.0, 2400.0) + assert abs(estimated - FREQ_WHITE) <= 30.0 + + def test_empty_samples(self): + """Should return 0 for empty input.""" + assert estimate_frequency(np.array([], dtype=np.float64)) == 0.0 + + +class TestFreqToPixel: + """Tests for frequency-to-pixel mapping.""" + + def test_black_level(self): + """1500 Hz should map to 0 (black).""" + assert freq_to_pixel(FREQ_PIXEL_LOW) == 0 + + def test_white_level(self): + """2300 Hz should map to 255 (white).""" + assert freq_to_pixel(FREQ_PIXEL_HIGH) == 255 + + def test_midpoint(self): + """Middle frequency should map to approximately 128.""" + mid_freq = (FREQ_PIXEL_LOW + FREQ_PIXEL_HIGH) / 2 + pixel = freq_to_pixel(mid_freq) + assert 120 <= pixel <= 135 + + def test_below_black_clamps(self): + """Frequencies below black level should clamp to 0.""" + assert freq_to_pixel(1000.0) == 0 + + def test_above_white_clamps(self): + """Frequencies above white level should clamp to 255.""" + assert freq_to_pixel(3000.0) == 255 + + +class TestNormalizeAudio: + """Tests for int16 to float64 normalization.""" + + def test_max_positive(self): + """int16 max should normalize to ~1.0.""" + raw = np.array([32767], dtype=np.int16) + result = normalize_audio(raw) + assert abs(result[0] - (32767.0 / 32768.0)) < 1e-10 + + def test_zero(self): + """int16 zero should normalize to 0.0.""" + raw = np.array([0], dtype=np.int16) + result = normalize_audio(raw) + assert result[0] == 0.0 + + def test_negative(self): + """int16 min should normalize to -1.0.""" + raw = np.array([-32768], dtype=np.int16) + result = normalize_audio(raw) + assert result[0] == -1.0 + + +class TestSamplesForDuration: + """Tests for duration-to-samples calculation.""" + + def test_one_second(self): + """1 second at 48kHz should be 48000 samples.""" + assert samples_for_duration(1.0) == 48000 + + def test_five_ms(self): + """5ms at 48kHz should be 240 samples.""" + assert samples_for_duration(0.005) == 240 + + def test_custom_rate(self): + """Should work with custom sample rates.""" + assert samples_for_duration(1.0, 22050) == 22050 + + +class TestGoertzelBatch: + """Tests for the vectorized batch Goertzel function.""" + + def test_matches_scalar_goertzel(self): + """Batch result should match individual goertzel calls.""" + rng = np.random.default_rng(42) + # 10 pixel windows of 20 samples each + audio_matrix = rng.standard_normal((10, 20)) + freqs = np.array([1200.0, 1500.0, 1900.0, 2300.0]) + + batch_result = goertzel_batch(audio_matrix, freqs) + assert batch_result.shape == (10, 4) + + for i in range(10): + for j, f in enumerate(freqs): + scalar = goertzel(audio_matrix[i], f) + assert abs(batch_result[i, j] - scalar) < 1e-6, \ + f"Mismatch at pixel {i}, freq {f}" + + def test_detects_correct_frequency(self): + """Batch should find peak at the correct frequency for each pixel. + + Uses 96-sample windows (2ms at 48kHz) matching the decoder's + minimum analysis window, with 5Hz resolution. + """ + freqs = np.arange(1400.0, 2405.0, 5.0) # 5Hz step, same as decoder + window_size = 96 # Matches _MIN_ANALYSIS_WINDOW + pixels = [] + for target in [1500.0, 1900.0, 2300.0]: + t = np.arange(window_size) / SAMPLE_RATE + pixels.append(0.8 * np.sin(2 * np.pi * target * t)) + audio_matrix = np.array(pixels) + + energies = goertzel_batch(audio_matrix, freqs) + best_idx = np.argmax(energies, axis=1) + best_freqs = freqs[best_idx] + + # With 96 samples, frequency accuracy is within ~25 Hz + assert abs(best_freqs[0] - 1500.0) <= 30.0 + assert abs(best_freqs[1] - 1900.0) <= 30.0 + assert abs(best_freqs[2] - 2300.0) <= 30.0 + + def test_empty_input(self): + """Should handle empty inputs gracefully.""" + result = goertzel_batch(np.zeros((0, 10)), np.array([1200.0])) + assert result.shape == (0, 1) + + result = goertzel_batch(np.zeros((5, 10)), np.array([])) + assert result.shape == (5, 0) + + +# --------------------------------------------------------------------------- +# VIS detection tests +# --------------------------------------------------------------------------- + +class TestVISDetector: + """Tests for VIS header detection.""" + + def test_initial_state(self): + """Detector should start in IDLE state.""" + detector = VISDetector() + assert detector.state == VISState.IDLE + + def test_reset(self): + """Reset should return to IDLE state.""" + detector = VISDetector() + # Feed some leader tone to change state + detector.feed(generate_tone(FREQ_LEADER, 0.250)) + detector.reset() + assert detector.state == VISState.IDLE + + def test_detect_robot36(self): + """Should detect Robot36 VIS code (8).""" + detector = VISDetector() + header = generate_vis_header(8) # Robot36 + # Add some silence before and after + audio = np.concatenate([ + np.zeros(2400), + header, + np.zeros(2400), + ]) + + result = detector.feed(audio) + assert result is not None + vis_code, mode_name = result + assert vis_code == 8 + assert mode_name == 'Robot36' + + def test_detect_martin1(self): + """Should detect Martin1 VIS code (44).""" + detector = VISDetector() + header = generate_vis_header(44) # Martin1 + audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)]) + + result = detector.feed(audio) + assert result is not None + vis_code, mode_name = result + assert vis_code == 44 + assert mode_name == 'Martin1' + + def test_detect_scottie1(self): + """Should detect Scottie1 VIS code (60).""" + detector = VISDetector() + header = generate_vis_header(60) # Scottie1 + audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)]) + + result = detector.feed(audio) + assert result is not None + vis_code, mode_name = result + assert vis_code == 60 + assert mode_name == 'Scottie1' + + def test_detect_pd120(self): + """Should detect PD120 VIS code (93).""" + detector = VISDetector() + header = generate_vis_header(93) # PD120 + audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)]) + + result = detector.feed(audio) + assert result is not None + vis_code, mode_name = result + assert vis_code == 93 + assert mode_name == 'PD120' + + def test_noise_rejection(self): + """Should not falsely detect VIS in noise.""" + detector = VISDetector() + rng = np.random.default_rng(42) + noise = rng.standard_normal(48000) * 0.1 # 1 second of noise + result = detector.feed(noise) + assert result is None + + def test_incremental_feeding(self): + """Should work with small chunks fed incrementally.""" + detector = VISDetector() + header = generate_vis_header(8) + audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)]) + + # Feed in small chunks (100 samples each) + chunk_size = 100 + result = None + offset = 0 + while offset < len(audio): + chunk = audio[offset:offset + chunk_size] + offset += chunk_size + result = detector.feed(chunk) + if result is not None: + break + + assert result is not None + vis_code, mode_name = result + assert vis_code == 8 + assert mode_name == 'Robot36' + + +# --------------------------------------------------------------------------- +# Mode spec tests +# --------------------------------------------------------------------------- + +class TestModes: + """Tests for SSTV mode specifications.""" + + def test_all_vis_codes_have_modes(self): + """All defined VIS codes should have matching mode specs.""" + for vis_code in [8, 12, 44, 40, 60, 56, 93, 95]: + mode = get_mode(vis_code) + assert mode is not None, f"No mode for VIS code {vis_code}" + + def test_robot36_spec(self): + """Robot36 should have correct dimensions and timing.""" + assert ROBOT_36.width == 320 + assert ROBOT_36.height == 240 + assert ROBOT_36.vis_code == 8 + assert ROBOT_36.color_model == ColorModel.YCRCB + assert ROBOT_36.has_half_rate_chroma is True + assert ROBOT_36.sync_position == SyncPosition.FRONT + + def test_martin1_spec(self): + """Martin1 should have correct dimensions.""" + assert MARTIN_1.width == 320 + assert MARTIN_1.height == 256 + assert MARTIN_1.vis_code == 44 + assert MARTIN_1.color_model == ColorModel.RGB + assert len(MARTIN_1.channels) == 3 + + def test_scottie1_spec(self): + """Scottie1 should have middle sync position.""" + assert SCOTTIE_1.sync_position == SyncPosition.MIDDLE + assert SCOTTIE_1.width == 320 + assert SCOTTIE_1.height == 256 + + def test_pd120_spec(self): + """PD120 should have dual-luminance YCrCb.""" + assert PD_120.width == 640 + assert PD_120.height == 496 + assert PD_120.color_model == ColorModel.YCRCB_DUAL + assert len(PD_120.channels) == 4 # Y1, Cr, Cb, Y2 + + def test_get_mode_unknown(self): + """Unknown VIS code should return None.""" + assert get_mode(999) is None + + def test_get_mode_by_name(self): + """Should look up modes by name.""" + mode = get_mode_by_name('Robot36') + assert mode is not None + assert mode.vis_code == 8 + + def test_mode_by_name_unknown(self): + """Unknown mode name should return None.""" + assert get_mode_by_name('FakeMode') is None + + def test_robot72_spec(self): + """Robot72 should have 3 channels and full-rate chroma.""" + assert ROBOT_72.width == 320 + assert ROBOT_72.height == 240 + assert ROBOT_72.vis_code == 12 + assert ROBOT_72.color_model == ColorModel.YCRCB + assert ROBOT_72.has_half_rate_chroma is False + assert len(ROBOT_72.channels) == 3 # Y, Cr, Cb + assert ROBOT_72.channel_separator_ms == 6.0 + + def test_robot36_separator(self): + """Robot36 should have a 6ms separator between Y and chroma.""" + assert ROBOT_36.channel_separator_ms == 6.0 + assert ROBOT_36.has_half_rate_chroma is True + assert len(ROBOT_36.channels) == 2 # Y, alternating Cr/Cb + + def test_pd120_channel_timings(self): + """PD120 channel durations should sum to line_duration minus sync+porch.""" + channel_sum = sum(ch.duration_ms for ch in PD_120.channels) + expected = PD_120.line_duration_ms - PD_120.sync_duration_ms - PD_120.sync_porch_ms + assert abs(channel_sum - expected) < 0.1, \ + f"PD120 channels sum to {channel_sum}ms, expected {expected}ms" + + def test_pd180_channel_timings(self): + """PD180 channel durations should sum to line_duration minus sync+porch.""" + channel_sum = sum(ch.duration_ms for ch in PD_180.channels) + expected = PD_180.line_duration_ms - PD_180.sync_duration_ms - PD_180.sync_porch_ms + assert abs(channel_sum - expected) < 0.1, \ + f"PD180 channels sum to {channel_sum}ms, expected {expected}ms" + + def test_robot36_timing_consistency(self): + """Robot36 total channel + sync + porch + separator should equal line_duration.""" + total = (ROBOT_36.sync_duration_ms + ROBOT_36.sync_porch_ms + + sum(ch.duration_ms for ch in ROBOT_36.channels) + + ROBOT_36.channel_separator_ms) # 1 separator for 2 channels + assert abs(total - ROBOT_36.line_duration_ms) < 0.1 + + def test_robot72_timing_consistency(self): + """Robot72 total should equal line_duration.""" + # 3 channels with 2 separators + total = (ROBOT_72.sync_duration_ms + ROBOT_72.sync_porch_ms + + sum(ch.duration_ms for ch in ROBOT_72.channels) + + ROBOT_72.channel_separator_ms * 2) + assert abs(total - ROBOT_72.line_duration_ms) < 0.1 + + def test_all_modes_have_positive_dimensions(self): + """All modes should have positive width and height.""" + for _vis_code, mode in ALL_MODES.items(): + assert mode.width > 0, f"{mode.name} has invalid width" + assert mode.height > 0, f"{mode.name} has invalid height" + assert mode.line_duration_ms > 0, f"{mode.name} has invalid line duration" + + +# --------------------------------------------------------------------------- +# Image decoder tests +# --------------------------------------------------------------------------- + +class TestImageDecoder: + """Tests for the SSTV image decoder.""" + + def test_creates_decoder(self): + """Should create an image decoder for any supported mode.""" + from utils.sstv.image_decoder import SSTVImageDecoder + decoder = SSTVImageDecoder(ROBOT_36) + assert decoder.is_complete is False + assert decoder.current_line == 0 + assert decoder.total_lines == 240 + + def test_pd120_dual_luminance_lines(self): + """PD120 decoder should expect half the image height in audio lines.""" + from utils.sstv.image_decoder import SSTVImageDecoder + decoder = SSTVImageDecoder(PD_120) + assert decoder.total_lines == 248 # 496 / 2 + + def test_progress_percent(self): + """Progress should start at 0.""" + from utils.sstv.image_decoder import SSTVImageDecoder + decoder = SSTVImageDecoder(ROBOT_36) + assert decoder.progress_percent == 0 + + def test_synthetic_robot36_decode(self): + """Should decode a synthetic Robot36 image (all white).""" + pytest.importorskip('PIL') + from utils.sstv.image_decoder import SSTVImageDecoder + + decoder = SSTVImageDecoder(ROBOT_36) + + # Generate synthetic scanlines (all white = 2300 Hz) + # Each line: sync(9ms) + porch(3ms) + Y(88ms) + separator(6ms) + Cr/Cb(44ms) + for _line in range(240): + parts = [] + # Sync pulse + parts.append(generate_tone(FREQ_SYNC, 0.009)) + # Porch + parts.append(generate_tone(FREQ_BLACK, 0.003)) + # Y channel (white = 2300 Hz) + parts.append(generate_tone(FREQ_WHITE, 0.088)) + # Separator + porch (6ms) + parts.append(generate_tone(FREQ_BLACK, 0.006)) + # Chroma channel (mid value = 1900 Hz ~ 128) + parts.append(generate_tone(1900.0, 0.044)) + # Pad to line duration + line_audio = np.concatenate(parts) + line_samples = samples_for_duration(ROBOT_36.line_duration_ms / 1000.0) + if len(line_audio) < line_samples: + line_audio = np.concatenate([ + line_audio, + np.zeros(line_samples - len(line_audio)) + ]) + + decoder.feed(line_audio) + + assert decoder.is_complete + img = decoder.get_image() + assert img is not None + assert img.size == (320, 240) + + +# --------------------------------------------------------------------------- +# SSTVDecoder orchestrator tests +# --------------------------------------------------------------------------- + +class TestSSTVDecoder: + """Tests for the SSTVDecoder orchestrator.""" + + def test_decoder_available(self): + """Python decoder should always be available.""" + decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) + assert decoder.decoder_available == 'python-sstv' + + def test_is_sstv_available(self): + """is_sstv_available() should always return True.""" + assert is_sstv_available() is True + + def test_not_running_initially(self): + """Decoder should not be running on creation.""" + decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) + assert decoder.is_running is False + + def test_doppler_disabled_by_default(self): + """Doppler should be disabled by default.""" + decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) + assert decoder.doppler_enabled is False + assert decoder.last_doppler_info is None + + def test_stop_when_not_running(self): + """Stop should be safe to call when not running.""" + decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) + decoder.stop() # Should not raise + + def test_set_callback(self): + """Should accept a callback function.""" + decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) + cb = MagicMock() + decoder.set_callback(cb) + # Trigger a progress emit + decoder._emit_progress(DecodeProgress(status='detecting')) + cb.assert_called_once() + + def test_get_images_empty(self): + """Should return empty list initially.""" + decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) + images = decoder.get_images() + assert images == [] + + def test_decode_file_not_found(self): + """Should raise FileNotFoundError for missing file.""" + decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) + with pytest.raises(FileNotFoundError): + decoder.decode_file('/nonexistent/audio.wav') + + def test_decode_file_with_synthetic_wav(self): + """Should process a WAV file through the decode pipeline.""" + pytest.importorskip('PIL') + + output_dir = tempfile.mkdtemp() + decoder = SSTVDecoder(output_dir=output_dir) + + # Generate a synthetic WAV with a VIS header + short image data + vis_header = generate_vis_header(8) # Robot36 + + # Add 240 lines of image data after the header + image_lines = [] + for _line in range(240): + parts = [] + parts.append(generate_tone(FREQ_SYNC, 0.009)) + parts.append(generate_tone(FREQ_BLACK, 0.003)) + parts.append(generate_tone(1900.0, 0.088)) # mid-gray Y + parts.append(generate_tone(FREQ_BLACK, 0.006)) # separator + parts.append(generate_tone(1900.0, 0.044)) # chroma + line_audio = np.concatenate(parts) + line_samples = samples_for_duration(ROBOT_36.line_duration_ms / 1000.0) + if len(line_audio) < line_samples: + line_audio = np.concatenate([ + line_audio, + np.zeros(line_samples - len(line_audio)) + ]) + image_lines.append(line_audio) + + audio = np.concatenate([ + np.zeros(4800), # 100ms silence + vis_header, + *image_lines, + np.zeros(4800), + ]) + + # Write WAV file + wav_path = Path(output_dir) / 'test_input.wav' + raw_int16 = (audio * 32767).astype(np.int16) + with wave.open(str(wav_path), 'wb') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(SAMPLE_RATE) + wf.writeframes(raw_int16.tobytes()) + + images = decoder.decode_file(wav_path) + assert len(images) >= 1 + assert images[0].mode == 'Robot36' + assert Path(images[0].path).exists() + + +# --------------------------------------------------------------------------- +# Dataclass tests +# --------------------------------------------------------------------------- + +class TestDataclasses: + """Tests for dataclass serialization.""" + + def test_decode_progress_to_dict(self): + """DecodeProgress should serialize correctly.""" + progress = DecodeProgress( + status='decoding', + mode='Robot36', + progress_percent=50, + message='Halfway done', + ) + d = progress.to_dict() + assert d['type'] == 'sstv_progress' + assert d['status'] == 'decoding' + assert d['mode'] == 'Robot36' + assert d['progress'] == 50 + assert d['message'] == 'Halfway done' + + def test_decode_progress_minimal(self): + """DecodeProgress with only status should omit optional fields.""" + progress = DecodeProgress(status='detecting') + d = progress.to_dict() + assert 'mode' not in d + assert 'message' not in d + assert 'image' not in d + + def test_sstv_image_to_dict(self): + """SSTVImage should serialize with URL.""" + from datetime import datetime, timezone + image = SSTVImage( + filename='test.png', + path=Path('/tmp/test.png'), + mode='Robot36', + timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc), + frequency=145.800, + size_bytes=1234, + ) + d = image.to_dict() + assert d['filename'] == 'test.png' + assert d['mode'] == 'Robot36' + assert d['url'] == '/sstv/images/test.png' + + def test_doppler_info_to_dict(self): + """DopplerInfo should serialize with rounding.""" + from datetime import datetime, timezone + info = DopplerInfo( + frequency_hz=145800123.456, + shift_hz=123.456, + range_rate_km_s=-1.23456, + elevation=45.678, + azimuth=180.123, + timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc), + ) + d = info.to_dict() + assert d['shift_hz'] == 123.5 + assert d['range_rate_km_s'] == -1.235 + assert d['elevation'] == 45.7 + + +# --------------------------------------------------------------------------- +# Integration tests +# --------------------------------------------------------------------------- + +class TestIntegration: + """Integration tests verifying the package works as a drop-in replacement.""" + + def test_import_from_utils_sstv(self): + """Routes should be able to import from utils.sstv.""" + from utils.sstv import ( + ISS_SSTV_FREQ, + is_sstv_available, + ) + assert ISS_SSTV_FREQ == 145.800 + assert is_sstv_available() is True + + def test_sstv_modes_constant(self): + """SSTV_MODES list should be importable.""" + from utils.sstv import SSTV_MODES + assert 'Robot36' in SSTV_MODES + assert 'Martin1' in SSTV_MODES + assert 'PD120' in SSTV_MODES + + def test_decoder_singleton(self): + """get_sstv_decoder should return a valid decoder.""" + # Reset the global singleton for test isolation + import utils.sstv.sstv_decoder as mod + old = mod._decoder + mod._decoder = None + try: + decoder = get_sstv_decoder() + assert decoder is not None + assert decoder.decoder_available == 'python-sstv' + finally: + mod._decoder = old + + @patch('subprocess.Popen') + def test_start_creates_subprocess(self, mock_popen): + """start() should create an rtl_fm subprocess.""" + mock_process = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stdout.read = MagicMock(return_value=b'') + mock_process.stderr = MagicMock() + mock_popen.return_value = mock_process + + decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) + success = decoder.start(frequency=145.800, device_index=0) + assert success is True + assert decoder.is_running is True + + # Verify rtl_fm was called + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd[0] == 'rtl_fm' + assert '-f' in cmd + assert '-M' in cmd + + decoder.stop() + assert decoder.is_running is False diff --git a/tests/test_waterfall.py b/tests/test_waterfall.py new file mode 100644 index 0000000..c0ced1b --- /dev/null +++ b/tests/test_waterfall.py @@ -0,0 +1,80 @@ +"""Tests for the Waterfall / Spectrogram endpoints.""" + +from unittest.mock import patch, MagicMock +import pytest + + +@pytest.fixture +def auth_client(client): + """Client with logged-in session.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + return client + + +def test_waterfall_start_no_rtl_power(auth_client): + """Start should fail gracefully when rtl_power is not available.""" + with patch('routes.listening_post.find_rtl_power', return_value=None): + resp = auth_client.post('/listening/waterfall/start', json={ + 'start_freq': 88.0, + 'end_freq': 108.0, + }) + assert resp.status_code == 503 + data = resp.get_json() + assert 'rtl_power' in data['message'] + + +def test_waterfall_start_invalid_range(auth_client): + """Start should reject end <= start.""" + with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'): + resp = auth_client.post('/listening/waterfall/start', json={ + 'start_freq': 108.0, + 'end_freq': 88.0, + }) + assert resp.status_code == 400 + + +def test_waterfall_start_success(auth_client): + """Start should succeed with mocked rtl_power and device.""" + with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'), \ + patch('routes.listening_post.app_module') as mock_app: + mock_app.claim_sdr_device.return_value = None # No error, claim succeeds + resp = auth_client.post('/listening/waterfall/start', json={ + 'start_freq': 88.0, + 'end_freq': 108.0, + 'gain': 40, + 'device': 0, + }) + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'started' + + # Clean up: stop waterfall + import routes.listening_post as lp + lp.waterfall_running = False + + +def test_waterfall_stop(auth_client): + """Stop should succeed.""" + resp = auth_client.post('/listening/waterfall/stop') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'stopped' + + +def test_waterfall_stream_mimetype(auth_client): + """Stream should return event-stream content type.""" + resp = auth_client.get('/listening/waterfall/stream') + assert resp.content_type.startswith('text/event-stream') + + +def test_waterfall_start_device_busy(auth_client): + """Start should fail when device is in use.""" + with patch('routes.listening_post.find_rtl_power', return_value='/usr/bin/rtl_power'), \ + patch('routes.listening_post.app_module') as mock_app: + mock_app.claim_sdr_device.return_value = 'SDR device 0 is in use by scanner' + resp = auth_client.post('/listening/waterfall/start', json={ + 'start_freq': 88.0, + 'end_freq': 108.0, + }) + assert resp.status_code == 409 diff --git a/tests/test_websdr.py b/tests/test_websdr.py new file mode 100644 index 0000000..013b255 --- /dev/null +++ b/tests/test_websdr.py @@ -0,0 +1,170 @@ +"""Tests for the HF/Shortwave WebSDR integration.""" + +from unittest.mock import patch, MagicMock +import pytest +from routes.websdr import _parse_gps_coord, _haversine +from utils.kiwisdr import parse_host_port + + +# ============================================ +# Helper function tests +# ============================================ + +def test_parse_gps_coord_float(): + """Should parse a simple float string.""" + assert _parse_gps_coord('51.5074') == pytest.approx(51.5074) + + +def test_parse_gps_coord_negative(): + """Should parse a negative coordinate.""" + assert _parse_gps_coord('-33.87') == pytest.approx(-33.87) + + +def test_parse_gps_coord_parentheses(): + """Should handle parentheses in coordinate string.""" + assert _parse_gps_coord('(-33.87)') == pytest.approx(-33.87) + + +def test_parse_gps_coord_empty(): + """Should return None for empty string.""" + assert _parse_gps_coord('') is None + assert _parse_gps_coord(None) is None + + +def test_parse_gps_coord_invalid(): + """Should return None for invalid string.""" + assert _parse_gps_coord('abc') is None + + +def test_haversine_same_point(): + """Distance between same point should be 0.""" + assert _haversine(51.5, -0.1, 51.5, -0.1) == pytest.approx(0.0, abs=0.01) + + +def test_haversine_known_distance(): + """Test with known city pair (London to Paris ~343 km).""" + dist = _haversine(51.5074, -0.1278, 48.8566, 2.3522) + assert 340 < dist < 350 + + +# ============================================ +# Endpoint tests +# ============================================ + +@pytest.fixture +def auth_client(client): + """Client with logged-in session.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + return client + + +def test_websdr_status(auth_client): + """Status endpoint should return cache info.""" + resp = auth_client.get('/websdr/status') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'ok' + assert 'cached_receivers' in data + + +def test_websdr_receivers_empty_cache(auth_client): + """Receivers endpoint should work even with empty cache.""" + with patch('routes.websdr.get_receivers', return_value=[]): + resp = auth_client.get('/websdr/receivers') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'success' + assert data['receivers'] == [] + + +def test_websdr_receivers_with_data(auth_client): + """Receivers endpoint should return filtered data.""" + mock_receivers = [ + {'name': 'Test RX', 'url': 'http://test.com', 'lat': 51.5, 'lon': -0.1, + 'users': 1, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Dipole', 'bands': 'HF'}, + {'name': 'Full RX', 'url': 'http://full.com', 'lat': 48.8, 'lon': 2.3, + 'users': 4, 'users_max': 4, 'available': False, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Loop', 'bands': 'HF'}, + ] + with patch('routes.websdr.get_receivers', return_value=mock_receivers): + # Filter available only + resp = auth_client.get('/websdr/receivers?available=true') + assert resp.status_code == 200 + data = resp.get_json() + assert len(data['receivers']) == 1 + assert data['receivers'][0]['name'] == 'Test RX' + + +def test_websdr_nearest_missing_params(auth_client): + """Nearest endpoint should require lat/lon.""" + resp = auth_client.get('/websdr/receivers/nearest') + assert resp.status_code == 400 + + +def test_websdr_nearest_with_coords(auth_client): + """Nearest endpoint should sort by distance.""" + mock_receivers = [ + {'name': 'Far RX', 'url': 'http://far.com', 'lat': -33.87, 'lon': 151.21, + 'users': 0, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Dipole', 'bands': 'HF'}, + {'name': 'Near RX', 'url': 'http://near.com', 'lat': 51.0, 'lon': -0.5, + 'users': 0, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Loop', 'bands': 'HF'}, + ] + with patch('routes.websdr.get_receivers', return_value=mock_receivers): + resp = auth_client.get('/websdr/receivers/nearest?lat=51.5&lon=-0.1') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'success' + assert len(data['receivers']) == 2 + # Near should be first + assert data['receivers'][0]['name'] == 'Near RX' + + +def test_websdr_spy_station_receivers(auth_client): + """Spy station cross-reference should find matching receivers.""" + mock_receivers = [ + {'name': 'HF RX', 'url': 'http://hf.com', 'lat': 51.5, 'lon': -0.1, + 'users': 0, 'users_max': 4, 'available': True, 'freq_lo': 0, 'freq_hi': 30000, + 'antenna': 'Dipole', 'bands': 'HF'}, + ] + with patch('routes.websdr.get_receivers', return_value=mock_receivers): + # e06 is one of the spy stations + resp = auth_client.get('/websdr/spy-station/e06/receivers') + assert resp.status_code == 200 + data = resp.get_json() + assert data['status'] == 'success' + assert 'station' in data + + +def test_websdr_spy_station_not_found(auth_client): + """Non-existent station should return 404.""" + resp = auth_client.get('/websdr/spy-station/nonexistent/receivers') + assert resp.status_code == 404 + + +# ============================================ +# parse_host_port tests (integration) +# ============================================ + +def test_parse_host_port_http_url(): + """Should parse standard KiwiSDR URL.""" + host, port = parse_host_port('http://kiwi.example.com:8073') + assert host == 'kiwi.example.com' + assert port == 8073 + + +def test_parse_host_port_no_protocol(): + """Should handle bare hostname.""" + host, port = parse_host_port('my-kiwi.local:8074') + assert host == 'my-kiwi.local' + assert port == 8074 + + +def test_parse_host_port_with_trailing_slash(): + """Should handle URL with trailing path.""" + host, port = parse_host_port('http://kiwi.com:8073/') + assert host == 'kiwi.com' + assert port == 8073 diff --git a/utils/alerts.py b/utils/alerts.py new file mode 100644 index 0000000..1f52eed --- /dev/null +++ b/utils/alerts.py @@ -0,0 +1,443 @@ +"""Alerting engine for cross-mode events.""" + +from __future__ import annotations + +import json +import logging +import queue +import re +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Generator + +from config import ALERT_WEBHOOK_URL, ALERT_WEBHOOK_TIMEOUT, ALERT_WEBHOOK_SECRET +from utils.database import get_db + +logger = logging.getLogger('intercept.alerts') + + +@dataclass +class AlertRule: + id: int + name: str + mode: str | None + event_type: str | None + match: dict + severity: str + enabled: bool + notify: dict + created_at: str | None = None + + +class AlertManager: + def __init__(self) -> None: + self._queue: queue.Queue = queue.Queue(maxsize=1000) + self._rules_cache: list[AlertRule] = [] + self._rules_loaded_at = 0.0 + self._cache_lock = threading.Lock() + + # ------------------------------------------------------------------ + # Rule management + # ------------------------------------------------------------------ + + def invalidate_cache(self) -> None: + with self._cache_lock: + self._rules_loaded_at = 0.0 + + def _load_rules(self) -> None: + with get_db() as conn: + cursor = conn.execute(''' + SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at + FROM alert_rules + WHERE enabled = 1 + ORDER BY id ASC + ''') + rules: list[AlertRule] = [] + for row in cursor: + match = {} + notify = {} + try: + match = json.loads(row['match']) if row['match'] else {} + except json.JSONDecodeError: + match = {} + try: + notify = json.loads(row['notify']) if row['notify'] else {} + except json.JSONDecodeError: + notify = {} + rules.append(AlertRule( + id=row['id'], + name=row['name'], + mode=row['mode'], + event_type=row['event_type'], + match=match, + severity=row['severity'] or 'medium', + enabled=bool(row['enabled']), + notify=notify, + created_at=row['created_at'], + )) + with self._cache_lock: + self._rules_cache = rules + self._rules_loaded_at = time.time() + + def _get_rules(self) -> list[AlertRule]: + with self._cache_lock: + stale = (time.time() - self._rules_loaded_at) > 10 + if stale: + self._load_rules() + with self._cache_lock: + return list(self._rules_cache) + + def list_rules(self, include_disabled: bool = False) -> list[dict]: + with get_db() as conn: + if include_disabled: + cursor = conn.execute(''' + SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at + FROM alert_rules + ORDER BY id DESC + ''') + else: + cursor = conn.execute(''' + SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at + FROM alert_rules + WHERE enabled = 1 + ORDER BY id DESC + ''') + + return [ + { + 'id': row['id'], + 'name': row['name'], + 'mode': row['mode'], + 'event_type': row['event_type'], + 'match': json.loads(row['match']) if row['match'] else {}, + 'severity': row['severity'], + 'enabled': bool(row['enabled']), + 'notify': json.loads(row['notify']) if row['notify'] else {}, + 'created_at': row['created_at'], + } + for row in cursor + ] + + def add_rule(self, rule: dict) -> int: + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO alert_rules (name, mode, event_type, match, severity, enabled, notify) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + rule.get('name') or 'Alert Rule', + rule.get('mode'), + rule.get('event_type'), + json.dumps(rule.get('match') or {}), + rule.get('severity') or 'medium', + 1 if rule.get('enabled', True) else 0, + json.dumps(rule.get('notify') or {}), + )) + rule_id = cursor.lastrowid + self.invalidate_cache() + return int(rule_id) + + def update_rule(self, rule_id: int, updates: dict) -> bool: + fields = [] + params = [] + for key in ('name', 'mode', 'event_type', 'severity'): + if key in updates: + fields.append(f"{key} = ?") + params.append(updates[key]) + if 'enabled' in updates: + fields.append('enabled = ?') + params.append(1 if updates['enabled'] else 0) + if 'match' in updates: + fields.append('match = ?') + params.append(json.dumps(updates['match'] or {})) + if 'notify' in updates: + fields.append('notify = ?') + params.append(json.dumps(updates['notify'] or {})) + + if not fields: + return False + + params.append(rule_id) + with get_db() as conn: + cursor = conn.execute( + f"UPDATE alert_rules SET {', '.join(fields)} WHERE id = ?", + params + ) + updated = cursor.rowcount > 0 + + if updated: + self.invalidate_cache() + return updated + + def delete_rule(self, rule_id: int) -> bool: + with get_db() as conn: + cursor = conn.execute('DELETE FROM alert_rules WHERE id = ?', (rule_id,)) + deleted = cursor.rowcount > 0 + if deleted: + self.invalidate_cache() + return deleted + + def list_events(self, limit: int = 100, mode: str | None = None, severity: str | None = None) -> list[dict]: + query = 'SELECT id, rule_id, mode, event_type, severity, title, message, payload, created_at FROM alert_events' + clauses = [] + params: list[Any] = [] + if mode: + clauses.append('mode = ?') + params.append(mode) + if severity: + clauses.append('severity = ?') + params.append(severity) + if clauses: + query += ' WHERE ' + ' AND '.join(clauses) + query += ' ORDER BY id DESC LIMIT ?' + params.append(limit) + + with get_db() as conn: + cursor = conn.execute(query, params) + events = [] + for row in cursor: + events.append({ + 'id': row['id'], + 'rule_id': row['rule_id'], + 'mode': row['mode'], + 'event_type': row['event_type'], + 'severity': row['severity'], + 'title': row['title'], + 'message': row['message'], + 'payload': json.loads(row['payload']) if row['payload'] else {}, + 'created_at': row['created_at'], + }) + return events + + # ------------------------------------------------------------------ + # Event processing + # ------------------------------------------------------------------ + + def process_event(self, mode: str, event: dict, event_type: str | None = None) -> None: + if not isinstance(event, dict): + return + + if event_type in ('keepalive', 'ping', 'status'): + return + + rules = self._get_rules() + if not rules: + return + + for rule in rules: + if rule.mode and rule.mode != mode: + continue + if rule.event_type and event_type and rule.event_type != event_type: + continue + if rule.event_type and not event_type: + continue + if not self._match_rule(rule.match, event): + continue + + title = rule.name or 'Alert' + message = self._build_message(rule, event, event_type) + payload = { + 'mode': mode, + 'event_type': event_type, + 'event': event, + 'rule': { + 'id': rule.id, + 'name': rule.name, + }, + } + event_id = self._store_event(rule.id, mode, event_type, rule.severity, title, message, payload) + alert_payload = { + 'id': event_id, + 'rule_id': rule.id, + 'mode': mode, + 'event_type': event_type, + 'severity': rule.severity, + 'title': title, + 'message': message, + 'payload': payload, + 'created_at': datetime.now(timezone.utc).isoformat(), + } + self._queue_event(alert_payload) + self._maybe_send_webhook(alert_payload, rule.notify) + + def _build_message(self, rule: AlertRule, event: dict, event_type: str | None) -> str: + if isinstance(rule.notify, dict) and rule.notify.get('message'): + return str(rule.notify.get('message')) + summary_bits = [] + if event_type: + summary_bits.append(event_type) + if 'name' in event: + summary_bits.append(str(event.get('name'))) + if 'ssid' in event: + summary_bits.append(str(event.get('ssid'))) + if 'bssid' in event: + summary_bits.append(str(event.get('bssid'))) + if 'address' in event: + summary_bits.append(str(event.get('address'))) + if 'mac' in event: + summary_bits.append(str(event.get('mac'))) + summary = ' | '.join(summary_bits) if summary_bits else 'Alert triggered' + return summary + + def _store_event( + self, + rule_id: int, + mode: str, + event_type: str | None, + severity: str, + title: str, + message: str, + payload: dict, + ) -> int: + with get_db() as conn: + cursor = conn.execute(''' + INSERT INTO alert_events (rule_id, mode, event_type, severity, title, message, payload) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + rule_id, + mode, + event_type, + severity, + title, + message, + json.dumps(payload), + )) + return int(cursor.lastrowid) + + def _queue_event(self, alert_payload: dict) -> None: + try: + self._queue.put_nowait(alert_payload) + except queue.Full: + try: + self._queue.get_nowait() + self._queue.put_nowait(alert_payload) + except queue.Empty: + pass + + def _maybe_send_webhook(self, payload: dict, notify: dict) -> None: + if not ALERT_WEBHOOK_URL: + return + if isinstance(notify, dict) and notify.get('webhook') is False: + return + + try: + import urllib.request + req = urllib.request.Request( + ALERT_WEBHOOK_URL, + data=json.dumps(payload).encode('utf-8'), + headers={ + 'Content-Type': 'application/json', + 'User-Agent': 'Intercept-Alert', + 'X-Alert-Token': ALERT_WEBHOOK_SECRET or '', + }, + method='POST' + ) + with urllib.request.urlopen(req, timeout=ALERT_WEBHOOK_TIMEOUT) as _: + pass + except Exception as e: + logger.debug(f"Alert webhook failed: {e}") + + # ------------------------------------------------------------------ + # Matching + # ------------------------------------------------------------------ + + def _match_rule(self, rule_match: dict, event: dict) -> bool: + if not rule_match: + return True + + for key, expected in rule_match.items(): + actual = self._extract_value(event, key) + if not self._match_value(actual, expected): + return False + return True + + def _extract_value(self, event: dict, key: str) -> Any: + if '.' not in key: + return event.get(key) + current: Any = event + for part in key.split('.'): + if isinstance(current, dict): + current = current.get(part) + else: + return None + return current + + def _match_value(self, actual: Any, expected: Any) -> bool: + if isinstance(expected, dict) and 'op' in expected: + op = expected.get('op') + value = expected.get('value') + return self._apply_op(op, actual, value) + + if isinstance(expected, list): + return actual in expected + + if isinstance(expected, str): + if actual is None: + return False + return str(actual).lower() == expected.lower() + + return actual == expected + + def _apply_op(self, op: str, actual: Any, value: Any) -> bool: + if op == 'exists': + return actual is not None + if op == 'eq': + return actual == value + if op == 'neq': + return actual != value + if op == 'gt': + return _safe_number(actual) is not None and _safe_number(actual) > _safe_number(value) + if op == 'gte': + return _safe_number(actual) is not None and _safe_number(actual) >= _safe_number(value) + if op == 'lt': + return _safe_number(actual) is not None and _safe_number(actual) < _safe_number(value) + if op == 'lte': + return _safe_number(actual) is not None and _safe_number(actual) <= _safe_number(value) + if op == 'in': + return actual in (value or []) + if op == 'contains': + if actual is None: + return False + if isinstance(actual, list): + return any(str(value).lower() in str(item).lower() for item in actual) + return str(value).lower() in str(actual).lower() + if op == 'regex': + if actual is None or value is None: + return False + try: + return re.search(str(value), str(actual)) is not None + except re.error: + return False + return False + + # ------------------------------------------------------------------ + # Streaming + # ------------------------------------------------------------------ + + def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]: + while True: + try: + event = self._queue.get(timeout=timeout) + yield event + except queue.Empty: + yield {'type': 'keepalive'} + + +_alert_manager: AlertManager | None = None +_alert_lock = threading.Lock() + + +def get_alert_manager() -> AlertManager: + global _alert_manager + with _alert_lock: + if _alert_manager is None: + _alert_manager = AlertManager() + return _alert_manager + + +def _safe_number(value: Any) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/utils/bluetooth/models.py b/utils/bluetooth/models.py index 932342a..2810819 100644 --- a/utils/bluetooth/models.py +++ b/utils/bluetooth/models.py @@ -148,9 +148,10 @@ class BTDeviceAggregate: is_strong_stable: bool = False has_random_address: bool = False - # Baseline tracking - in_baseline: bool = False - baseline_id: Optional[int] = None + # Baseline tracking + in_baseline: bool = False + baseline_id: Optional[int] = None + seen_before: bool = False # Tracker detection fields is_tracker: bool = False @@ -274,9 +275,10 @@ class BTDeviceAggregate: }, 'heuristic_flags': self.heuristic_flags, - # Baseline - 'in_baseline': self.in_baseline, - 'baseline_id': self.baseline_id, + # Baseline + 'in_baseline': self.in_baseline, + 'baseline_id': self.baseline_id, + 'seen_before': self.seen_before, # Tracker detection 'tracker': { @@ -325,10 +327,11 @@ class BTDeviceAggregate: 'last_seen': self.last_seen.isoformat(), 'age_seconds': self.age_seconds, 'seen_count': self.seen_count, - 'heuristic_flags': self.heuristic_flags, - 'in_baseline': self.in_baseline, - # Tracker info for list view - 'is_tracker': self.is_tracker, + 'heuristic_flags': self.heuristic_flags, + 'in_baseline': self.in_baseline, + 'seen_before': self.seen_before, + # Tracker info for list view + 'is_tracker': self.is_tracker, 'tracker_type': self.tracker_type, 'tracker_name': self.tracker_name, 'tracker_confidence': self.tracker_confidence, diff --git a/utils/database.py b/utils/database.py index 6467c27..92b62cc 100644 --- a/utils/database.py +++ b/utils/database.py @@ -88,19 +88,65 @@ def init_db() -> None: ON signal_history(mode, device_id, timestamp) ''') - # Device correlation table - conn.execute(''' - CREATE TABLE IF NOT EXISTS device_correlations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - wifi_mac TEXT, - bt_mac TEXT, - confidence REAL, - first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - metadata TEXT, - UNIQUE(wifi_mac, bt_mac) - ) - ''') + # Device correlation table + conn.execute(''' + CREATE TABLE IF NOT EXISTS device_correlations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wifi_mac TEXT, + bt_mac TEXT, + confidence REAL, + first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + metadata TEXT, + UNIQUE(wifi_mac, bt_mac) + ) + ''') + + # Alert rules + conn.execute(''' + CREATE TABLE IF NOT EXISTS alert_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + mode TEXT, + event_type TEXT, + match TEXT, + severity TEXT DEFAULT 'medium', + enabled BOOLEAN DEFAULT 1, + notify TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Alert events + conn.execute(''' + CREATE TABLE IF NOT EXISTS alert_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id INTEGER, + mode TEXT, + event_type TEXT, + severity TEXT DEFAULT 'medium', + title TEXT, + message TEXT, + payload TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL + ) + ''') + + # Session recordings + conn.execute(''' + CREATE TABLE IF NOT EXISTS recording_sessions ( + id TEXT PRIMARY KEY, + mode TEXT NOT NULL, + label TEXT, + started_at TIMESTAMP NOT NULL, + stopped_at TIMESTAMP, + file_path TEXT NOT NULL, + event_count INTEGER DEFAULT 0, + size_bytes INTEGER DEFAULT 0, + metadata TEXT + ) + ''') # Users table for authentication conn.execute(''' @@ -131,20 +177,29 @@ def init_db() -> None: # ===================================================================== # TSCM Baselines - Environment snapshots for comparison - conn.execute(''' - CREATE TABLE IF NOT EXISTS tscm_baselines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - location TEXT, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - wifi_networks TEXT, - bt_devices TEXT, - rf_frequencies TEXT, - gps_coords TEXT, - is_active BOOLEAN DEFAULT 0 - ) - ''') + conn.execute(''' + CREATE TABLE IF NOT EXISTS tscm_baselines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + location TEXT, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + wifi_networks TEXT, + wifi_clients TEXT, + bt_devices TEXT, + rf_frequencies TEXT, + gps_coords TEXT, + is_active BOOLEAN DEFAULT 0 + ) + ''') + + # Ensure new columns exist for older databases + try: + columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")} + if 'wifi_clients' not in columns: + conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT') + except Exception as e: + logger.debug(f"Schema update skipped for tscm_baselines: {e}") # TSCM Sweeps - Individual sweep sessions conn.execute(''' @@ -685,15 +740,16 @@ def get_correlations(min_confidence: float = 0.5) -> list[dict]: # TSCM Functions # ============================================================================= -def create_tscm_baseline( - name: str, - location: str | None = None, - description: str | None = None, - wifi_networks: list | None = None, - bt_devices: list | None = None, - rf_frequencies: list | None = None, - gps_coords: dict | None = None -) -> int: +def create_tscm_baseline( + name: str, + location: str | None = None, + description: str | None = None, + wifi_networks: list | None = None, + wifi_clients: list | None = None, + bt_devices: list | None = None, + rf_frequencies: list | None = None, + gps_coords: dict | None = None +) -> int: """ Create a new TSCM baseline. @@ -701,19 +757,20 @@ def create_tscm_baseline( The ID of the created baseline """ with get_db() as conn: - cursor = conn.execute(''' - INSERT INTO tscm_baselines - (name, location, description, wifi_networks, bt_devices, rf_frequencies, gps_coords) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ( - name, - location, - description, - json.dumps(wifi_networks) if wifi_networks else None, - json.dumps(bt_devices) if bt_devices else None, - json.dumps(rf_frequencies) if rf_frequencies else None, - json.dumps(gps_coords) if gps_coords else None - )) + cursor = conn.execute(''' + INSERT INTO tscm_baselines + (name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + name, + location, + description, + json.dumps(wifi_networks) if wifi_networks else None, + json.dumps(wifi_clients) if wifi_clients else None, + json.dumps(bt_devices) if bt_devices else None, + json.dumps(rf_frequencies) if rf_frequencies else None, + json.dumps(gps_coords) if gps_coords else None + )) return cursor.lastrowid @@ -728,18 +785,19 @@ def get_tscm_baseline(baseline_id: int) -> dict | None: if row is None: return None - return { - 'id': row['id'], - 'name': row['name'], - 'location': row['location'], - 'description': row['description'], - 'created_at': row['created_at'], - 'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [], - 'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [], - 'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [], - 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None, - 'is_active': bool(row['is_active']) - } + return { + 'id': row['id'], + 'name': row['name'], + 'location': row['location'], + 'description': row['description'], + 'created_at': row['created_at'], + 'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [], + 'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [], + 'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [], + 'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [], + 'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None, + 'is_active': bool(row['is_active']) + } def get_all_tscm_baselines() -> list[dict]: @@ -781,19 +839,23 @@ def set_active_tscm_baseline(baseline_id: int) -> bool: return cursor.rowcount > 0 -def update_tscm_baseline( - baseline_id: int, - wifi_networks: list | None = None, - bt_devices: list | None = None, - rf_frequencies: list | None = None -) -> bool: +def update_tscm_baseline( + baseline_id: int, + wifi_networks: list | None = None, + wifi_clients: list | None = None, + bt_devices: list | None = None, + rf_frequencies: list | None = None +) -> bool: """Update baseline device lists.""" updates = [] params = [] - if wifi_networks is not None: - updates.append('wifi_networks = ?') - params.append(json.dumps(wifi_networks)) + if wifi_networks is not None: + updates.append('wifi_networks = ?') + params.append(json.dumps(wifi_networks)) + if wifi_clients is not None: + updates.append('wifi_clients = ?') + params.append(json.dumps(wifi_clients)) if bt_devices is not None: updates.append('bt_devices = ?') params.append(json.dumps(bt_devices)) diff --git a/utils/event_pipeline.py b/utils/event_pipeline.py new file mode 100644 index 0000000..cbab8bb --- /dev/null +++ b/utils/event_pipeline.py @@ -0,0 +1,29 @@ +"""Shared event pipeline for alerts and recordings.""" + +from __future__ import annotations + +from typing import Any + +from utils.alerts import get_alert_manager +from utils.recording import get_recording_manager + +IGNORE_TYPES = {'keepalive', 'ping'} + + +def process_event(mode: str, event: dict | Any, event_type: str | None = None) -> None: + if event_type in IGNORE_TYPES: + return + if not isinstance(event, dict): + return + + try: + get_recording_manager().record_event(mode, event, event_type) + except Exception: + # Recording failures should never break streaming + pass + + try: + get_alert_manager().process_event(mode, event, event_type) + except Exception: + # Alert failures should never break streaming + pass diff --git a/utils/kiwisdr.py b/utils/kiwisdr.py new file mode 100644 index 0000000..7df5210 --- /dev/null +++ b/utils/kiwisdr.py @@ -0,0 +1,288 @@ +"""KiwiSDR WebSocket audio client. + +Connects to a KiwiSDR receiver via its WebSocket API and streams +decoded PCM audio back through a callback. +""" + +from __future__ import annotations + +import struct +import threading +import time +from typing import Optional, Callable + +try: + import websocket # websocket-client library + WEBSOCKET_CLIENT_AVAILABLE = True +except ImportError: + WEBSOCKET_CLIENT_AVAILABLE = False + +from utils.logging import get_logger + +logger = get_logger('intercept.kiwisdr') + +# Protocol constants +KIWI_KEEPALIVE_INTERVAL = 5.0 +KIWI_SAMPLE_RATE = 12000 # 12 kHz mono +KIWI_SND_HEADER_SIZE = 10 # "SND"(3) + flags(1) + seq(4) + smeter(2) +KIWI_DEFAULT_PORT = 8073 + +VALID_MODES = ('am', 'usb', 'lsb', 'cw') + +# Default bandpass filters per mode (Hz) +MODE_FILTERS = { + 'am': (-4500, 4500), + 'usb': (300, 3000), + 'lsb': (-3000, -300), + 'cw': (300, 800), +} + + +def parse_host_port(url: str) -> tuple[str, int]: + """Extract host and port from a KiwiSDR URL like 'http://host:port'. + + Returns (host, port) tuple. Defaults to port 8073 if not specified. + """ + if not url: + return ('', KIWI_DEFAULT_PORT) + + # Strip protocol + cleaned = url + for prefix in ('http://', 'https://', 'ws://', 'wss://'): + if cleaned.lower().startswith(prefix): + cleaned = cleaned[len(prefix):] + break + + # Strip path + cleaned = cleaned.split('/')[0] + + # Split host:port + if ':' in cleaned: + parts = cleaned.rsplit(':', 1) + host = parts[0] + try: + port = int(parts[1]) + except ValueError: + port = KIWI_DEFAULT_PORT + else: + host = cleaned + port = KIWI_DEFAULT_PORT + + return (host, port) + + +class KiwiSDRClient: + """Manages a WebSocket connection to a single KiwiSDR receiver.""" + + def __init__( + self, + host: str, + port: int = KIWI_DEFAULT_PORT, + on_audio: Optional[Callable[[bytes, int], None]] = None, + on_error: Optional[Callable[[str], None]] = None, + on_disconnect: Optional[Callable[[], None]] = None, + password: str = '', + ): + self.host = host + self.port = port + self.password = password + self._on_audio = on_audio + self._on_error = on_error + self._on_disconnect = on_disconnect + + self._ws = None + self._connected = False + self._stopping = False + self._receive_thread: Optional[threading.Thread] = None + self._keepalive_thread: Optional[threading.Thread] = None + self._send_lock = threading.Lock() + + self.frequency_khz: float = 0 + self.mode: str = 'am' + self.last_smeter: int = 0 + + @property + def connected(self) -> bool: + return self._connected + + def connect(self, frequency_khz: float, mode: str = 'am') -> bool: + """Connect to KiwiSDR and start receiving audio.""" + if not WEBSOCKET_CLIENT_AVAILABLE: + logger.error("websocket-client not installed") + return False + + if self._connected: + self.disconnect() + + self.frequency_khz = frequency_khz + self.mode = mode if mode in VALID_MODES else 'am' + self._stopping = False + + ws_url = self._build_ws_url() + logger.info(f"Connecting to KiwiSDR: {ws_url}") + + try: + self._ws = websocket.WebSocket() + self._ws.settimeout(10) + self._ws.connect(ws_url) + + # Auth + self._send('SET auth t=kiwi p=' + self.password) + time.sleep(0.2) + + # Request uncompressed PCM + self._send('SET compression=0') + + # Set AGC + self._send('SET agc=1 hang=0 thresh=-100 slope=6 decay=1000 manGain=50') + + # Tune to frequency + self._send_tune(frequency_khz, self.mode) + + # Request audio start + self._send('SET AR OK in=12000 out=44100') + + self._connected = True + + # Start receive thread + self._receive_thread = threading.Thread( + target=self._receive_loop, daemon=True, name='kiwi-rx' + ) + self._receive_thread.start() + + # Start keepalive thread + self._keepalive_thread = threading.Thread( + target=self._keepalive_loop, daemon=True, name='kiwi-ka' + ) + self._keepalive_thread.start() + + logger.info(f"Connected to KiwiSDR {self.host}:{self.port} @ {frequency_khz} kHz {self.mode}") + return True + + except Exception as e: + logger.error(f"KiwiSDR connection failed: {e}") + self._cleanup() + return False + + def tune(self, frequency_khz: float, mode: str = 'am') -> bool: + """Retune without disconnecting.""" + if not self._connected or not self._ws: + return False + + self.frequency_khz = frequency_khz + if mode in VALID_MODES: + self.mode = mode + + try: + self._send_tune(frequency_khz, self.mode) + logger.info(f"Retuned to {frequency_khz} kHz {self.mode}") + return True + except Exception as e: + logger.error(f"Retune failed: {e}") + return False + + def disconnect(self) -> None: + """Cleanly disconnect from KiwiSDR.""" + self._stopping = True + self._connected = False + self._cleanup() + logger.info("Disconnected from KiwiSDR") + + def _build_ws_url(self) -> str: + ts = int(time.time() * 1000) + return f'ws://{self.host}:{self.port}/{ts}/SND' + + def _send(self, msg: str) -> None: + with self._send_lock: + if self._ws: + self._ws.send(msg) + + def _send_tune(self, freq_khz: float, mode: str) -> None: + low_cut, high_cut = MODE_FILTERS.get(mode, MODE_FILTERS['am']) + self._send(f'SET mod={mode} low_cut={low_cut} high_cut={high_cut} freq={freq_khz}') + + def _receive_loop(self) -> None: + """Background thread: read frames from KiwiSDR WebSocket.""" + try: + while self._connected and not self._stopping: + try: + if not self._ws: + break + self._ws.settimeout(2.0) + data = self._ws.recv() + except websocket.WebSocketTimeoutException: + continue + except Exception as e: + if not self._stopping: + logger.error(f"KiwiSDR receive error: {e}") + break + + if not data or not isinstance(data, bytes): + # Text message (status/config) — ignore + continue + + self._parse_snd_frame(data) + + except Exception as e: + if not self._stopping: + logger.error(f"KiwiSDR receive loop error: {e}") + finally: + if not self._stopping: + self._connected = False + if self._on_disconnect: + try: + self._on_disconnect() + except Exception: + pass + + def _parse_snd_frame(self, data: bytes) -> None: + """Parse a KiwiSDR SND binary frame.""" + if len(data) < KIWI_SND_HEADER_SIZE: + return + + # Check header magic + if data[:3] != b'SND': + return + + # flags = data[3] + # seq = struct.unpack('>I', data[4:8])[0] + + # S-meter: big-endian int16 at offset 8 + smeter_raw = struct.unpack('>h', data[8:10])[0] + self.last_smeter = smeter_raw + + # PCM audio data starts at offset 10 + pcm_data = data[KIWI_SND_HEADER_SIZE:] + + if pcm_data and self._on_audio: + try: + self._on_audio(pcm_data, smeter_raw) + except Exception: + pass + + def _keepalive_loop(self) -> None: + """Background thread: send keepalive every 5 seconds.""" + while self._connected and not self._stopping: + time.sleep(KIWI_KEEPALIVE_INTERVAL) + if self._connected and not self._stopping: + try: + self._send('SET keepalive') + except Exception: + break + + def _cleanup(self) -> None: + """Close WebSocket and join threads.""" + if self._ws: + try: + self._ws.close() + except Exception: + pass + self._ws = None + + if self._receive_thread and self._receive_thread.is_alive(): + self._receive_thread.join(timeout=3.0) + if self._keepalive_thread and self._keepalive_thread.is_alive(): + self._keepalive_thread.join(timeout=3.0) + + self._receive_thread = None + self._keepalive_thread = None diff --git a/utils/recording.py b/utils/recording.py new file mode 100644 index 0000000..dc8ca79 --- /dev/null +++ b/utils/recording.py @@ -0,0 +1,222 @@ +"""Session recording utilities for SSE/event streams.""" + +from __future__ import annotations + +import json +import logging +import threading +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from utils.database import get_db + +logger = logging.getLogger('intercept.recording') + +RECORDING_ROOT = Path(__file__).parent.parent / 'instance' / 'recordings' + + +@dataclass +class RecordingSession: + id: str + mode: str + label: str | None + file_path: Path + started_at: datetime + stopped_at: datetime | None = None + event_count: int = 0 + size_bytes: int = 0 + metadata: dict | None = None + + _file_handle: Any | None = None + _lock: threading.Lock = threading.Lock() + + def open(self) -> None: + self.file_path.parent.mkdir(parents=True, exist_ok=True) + self._file_handle = self.file_path.open('a', encoding='utf-8') + + def close(self) -> None: + if self._file_handle: + self._file_handle.flush() + self._file_handle.close() + self._file_handle = None + + def write_event(self, record: dict) -> None: + if not self._file_handle: + self.open() + line = json.dumps(record, ensure_ascii=True) + '\n' + with self._lock: + self._file_handle.write(line) + self._file_handle.flush() + self.event_count += 1 + self.size_bytes += len(line.encode('utf-8')) + + +class RecordingManager: + def __init__(self) -> None: + self._active_by_mode: dict[str, RecordingSession] = {} + self._active_by_id: dict[str, RecordingSession] = {} + self._lock = threading.Lock() + + def start_recording(self, mode: str, label: str | None = None, metadata: dict | None = None) -> RecordingSession: + with self._lock: + existing = self._active_by_mode.get(mode) + if existing: + return existing + + session_id = str(uuid.uuid4()) + started_at = datetime.now(timezone.utc) + filename = f"{mode}_{started_at.strftime('%Y%m%d_%H%M%S')}_{session_id}.jsonl" + file_path = RECORDING_ROOT / mode / filename + + session = RecordingSession( + id=session_id, + mode=mode, + label=label, + file_path=file_path, + started_at=started_at, + metadata=metadata or {}, + ) + session.open() + + self._active_by_mode[mode] = session + self._active_by_id[session_id] = session + + with get_db() as conn: + conn.execute(''' + INSERT INTO recording_sessions + (id, mode, label, started_at, file_path, event_count, size_bytes, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + session.id, + session.mode, + session.label, + session.started_at.isoformat(), + str(session.file_path), + session.event_count, + session.size_bytes, + json.dumps(session.metadata or {}), + )) + + return session + + def stop_recording(self, mode: str | None = None, session_id: str | None = None) -> RecordingSession | None: + with self._lock: + session = None + if session_id: + session = self._active_by_id.get(session_id) + elif mode: + session = self._active_by_mode.get(mode) + + if not session: + return None + + session.stopped_at = datetime.now(timezone.utc) + session.close() + + self._active_by_mode.pop(session.mode, None) + self._active_by_id.pop(session.id, None) + + with get_db() as conn: + conn.execute(''' + UPDATE recording_sessions + SET stopped_at = ?, event_count = ?, size_bytes = ? + WHERE id = ? + ''', ( + session.stopped_at.isoformat(), + session.event_count, + session.size_bytes, + session.id, + )) + + return session + + def record_event(self, mode: str, event: dict, event_type: str | None = None) -> None: + if event_type in ('keepalive', 'ping'): + return + session = self._active_by_mode.get(mode) + if not session: + return + record = { + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'mode': mode, + 'event_type': event_type, + 'event': event, + } + try: + session.write_event(record) + except Exception as e: + logger.debug(f"Recording write failed: {e}") + + def list_recordings(self, limit: int = 50) -> list[dict]: + with get_db() as conn: + cursor = conn.execute(''' + SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata + FROM recording_sessions + ORDER BY started_at DESC + LIMIT ? + ''', (limit,)) + rows = [] + for row in cursor: + rows.append({ + 'id': row['id'], + 'mode': row['mode'], + 'label': row['label'], + 'started_at': row['started_at'], + 'stopped_at': row['stopped_at'], + 'file_path': row['file_path'], + 'event_count': row['event_count'], + 'size_bytes': row['size_bytes'], + 'metadata': json.loads(row['metadata']) if row['metadata'] else {}, + }) + return rows + + def get_recording(self, session_id: str) -> dict | None: + with get_db() as conn: + cursor = conn.execute(''' + SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata + FROM recording_sessions + WHERE id = ? + ''', (session_id,)) + row = cursor.fetchone() + if not row: + return None + return { + 'id': row['id'], + 'mode': row['mode'], + 'label': row['label'], + 'started_at': row['started_at'], + 'stopped_at': row['stopped_at'], + 'file_path': row['file_path'], + 'event_count': row['event_count'], + 'size_bytes': row['size_bytes'], + 'metadata': json.loads(row['metadata']) if row['metadata'] else {}, + } + + def get_active(self) -> list[dict]: + with self._lock: + sessions = [] + for session in self._active_by_mode.values(): + sessions.append({ + 'id': session.id, + 'mode': session.mode, + 'label': session.label, + 'started_at': session.started_at.isoformat(), + 'event_count': session.event_count, + 'size_bytes': session.size_bytes, + }) + return sessions + + +_recording_manager: RecordingManager | None = None +_recording_lock = threading.Lock() + + +def get_recording_manager() -> RecordingManager: + global _recording_manager + with _recording_lock: + if _recording_manager is None: + _recording_manager = RecordingManager() + return _recording_manager diff --git a/utils/sstv.py b/utils/sstv.py deleted file mode 100644 index 973df4f..0000000 --- a/utils/sstv.py +++ /dev/null @@ -1,769 +0,0 @@ -"""SSTV (Slow-Scan Television) decoder for ISS transmissions. - -This module provides SSTV decoding capabilities for receiving images -from the International Space Station during special events. - -ISS SSTV typically transmits on 145.800 MHz FM. - -Includes real-time Doppler shift compensation for improved reception. -""" - -from __future__ import annotations - -import os -import queue -import subprocess -import threading -import time -from dataclasses import dataclass, field -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import Callable - -from utils.logging import get_logger - -logger = get_logger('intercept.sstv') - -# ISS SSTV frequency -ISS_SSTV_FREQ = 145.800 # MHz - -# Speed of light in m/s -SPEED_OF_LIGHT = 299_792_458 - -# Common SSTV modes used by ISS -SSTV_MODES = ['PD120', 'PD180', 'Martin1', 'Martin2', 'Scottie1', 'Scottie2', 'Robot36'] - - -@dataclass -class DopplerInfo: - """Doppler shift information.""" - frequency_hz: float # Doppler-corrected frequency in Hz - shift_hz: float # Doppler shift in Hz (positive = approaching) - range_rate_km_s: float # Range rate in km/s (negative = approaching) - elevation: float # Current elevation in degrees - azimuth: float # Current azimuth in degrees - timestamp: datetime - - def to_dict(self) -> dict: - return { - 'frequency_hz': self.frequency_hz, - 'shift_hz': round(self.shift_hz, 1), - 'range_rate_km_s': round(self.range_rate_km_s, 3), - 'elevation': round(self.elevation, 1), - 'azimuth': round(self.azimuth, 1), - 'timestamp': self.timestamp.isoformat(), - } - - -class DopplerTracker: - """ - Real-time Doppler shift calculator for satellite tracking. - - Uses skyfield to calculate the range rate between observer and satellite, - then computes the Doppler-shifted receive frequency. - """ - - def __init__(self, satellite_name: str = 'ISS'): - self._satellite_name = satellite_name - self._observer_lat: float | None = None - self._observer_lon: float | None = None - self._satellite = None - self._observer = None - self._ts = None - self._enabled = False - - def configure(self, latitude: float, longitude: float) -> bool: - """ - Configure the Doppler tracker with observer location. - - Args: - latitude: Observer latitude in degrees - longitude: Observer longitude in degrees - - Returns: - True if configured successfully - """ - try: - from skyfield.api import load, wgs84, EarthSatellite - from data.satellites import TLE_SATELLITES - - # Get satellite TLE - tle_data = TLE_SATELLITES.get(self._satellite_name) - if not tle_data: - logger.error(f"No TLE data for satellite: {self._satellite_name}") - return False - - self._ts = load.timescale() - self._satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], self._ts) - self._observer = wgs84.latlon(latitude, longitude) - self._observer_lat = latitude - self._observer_lon = longitude - self._enabled = True - - logger.info(f"Doppler tracker configured for {self._satellite_name} at ({latitude}, {longitude})") - return True - - except ImportError: - logger.warning("skyfield not available - Doppler tracking disabled") - return False - except Exception as e: - logger.error(f"Failed to configure Doppler tracker: {e}") - return False - - @property - def is_enabled(self) -> bool: - return self._enabled - - def calculate(self, nominal_freq_mhz: float) -> DopplerInfo | None: - """ - Calculate current Doppler-shifted frequency. - - Args: - nominal_freq_mhz: Nominal transmit frequency in MHz - - Returns: - DopplerInfo with corrected frequency, or None if unavailable - """ - if not self._enabled or not self._satellite or not self._observer: - return None - - try: - # Get current time - t = self._ts.now() - - # Calculate satellite position relative to observer - difference = self._satellite - self._observer - topocentric = difference.at(t) - - # Get altitude/azimuth - alt, az, distance = topocentric.altaz() - - # Get velocity (range rate) - negative means approaching - # We need the rate of change of distance - # Calculate positions slightly apart to get velocity - dt_seconds = 1.0 - t_future = self._ts.utc(t.utc_datetime() + timedelta(seconds=dt_seconds)) - - topocentric_future = difference.at(t_future) - _, _, distance_future = topocentric_future.altaz() - - # Range rate in km/s (negative = approaching = positive Doppler) - range_rate_km_s = (distance_future.km - distance.km) / dt_seconds - - # Calculate Doppler shift - # f_received = f_transmitted * (1 - v_radial / c) - # When approaching (negative range_rate), frequency is higher - nominal_freq_hz = nominal_freq_mhz * 1_000_000 - doppler_factor = 1 - (range_rate_km_s * 1000 / SPEED_OF_LIGHT) - corrected_freq_hz = nominal_freq_hz * doppler_factor - shift_hz = corrected_freq_hz - nominal_freq_hz - - return DopplerInfo( - frequency_hz=corrected_freq_hz, - shift_hz=shift_hz, - range_rate_km_s=range_rate_km_s, - elevation=alt.degrees, - azimuth=az.degrees, - timestamp=datetime.now(timezone.utc) - ) - - except Exception as e: - logger.error(f"Doppler calculation failed: {e}") - return None - - -@dataclass -class SSTVImage: - """Decoded SSTV image.""" - filename: str - path: Path - mode: str - timestamp: datetime - frequency: float - size_bytes: int = 0 - - def to_dict(self) -> dict: - return { - 'filename': self.filename, - 'path': str(self.path), - 'mode': self.mode, - 'timestamp': self.timestamp.isoformat(), - 'frequency': self.frequency, - 'size_bytes': self.size_bytes, - 'url': f'/sstv/images/{self.filename}' - } - - -@dataclass -class DecodeProgress: - """SSTV decode progress update.""" - status: str # 'detecting', 'decoding', 'complete', 'error' - mode: str | None = None - progress_percent: int = 0 - message: str | None = None - image: SSTVImage | None = None - - def to_dict(self) -> dict: - result = { - 'type': 'sstv_progress', - 'status': self.status, - 'progress': self.progress_percent, - } - if self.mode: - result['mode'] = self.mode - if self.message: - result['message'] = self.message - if self.image: - result['image'] = self.image.to_dict() - return result - - -class SSTVDecoder: - """SSTV decoder using external tools (slowrx) with Doppler compensation.""" - - # Minimum frequency change (Hz) before retuning rtl_fm - RETUNE_THRESHOLD_HZ = 500 - - # How often to check/update Doppler (seconds) - DOPPLER_UPDATE_INTERVAL = 5 - - def __init__(self, output_dir: str | Path | None = None): - self._process = None - self._rtl_process = None - self._running = False - self._lock = threading.Lock() - self._callback: Callable[[DecodeProgress], None] | None = None - self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images') - self._images: list[SSTVImage] = [] - self._reader_thread = None - self._watcher_thread = None - self._doppler_thread = None - self._frequency = ISS_SSTV_FREQ - self._current_tuned_freq_hz: int = 0 - self._device_index = 0 - - # Doppler tracking - self._doppler_tracker = DopplerTracker('ISS') - self._doppler_enabled = False - self._last_doppler_info: DopplerInfo | None = None - self._file_decoder: str | None = None - - # Ensure output directory exists - self._output_dir.mkdir(parents=True, exist_ok=True) - - # Detect available decoder - self._decoder = self._detect_decoder() - - @property - def is_running(self) -> bool: - return self._running - - @property - def decoder_available(self) -> str | None: - """Return name of available decoder or None.""" - return self._decoder - - def _detect_decoder(self) -> str | None: - """Detect which SSTV decoder is available.""" - # Check for slowrx (command-line SSTV decoder) - try: - result = subprocess.run(['which', 'slowrx'], capture_output=True, timeout=5) - if result.returncode == 0: - self._file_decoder = 'slowrx' - return 'slowrx' - except Exception: - pass - - # Note: qsstv is GUI-only and not suitable for headless/server operation - - # Check for Python sstv package - try: - import sstv - self._file_decoder = 'python-sstv' - return None - except ImportError: - pass - - logger.warning("No SSTV decoder found. Install slowrx (apt install slowrx) or python sstv package. Note: qsstv is GUI-only and not supported for headless operation.") - return None - - def set_callback(self, callback: Callable[[DecodeProgress], None]) -> None: - """Set callback for decode progress updates.""" - self._callback = callback - - def start( - self, - frequency: float = ISS_SSTV_FREQ, - device_index: int = 0, - latitude: float | None = None, - longitude: float | None = None, - ) -> bool: - """ - Start SSTV decoder listening on specified frequency. - - Args: - frequency: Frequency in MHz (default: 145.800 for ISS) - device_index: RTL-SDR device index - latitude: Observer latitude for Doppler correction (optional) - longitude: Observer longitude for Doppler correction (optional) - - Returns: - True if started successfully - """ - with self._lock: - if self._running: - return True - - if not self._decoder: - logger.error("No SSTV decoder available") - self._emit_progress(DecodeProgress( - status='error', - message='No SSTV decoder installed. Install slowrx: apt install slowrx' - )) - return False - - self._frequency = frequency - self._device_index = device_index - - # Configure Doppler tracking if location provided - self._doppler_enabled = False - if latitude is not None and longitude is not None: - if self._doppler_tracker.configure(latitude, longitude): - self._doppler_enabled = True - logger.info(f"Doppler tracking enabled for location ({latitude}, {longitude})") - else: - logger.warning("Doppler tracking unavailable - using fixed frequency") - - try: - if self._decoder == 'slowrx': - self._start_slowrx() - elif self._decoder == 'python-sstv': - self._start_python_sstv() - else: - logger.error(f"Unsupported decoder: {self._decoder}") - return False - - self._running = True - - # Start Doppler tracking thread if enabled - if self._doppler_enabled: - self._doppler_thread = threading.Thread(target=self._doppler_tracking_loop, daemon=True) - self._doppler_thread.start() - logger.info(f"SSTV decoder started on {frequency} MHz with Doppler tracking") - self._emit_progress(DecodeProgress( - status='detecting', - message=f'Listening on {frequency} MHz with Doppler tracking...' - )) - else: - logger.info(f"SSTV decoder started on {frequency} MHz (no Doppler tracking)") - self._emit_progress(DecodeProgress( - status='detecting', - message=f'Listening on {frequency} MHz...' - )) - - return True - - except Exception as e: - logger.error(f"Failed to start SSTV decoder: {e}") - self._emit_progress(DecodeProgress( - status='error', - message=str(e) - )) - return False - - def _start_slowrx(self) -> None: - """Start slowrx decoder with rtl_fm piped input.""" - # Calculate initial frequency (with Doppler correction if enabled) - freq_hz = self._get_doppler_corrected_freq_hz() - self._current_tuned_freq_hz = freq_hz - - self._start_rtl_fm_pipeline(freq_hz) - - def _get_doppler_corrected_freq_hz(self) -> int: - """Get the Doppler-corrected frequency in Hz.""" - nominal_freq_hz = int(self._frequency * 1_000_000) - - if self._doppler_enabled: - doppler_info = self._doppler_tracker.calculate(self._frequency) - if doppler_info: - self._last_doppler_info = doppler_info - corrected_hz = int(doppler_info.frequency_hz) - logger.info( - f"Doppler correction: {doppler_info.shift_hz:+.1f} Hz " - f"(range rate: {doppler_info.range_rate_km_s:+.3f} km/s, " - f"el: {doppler_info.elevation:.1f}°)" - ) - return corrected_hz - - return nominal_freq_hz - - def _start_rtl_fm_pipeline(self, freq_hz: int) -> None: - """Start the rtl_fm -> slowrx pipeline at the specified frequency.""" - # Build rtl_fm command for FM demodulation - rtl_cmd = [ - 'rtl_fm', - '-d', str(self._device_index), - '-f', str(freq_hz), - '-M', 'fm', - '-s', '48000', - '-r', '48000', - '-l', '0', # No squelch - '-' - ] - - # slowrx reads from stdin and outputs images to directory - slowrx_cmd = [ - 'slowrx', - '-o', str(self._output_dir), - '-' - ] - - logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}") - logger.info(f"Piping to slowrx: {' '.join(slowrx_cmd)}") - - # Start rtl_fm - self._rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - # Start slowrx reading from rtl_fm - self._process = subprocess.Popen( - slowrx_cmd, - stdin=self._rtl_process.stdout, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - # Start reader thread to monitor output - self._reader_thread = threading.Thread(target=self._read_slowrx_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.start() - - def _doppler_tracking_loop(self) -> None: - """Background thread that monitors Doppler shift and retunes when needed.""" - logger.info("Doppler tracking thread started") - - while self._running and self._doppler_enabled: - time.sleep(self.DOPPLER_UPDATE_INTERVAL) - - if not self._running: - break - - try: - doppler_info = self._doppler_tracker.calculate(self._frequency) - if not doppler_info: - continue - - self._last_doppler_info = doppler_info - new_freq_hz = int(doppler_info.frequency_hz) - freq_diff = abs(new_freq_hz - self._current_tuned_freq_hz) - - # Log current Doppler status - logger.debug( - f"Doppler: {doppler_info.shift_hz:+.1f} Hz, " - f"el: {doppler_info.elevation:.1f}°, " - f"diff from tuned: {freq_diff} Hz" - ) - - # Emit Doppler update to callback - self._emit_progress(DecodeProgress( - status='detecting', - message=f'Doppler: {doppler_info.shift_hz:+.0f} Hz, elevation: {doppler_info.elevation:.1f}°' - )) - - # Retune if frequency has drifted enough - if freq_diff >= self.RETUNE_THRESHOLD_HZ: - logger.info( - f"Retuning: {self._current_tuned_freq_hz} -> {new_freq_hz} Hz " - f"(Doppler shift: {doppler_info.shift_hz:+.1f} Hz)" - ) - self._retune_rtl_fm(new_freq_hz) - - except Exception as e: - logger.error(f"Doppler tracking error: {e}") - - logger.info("Doppler tracking thread stopped") - - def _retune_rtl_fm(self, new_freq_hz: int) -> None: - """ - Retune rtl_fm to a new frequency. - - Since rtl_fm doesn't support dynamic frequency changes, we need to - restart the rtl_fm process. The slowrx process continues running - and will resume decoding when audio resumes. - """ - with self._lock: - if not self._running: - return - - # Terminate old rtl_fm process - if self._rtl_process: - try: - self._rtl_process.terminate() - self._rtl_process.wait(timeout=2) - except Exception: - try: - self._rtl_process.kill() - except Exception: - pass - - # Start new rtl_fm at new frequency - rtl_cmd = [ - 'rtl_fm', - '-d', str(self._device_index), - '-f', str(new_freq_hz), - '-M', 'fm', - '-s', '48000', - '-r', '48000', - '-l', '0', - '-' - ] - - logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}") - - self._rtl_process = subprocess.Popen( - rtl_cmd, - stdout=self._process.stdin if self._process else subprocess.PIPE, - stderr=subprocess.PIPE - ) - - self._current_tuned_freq_hz = new_freq_hz - - @property - def last_doppler_info(self) -> DopplerInfo | None: - """Get the most recent Doppler calculation.""" - return self._last_doppler_info - - @property - def doppler_enabled(self) -> bool: - """Check if Doppler tracking is enabled.""" - return self._doppler_enabled - - def _start_python_sstv(self) -> None: - """Start Python SSTV decoder (requires audio file input).""" - # Python sstv package typically works with audio files - # For real-time decoding, we'd need to record audio first - # This is a simplified implementation - logger.warning("Python SSTV package requires audio file input") - self._emit_progress(DecodeProgress( - status='error', - message='Python SSTV decoder requires audio files. Use slowrx for real-time decoding.' - )) - raise NotImplementedError("Real-time Python SSTV not implemented") - - def _read_slowrx_output(self) -> None: - """Read slowrx stderr for progress updates.""" - if not self._process: - return - - try: - for line in iter(self._process.stderr.readline, b''): - if not self._running: - break - - line_str = line.decode('utf-8', errors='ignore').strip() - if not line_str: - continue - - logger.debug(f"slowrx: {line_str}") - - # Parse slowrx output for mode detection and progress - if 'Detected' in line_str or 'mode' in line_str.lower(): - for mode in SSTV_MODES: - if mode.lower() in line_str.lower(): - self._emit_progress(DecodeProgress( - status='decoding', - mode=mode, - message=f'Decoding {mode} image...' - )) - break - - except Exception as e: - logger.error(f"Error reading slowrx output: {e}") - - def _watch_images(self) -> None: - """Watch output directory for new images.""" - known_files = set(f.name for f in self._output_dir.glob('*.png')) - - while self._running: - time.sleep(1) - - try: - current_files = set(f.name for f in self._output_dir.glob('*.png')) - new_files = current_files - known_files - - for filename in new_files: - filepath = self._output_dir / filename - if filepath.exists(): - # New image detected - image = SSTVImage( - filename=filename, - path=filepath, - mode='Unknown', # Would need to parse from slowrx output - timestamp=datetime.now(timezone.utc), - frequency=self._frequency, - size_bytes=filepath.stat().st_size - ) - self._images.append(image) - - logger.info(f"New SSTV image: {filename}") - self._emit_progress(DecodeProgress( - status='complete', - message='Image decoded', - image=image - )) - - known_files = current_files - - except Exception as e: - logger.error(f"Error watching images: {e}") - - def stop(self) -> None: - """Stop SSTV decoder.""" - with self._lock: - self._running = False - - if hasattr(self, '_rtl_process') and self._rtl_process: - try: - self._rtl_process.terminate() - self._rtl_process.wait(timeout=5) - except Exception: - self._rtl_process.kill() - self._rtl_process = None - - if self._process: - try: - self._process.terminate() - self._process.wait(timeout=5) - except Exception: - self._process.kill() - self._process = None - - logger.info("SSTV decoder stopped") - - def get_images(self) -> list[SSTVImage]: - """Get list of decoded images.""" - # Also scan directory for any images we might have missed - self._scan_images() - return list(self._images) - - def _scan_images(self) -> None: - """Scan output directory for images.""" - known_filenames = {img.filename for img in self._images} - - for filepath in self._output_dir.glob('*.png'): - if filepath.name not in known_filenames: - try: - stat = filepath.stat() - image = SSTVImage( - filename=filepath.name, - path=filepath, - mode='Unknown', - timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), - frequency=ISS_SSTV_FREQ, - size_bytes=stat.st_size - ) - self._images.append(image) - except Exception as e: - logger.warning(f"Error scanning image {filepath}: {e}") - - def _emit_progress(self, progress: DecodeProgress) -> None: - """Emit progress update to callback.""" - if self._callback: - try: - self._callback(progress) - except Exception as e: - logger.error(f"Error in progress callback: {e}") - - def decode_file(self, audio_path: str | Path) -> list[SSTVImage]: - """ - Decode SSTV image from audio file. - - Args: - audio_path: Path to WAV audio file - - Returns: - List of decoded images - """ - audio_path = Path(audio_path) - if not audio_path.exists(): - raise FileNotFoundError(f"Audio file not found: {audio_path}") - - images = [] - - decoder = self._decoder or self._file_decoder - - if decoder == 'slowrx': - # Use slowrx with file input - output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" - - cmd = ['slowrx', '-o', str(self._output_dir), str(audio_path)] - result = subprocess.run(cmd, capture_output=True, timeout=300) - - if result.returncode == 0: - # Check for new images - for filepath in self._output_dir.glob('*.png'): - stat = filepath.stat() - if stat.st_mtime > time.time() - 60: # Created in last minute - image = SSTVImage( - filename=filepath.name, - path=filepath, - mode='Unknown', - timestamp=datetime.now(timezone.utc), - frequency=0, - size_bytes=stat.st_size - ) - images.append(image) - - elif decoder == 'python-sstv': - # Use Python sstv library - try: - from sstv.decode import SSTVDecoder as PythonSSTVDecoder - from PIL import Image - - decoder = PythonSSTVDecoder(str(audio_path)) - img = decoder.decode() - - if img: - output_file = self._output_dir / f"sstv_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" - img.save(output_file) - - image = SSTVImage( - filename=output_file.name, - path=output_file, - mode=decoder.mode or 'Unknown', - timestamp=datetime.now(timezone.utc), - frequency=0, - size_bytes=output_file.stat().st_size - ) - images.append(image) - - except ImportError: - logger.error("Python sstv package not properly installed") - except Exception as e: - logger.error(f"Error decoding with Python sstv: {e}") - - return images - - -# Global decoder instance -_decoder: SSTVDecoder | None = None - - -def get_sstv_decoder() -> SSTVDecoder: - """Get or create the global SSTV decoder instance.""" - global _decoder - if _decoder is None: - _decoder = SSTVDecoder() - return _decoder - - -def is_sstv_available() -> bool: - """Check if SSTV decoding is available.""" - decoder = get_sstv_decoder() - return decoder.decoder_available is not None diff --git a/utils/sstv/__init__.py b/utils/sstv/__init__.py new file mode 100644 index 0000000..092d9a8 --- /dev/null +++ b/utils/sstv/__init__.py @@ -0,0 +1,33 @@ +"""SSTV (Slow-Scan Television) decoder package. + +Pure Python SSTV decoder using Goertzel-based DSP for VIS header detection +and scanline-by-scanline image decoding. Supports Robot36/72, Martin1/2, +Scottie1/2, and PD120/180 modes. + +Replaces the external slowrx dependency with numpy/scipy + Pillow. +""" + +from .constants import ISS_SSTV_FREQ, SSTV_MODES +from .sstv_decoder import ( + DecodeProgress, + DopplerInfo, + DopplerTracker, + SSTVDecoder, + SSTVImage, + get_general_sstv_decoder, + get_sstv_decoder, + is_sstv_available, +) + +__all__ = [ + 'DecodeProgress', + 'DopplerInfo', + 'DopplerTracker', + 'ISS_SSTV_FREQ', + 'SSTV_MODES', + 'SSTVDecoder', + 'SSTVImage', + 'get_general_sstv_decoder', + 'get_sstv_decoder', + 'is_sstv_available', +] diff --git a/utils/sstv/constants.py b/utils/sstv/constants.py new file mode 100644 index 0000000..e4d56d9 --- /dev/null +++ b/utils/sstv/constants.py @@ -0,0 +1,92 @@ +"""SSTV protocol constants. + +VIS (Vertical Interval Signaling) codes, frequency assignments, and timing +constants for all supported SSTV modes per the SSTV protocol specification. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Audio / DSP +# --------------------------------------------------------------------------- +SAMPLE_RATE = 48000 # Hz - standard audio sample rate used by rtl_fm + +# Window size for Goertzel tone detection (5 ms at 48 kHz = 240 samples) +GOERTZEL_WINDOW = 240 + +# Chunk size for reading from rtl_fm (100 ms = 4800 samples) +STREAM_CHUNK_SAMPLES = 4800 + +# --------------------------------------------------------------------------- +# SSTV tone frequencies (Hz) +# --------------------------------------------------------------------------- +FREQ_VIS_BIT_1 = 1100 # VIS logic 1 +FREQ_SYNC = 1200 # Horizontal sync pulse +FREQ_VIS_BIT_0 = 1300 # VIS logic 0 +FREQ_BREAK = 1200 # Break tone in VIS header (same as sync) +FREQ_LEADER = 1900 # Leader / calibration tone +FREQ_BLACK = 1500 # Black level +FREQ_WHITE = 2300 # White level + +# Pixel luminance mapping range +FREQ_PIXEL_LOW = 1500 # 0 luminance +FREQ_PIXEL_HIGH = 2300 # 255 luminance + +# Frequency tolerance for tone detection (Hz) +FREQ_TOLERANCE = 50 + +# --------------------------------------------------------------------------- +# VIS header timing (seconds) +# --------------------------------------------------------------------------- +VIS_LEADER_MIN = 0.200 # Minimum leader tone duration +VIS_LEADER_MAX = 0.500 # Maximum leader tone duration +VIS_LEADER_NOMINAL = 0.300 # Nominal leader tone duration +VIS_BREAK_DURATION = 0.010 # Break pulse duration (10 ms) +VIS_BIT_DURATION = 0.030 # Each VIS data bit (30 ms) +VIS_START_BIT_DURATION = 0.030 # Start bit (30 ms) +VIS_STOP_BIT_DURATION = 0.030 # Stop bit (30 ms) + +# Timing tolerance for VIS detection +VIS_TIMING_TOLERANCE = 0.5 # 50% tolerance on durations + +# --------------------------------------------------------------------------- +# VIS code → mode name mapping +# --------------------------------------------------------------------------- +VIS_CODES: dict[int, str] = { + 8: 'Robot36', + 12: 'Robot72', + 44: 'Martin1', + 40: 'Martin2', + 60: 'Scottie1', + 56: 'Scottie2', + 93: 'PD120', + 95: 'PD180', + # Less common but recognized + 4: 'Robot24', + 36: 'Martin3', + 52: 'Scottie3', + 55: 'ScottieDX', + 113: 'PD240', + 96: 'PD90', + 98: 'PD160', +} + +# Reverse mapping: mode name → VIS code +MODE_TO_VIS: dict[str, int] = {v: k for k, v in VIS_CODES.items()} + +# --------------------------------------------------------------------------- +# Common SSTV modes list (for UI / status) +# --------------------------------------------------------------------------- +SSTV_MODES = [ + 'PD120', 'PD180', 'Martin1', 'Martin2', + 'Scottie1', 'Scottie2', 'Robot36', 'Robot72', +] + +# ISS SSTV frequency +ISS_SSTV_FREQ = 145.800 # MHz + +# Speed of light in m/s +SPEED_OF_LIGHT = 299_792_458 + +# Minimum energy ratio for valid tone detection (vs noise floor) +MIN_ENERGY_RATIO = 5.0 diff --git a/utils/sstv/dsp.py b/utils/sstv/dsp.py new file mode 100644 index 0000000..3c1782a --- /dev/null +++ b/utils/sstv/dsp.py @@ -0,0 +1,232 @@ +"""DSP utilities for SSTV decoding. + +Goertzel algorithm for efficient single-frequency energy detection, +frequency estimation, and frequency-to-pixel luminance mapping. +""" + +from __future__ import annotations + +import math + +import numpy as np + +from .constants import ( + FREQ_PIXEL_HIGH, + FREQ_PIXEL_LOW, + MIN_ENERGY_RATIO, + SAMPLE_RATE, +) + + +def goertzel(samples: np.ndarray, target_freq: float, + sample_rate: int = SAMPLE_RATE) -> float: + """Compute Goertzel energy at a single target frequency. + + O(N) per frequency - more efficient than FFT when only a few + frequencies are needed. + + Args: + samples: Audio samples (float64, -1.0 to 1.0). + target_freq: Frequency to detect (Hz). + sample_rate: Sample rate (Hz). + + Returns: + Magnitude squared (energy) at the target frequency. + """ + n = len(samples) + if n == 0: + return 0.0 + + # Generalized Goertzel (DTFT): use exact target frequency rather than + # rounding to the nearest DFT bin. This is critical for short windows + # (e.g. 13 samples/pixel) where integer-k Goertzel quantizes all SSTV + # pixel frequencies into 1-2 bins, making estimation impossible. + w = 2.0 * math.pi * target_freq / sample_rate + coeff = 2.0 * math.cos(w) + + s0 = 0.0 + s1 = 0.0 + s2 = 0.0 + + for sample in samples: + s0 = sample + coeff * s1 - s2 + s2 = s1 + s1 = s0 + + return s1 * s1 + s2 * s2 - coeff * s1 * s2 + + +def goertzel_mag(samples: np.ndarray, target_freq: float, + sample_rate: int = SAMPLE_RATE) -> float: + """Compute Goertzel magnitude (square root of energy). + + Args: + samples: Audio samples. + target_freq: Frequency to detect (Hz). + sample_rate: Sample rate (Hz). + + Returns: + Magnitude at the target frequency. + """ + return math.sqrt(max(0.0, goertzel(samples, target_freq, sample_rate))) + + +def detect_tone(samples: np.ndarray, candidates: list[float], + sample_rate: int = SAMPLE_RATE) -> tuple[float | None, float]: + """Detect which candidate frequency has the strongest energy. + + Args: + samples: Audio samples. + candidates: List of candidate frequencies (Hz). + sample_rate: Sample rate (Hz). + + Returns: + Tuple of (detected_frequency or None, energy_ratio). + Returns None if no tone significantly dominates. + """ + if len(samples) == 0 or not candidates: + return None, 0.0 + + energies = {f: goertzel(samples, f, sample_rate) for f in candidates} + max_freq = max(energies, key=energies.get) # type: ignore[arg-type] + max_energy = energies[max_freq] + + if max_energy <= 0: + return None, 0.0 + + # Calculate ratio of strongest to average of others + others = [e for f, e in energies.items() if f != max_freq] + avg_others = sum(others) / len(others) if others else 0.0 + + ratio = max_energy / avg_others if avg_others > 0 else float('inf') + + if ratio >= MIN_ENERGY_RATIO: + return max_freq, ratio + return None, ratio + + +def estimate_frequency(samples: np.ndarray, freq_low: float = 1000.0, + freq_high: float = 2500.0, step: float = 25.0, + sample_rate: int = SAMPLE_RATE) -> float: + """Estimate the dominant frequency in a range using Goertzel sweep. + + Sweeps through frequencies in the given range and returns the one + with maximum energy. Uses a coarse sweep followed by a fine sweep + for accuracy. + + Args: + samples: Audio samples. + freq_low: Lower bound of frequency range (Hz). + freq_high: Upper bound of frequency range (Hz). + step: Coarse step size (Hz). + sample_rate: Sample rate (Hz). + + Returns: + Estimated dominant frequency (Hz). + """ + if len(samples) == 0: + return 0.0 + + # Coarse sweep + best_freq = freq_low + best_energy = 0.0 + + freq = freq_low + while freq <= freq_high: + energy = goertzel(samples, freq, sample_rate) + if energy > best_energy: + best_energy = energy + best_freq = freq + freq += step + + # Fine sweep around the coarse peak (+/- one step, 5 Hz resolution) + fine_low = max(freq_low, best_freq - step) + fine_high = min(freq_high, best_freq + step) + freq = fine_low + while freq <= fine_high: + energy = goertzel(samples, freq, sample_rate) + if energy > best_energy: + best_energy = energy + best_freq = freq + freq += 5.0 + + return best_freq + + +def freq_to_pixel(frequency: float) -> int: + """Convert SSTV audio frequency to pixel luminance value (0-255). + + Linear mapping: 1500 Hz = 0 (black), 2300 Hz = 255 (white). + + Args: + frequency: Detected frequency (Hz). + + Returns: + Pixel value clamped to 0-255. + """ + normalized = (frequency - FREQ_PIXEL_LOW) / (FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW) + return max(0, min(255, int(normalized * 255 + 0.5))) + + +def samples_for_duration(duration_s: float, + sample_rate: int = SAMPLE_RATE) -> int: + """Calculate number of samples for a given duration. + + Args: + duration_s: Duration in seconds. + sample_rate: Sample rate (Hz). + + Returns: + Number of samples. + """ + return int(duration_s * sample_rate + 0.5) + + +def goertzel_batch(audio_matrix: np.ndarray, frequencies: np.ndarray, + sample_rate: int = SAMPLE_RATE) -> np.ndarray: + """Compute Goertzel energy for multiple audio segments at multiple frequencies. + + Vectorized implementation using numpy broadcasting. Processes all + pixel windows and all candidate frequencies simultaneously, giving + roughly 50-100x speed-up over the scalar ``goertzel`` called in a + Python loop. + + Args: + audio_matrix: Shape (M, N) – M audio segments of N samples each. + frequencies: 1-D array of F target frequencies in Hz. + sample_rate: Sample rate in Hz. + + Returns: + Shape (M, F) array of energy values. + """ + if audio_matrix.size == 0 or len(frequencies) == 0: + return np.zeros((audio_matrix.shape[0], len(frequencies))) + + _M, N = audio_matrix.shape + + # Generalized Goertzel (DTFT): exact target frequencies, no bin rounding + w = 2.0 * np.pi * frequencies / sample_rate + coeff = 2.0 * np.cos(w) # (F,) + + s1 = np.zeros((audio_matrix.shape[0], len(frequencies))) + s2 = np.zeros_like(s1) + + for n in range(N): + samples_n = audio_matrix[:, n:n + 1] # (M, 1) — broadcasts with (M, F) + s0 = samples_n + coeff * s1 - s2 + s2 = s1 + s1 = s0 + + return s1 * s1 + s2 * s2 - coeff * s1 * s2 + + +def normalize_audio(raw: np.ndarray) -> np.ndarray: + """Normalize int16 PCM audio to float64 in range [-1.0, 1.0]. + + Args: + raw: Raw int16 samples from rtl_fm. + + Returns: + Float64 normalized samples. + """ + return raw.astype(np.float64) / 32768.0 diff --git a/utils/sstv/image_decoder.py b/utils/sstv/image_decoder.py new file mode 100644 index 0000000..c7f2391 --- /dev/null +++ b/utils/sstv/image_decoder.py @@ -0,0 +1,460 @@ +"""SSTV scanline-by-scanline image decoder. + +Decodes raw audio samples into a PIL Image for all supported SSTV modes. +Handles sync pulse re-synchronization on each line for robust decoding +under weak-signal or drifting conditions. +""" + +from __future__ import annotations + +from typing import Callable + +import numpy as np + +from .constants import ( + FREQ_BLACK, + FREQ_PIXEL_HIGH, + FREQ_PIXEL_LOW, + FREQ_SYNC, + SAMPLE_RATE, +) +from .dsp import ( + goertzel, + goertzel_batch, + samples_for_duration, +) +from .modes import ( + ColorModel, + SSTVMode, + SyncPosition, +) + +# Pillow is imported lazily to keep the module importable when Pillow +# is not installed (is_sstv_available() just returns True, but actual +# decoding would fail gracefully). +try: + from PIL import Image +except ImportError: + Image = None # type: ignore[assignment,misc] + + +# Type alias for progress callback: (current_line, total_lines) +ProgressCallback = Callable[[int, int], None] + + +class SSTVImageDecoder: + """Decode an SSTV image from a stream of audio samples. + + Usage:: + + decoder = SSTVImageDecoder(mode) + decoder.feed(samples) + ... + if decoder.is_complete: + image = decoder.get_image() + """ + + def __init__(self, mode: SSTVMode, sample_rate: int = SAMPLE_RATE, + progress_cb: ProgressCallback | None = None): + self._mode = mode + self._sample_rate = sample_rate + self._progress_cb = progress_cb + + self._buffer = np.array([], dtype=np.float64) + self._current_line = 0 + self._complete = False + + # Pre-calculate sample counts + self._sync_samples = samples_for_duration( + mode.sync_duration_ms / 1000.0, sample_rate) + self._porch_samples = samples_for_duration( + mode.sync_porch_ms / 1000.0, sample_rate) + self._line_samples = samples_for_duration( + mode.line_duration_ms / 1000.0, sample_rate) + self._separator_samples = ( + samples_for_duration(mode.channel_separator_ms / 1000.0, sample_rate) + if mode.channel_separator_ms > 0 else 0 + ) + + self._channel_samples = [ + samples_for_duration(ch.duration_ms / 1000.0, sample_rate) + for ch in mode.channels + ] + + # For PD modes, each "line" of audio produces 2 image lines + if mode.color_model == ColorModel.YCRCB_DUAL: + self._total_audio_lines = mode.height // 2 + else: + self._total_audio_lines = mode.height + + # Initialize pixel data arrays per channel + self._channel_data: list[np.ndarray] = [] + for _i, _ch_spec in enumerate(mode.channels): + if mode.color_model == ColorModel.YCRCB_DUAL: + # Y1, Cr, Cb, Y2 - all are width-wide + self._channel_data.append( + np.zeros((self._total_audio_lines, mode.width), dtype=np.uint8)) + else: + self._channel_data.append( + np.zeros((mode.height, mode.width), dtype=np.uint8)) + + # Pre-compute candidate frequencies for batch pixel decoding (5 Hz step) + self._freq_candidates = np.arange( + FREQ_PIXEL_LOW - 100, FREQ_PIXEL_HIGH + 105, 5.0) + + # Track sync position for re-synchronization + self._expected_line_start = 0 # Sample offset within buffer + self._synced = False + + @property + def is_complete(self) -> bool: + return self._complete + + @property + def current_line(self) -> int: + return self._current_line + + @property + def total_lines(self) -> int: + return self._total_audio_lines + + @property + def progress_percent(self) -> int: + if self._total_audio_lines == 0: + return 0 + return min(100, int(100 * self._current_line / self._total_audio_lines)) + + def feed(self, samples: np.ndarray) -> bool: + """Feed audio samples into the decoder. + + Args: + samples: Float64 audio samples. + + Returns: + True when image is complete. + """ + if self._complete: + return True + + self._buffer = np.concatenate([self._buffer, samples]) + + # Process complete lines. + # Guard against stalls: if _decode_line() cannot consume data + # (e.g. sub-component samples exceed line_samples due to rounding), + # break out and wait for more audio. + while not self._complete and len(self._buffer) >= self._line_samples: + prev_line = self._current_line + prev_len = len(self._buffer) + self._decode_line() + if self._current_line == prev_line and len(self._buffer) == prev_len: + break # No progress — need more data + + # Prevent unbounded buffer growth - keep at most 2 lines worth + max_buffer = self._line_samples * 2 + if len(self._buffer) > max_buffer and not self._complete: + self._buffer = self._buffer[-max_buffer:] + + return self._complete + + def _find_sync(self, search_region: np.ndarray) -> int | None: + """Find the 1200 Hz sync pulse within a search region. + + Scans through the region looking for a stretch of 1200 Hz + tone of approximately the right duration. + + Args: + search_region: Audio samples to search within. + + Returns: + Sample offset of the sync pulse start, or None if not found. + """ + window_size = min(self._sync_samples, 200) + if len(search_region) < window_size: + return None + + best_pos = None + best_energy = 0.0 + + step = window_size // 2 + for pos in range(0, len(search_region) - window_size, step): + chunk = search_region[pos:pos + window_size] + sync_energy = goertzel(chunk, FREQ_SYNC, self._sample_rate) + # Check it's actually sync, not data at 1200 Hz area + black_energy = goertzel(chunk, FREQ_BLACK, self._sample_rate) + if sync_energy > best_energy and sync_energy > black_energy * 2: + best_energy = sync_energy + best_pos = pos + + return best_pos + + def _decode_line(self) -> None: + """Decode one scanline from the buffer.""" + if self._current_line >= self._total_audio_lines: + self._complete = True + return + + # Try to find sync pulse for re-synchronization + # Search within +/-10% of expected line start + search_margin = max(100, self._line_samples // 10) + + line_start = 0 + + if self._mode.sync_position in (SyncPosition.FRONT, SyncPosition.FRONT_PD): + # Sync is at the beginning of each line + search_start = 0 + search_end = min(len(self._buffer), self._sync_samples + search_margin) + search_region = self._buffer[search_start:search_end] + + sync_pos = self._find_sync(search_region) + if sync_pos is not None: + line_start = sync_pos + # Skip sync + porch to get to pixel data + pixel_start = line_start + self._sync_samples + self._porch_samples + + elif self._mode.sync_position == SyncPosition.MIDDLE: + # Scottie: sep(1.5ms) -> G -> sep(1.5ms) -> B -> sync(9ms) -> porch(1.5ms) -> R + # Skip initial separator (same duration as porch) + pixel_start = self._porch_samples + line_start = 0 + + else: + pixel_start = self._sync_samples + self._porch_samples + + # Decode each channel + pos = pixel_start + for ch_idx, ch_samples in enumerate(self._channel_samples): + if pos + ch_samples > len(self._buffer): + # Not enough data yet - put the data back and wait + return + + channel_audio = self._buffer[pos:pos + ch_samples] + pixels = self._decode_channel_pixels(channel_audio) + self._channel_data[ch_idx][self._current_line, :] = pixels + pos += ch_samples + + # Add inter-channel gaps based on mode family + if ch_idx < len(self._channel_samples) - 1: + if self._mode.sync_position == SyncPosition.MIDDLE: + if ch_idx == 0: + # Scottie: separator between G and B + pos += self._porch_samples + else: + # Scottie: sync + porch between B and R + pos += self._sync_samples + self._porch_samples + elif self._separator_samples > 0: + # Robot: separator + porch between channels + pos += self._separator_samples + elif (self._mode.sync_position == SyncPosition.FRONT + and self._mode.color_model == ColorModel.RGB): + # Martin: porch between channels + pos += self._porch_samples + + # Advance buffer past this line + consumed = max(pos, self._line_samples) + self._buffer = self._buffer[consumed:] + + self._current_line += 1 + + if self._progress_cb: + self._progress_cb(self._current_line, self._total_audio_lines) + + if self._current_line >= self._total_audio_lines: + self._complete = True + + # Minimum analysis window for meaningful Goertzel frequency estimation. + # With 96 samples (2ms at 48kHz), frequency accuracy is within ~25 Hz, + # giving pixel-level accuracy of ~8/255 levels. + _MIN_ANALYSIS_WINDOW = 96 + + def _decode_channel_pixels(self, audio: np.ndarray) -> np.ndarray: + """Decode pixel values from a channel's audio data. + + Uses batch Goertzel to estimate frequencies for all pixels + simultaneously, then maps to luminance values. When pixels have + fewer samples than ``_MIN_ANALYSIS_WINDOW``, overlapping analysis + windows are used to maintain frequency estimation accuracy. + + Args: + audio: Audio samples for one channel of one scanline. + + Returns: + Array of pixel values (0-255), shape (width,). + """ + width = self._mode.width + samples_per_pixel = max(1, len(audio) // width) + + if len(audio) < width or samples_per_pixel < 2: + return np.zeros(width, dtype=np.uint8) + + window_size = max(samples_per_pixel, self._MIN_ANALYSIS_WINDOW) + + if window_size > samples_per_pixel and len(audio) >= window_size: + # Use overlapping windows centered on each pixel position + windows = np.lib.stride_tricks.sliding_window_view( + audio, window_size) + # Pixel centers, clamped to valid window indices + centers = np.arange(width) * samples_per_pixel + indices = np.minimum(centers, len(windows) - 1) + audio_matrix = np.ascontiguousarray(windows[indices]) + else: + # Non-overlapping: each pixel has enough samples + usable = width * samples_per_pixel + audio_matrix = audio[:usable].reshape(width, samples_per_pixel) + + # Batch Goertzel at all candidate frequencies + energies = goertzel_batch( + audio_matrix, self._freq_candidates, self._sample_rate) + + # Find peak frequency per pixel + best_idx = np.argmax(energies, axis=1) + best_freqs = self._freq_candidates[best_idx] + + # Map frequencies to pixel values (1500 Hz = 0, 2300 Hz = 255) + normalized = (best_freqs - FREQ_PIXEL_LOW) / (FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW) + return np.clip(normalized * 255 + 0.5, 0, 255).astype(np.uint8) + + def get_image(self) -> Image.Image | None: + """Convert decoded channel data to a PIL Image. + + Returns: + PIL Image in RGB mode, or None if Pillow is not available + or decoding is incomplete. + """ + if Image is None: + return None + + mode = self._mode + + if mode.color_model == ColorModel.RGB: + return self._assemble_rgb() + elif mode.color_model == ColorModel.YCRCB: + return self._assemble_ycrcb() + elif mode.color_model == ColorModel.YCRCB_DUAL: + return self._assemble_ycrcb_dual() + + return None + + def _assemble_rgb(self) -> Image.Image: + """Assemble RGB image from sequential R, G, B channel data. + + Martin/Scottie channel order: G, B, R. + """ + height = self._mode.height + + # Channel order for Martin/Scottie: [0]=G, [1]=B, [2]=R + g_data = self._channel_data[0][:height] + b_data = self._channel_data[1][:height] + r_data = self._channel_data[2][:height] + + rgb = np.stack([r_data, g_data, b_data], axis=-1) + return Image.fromarray(rgb, 'RGB') + + def _assemble_ycrcb(self) -> Image.Image: + """Assemble image from YCrCb data (Robot modes). + + Robot36: Y every line, Cr/Cb alternating (half-rate chroma). + Robot72: Y, Cr, Cb every line (full-rate chroma). + """ + height = self._mode.height + width = self._mode.width + + if not self._mode.has_half_rate_chroma: + # Full-rate chroma (Robot72): Y, Cr, Cb as separate channels + y_data = self._channel_data[0][:height].astype(np.float64) + cr = self._channel_data[1][:height].astype(np.float64) + cb = self._channel_data[2][:height].astype(np.float64) + return self._ycrcb_to_rgb(y_data, cr, cb, height, width) + + # Half-rate chroma (Robot36): Y + alternating Cr/Cb + y_data = self._channel_data[0][:height].astype(np.float64) + chroma_data = self._channel_data[1][:height].astype(np.float64) + + # Separate Cr (even lines) and Cb (odd lines), then interpolate + cr = np.zeros((height, width), dtype=np.float64) + cb = np.zeros((height, width), dtype=np.float64) + + for line in range(height): + if line % 2 == 0: + cr[line] = chroma_data[line] + else: + cb[line] = chroma_data[line] + + # Interpolate missing chroma lines + for line in range(height): + if line % 2 == 1: + # Missing Cr - interpolate from neighbors + prev_cr = line - 1 if line > 0 else line + 1 + next_cr = line + 1 if line + 1 < height else line - 1 + cr[line] = (cr[prev_cr] + cr[next_cr]) / 2 + else: + # Missing Cb - interpolate from neighbors + prev_cb = line - 1 if line > 0 else line + 1 + next_cb = line + 1 if line + 1 < height else line - 1 + if prev_cb >= 0 and next_cb < height: + cb[line] = (cb[prev_cb] + cb[next_cb]) / 2 + elif prev_cb >= 0: + cb[line] = cb[prev_cb] + else: + cb[line] = cb[next_cb] + + return self._ycrcb_to_rgb(y_data, cr, cb, height, width) + + def _assemble_ycrcb_dual(self) -> Image.Image: + """Assemble image from dual-luminance YCrCb data (PD modes). + + PD modes send Y1, Cr, Cb, Y2 per audio line, producing 2 image lines. + """ + audio_lines = self._total_audio_lines + width = self._mode.width + height = self._mode.height + + y1_data = self._channel_data[0][:audio_lines].astype(np.float64) + cr_data = self._channel_data[1][:audio_lines].astype(np.float64) + cb_data = self._channel_data[2][:audio_lines].astype(np.float64) + y2_data = self._channel_data[3][:audio_lines].astype(np.float64) + + # Interleave Y1 and Y2 to produce full-height luminance + y_full = np.zeros((height, width), dtype=np.float64) + cr_full = np.zeros((height, width), dtype=np.float64) + cb_full = np.zeros((height, width), dtype=np.float64) + + for i in range(audio_lines): + even_line = i * 2 + odd_line = i * 2 + 1 + if even_line < height: + y_full[even_line] = y1_data[i] + cr_full[even_line] = cr_data[i] + cb_full[even_line] = cb_data[i] + if odd_line < height: + y_full[odd_line] = y2_data[i] + cr_full[odd_line] = cr_data[i] + cb_full[odd_line] = cb_data[i] + + return self._ycrcb_to_rgb(y_full, cr_full, cb_full, height, width) + + @staticmethod + def _ycrcb_to_rgb(y: np.ndarray, cr: np.ndarray, cb: np.ndarray, + height: int, width: int) -> Image.Image: + """Convert YCrCb pixel data to an RGB PIL Image. + + Uses the SSTV convention where pixel values 0-255 map to the + standard Y'CbCr color space used by JPEG/SSTV. + """ + # Normalize from 0-255 pixel range to standard ranges + # Y: 0-255, Cr/Cb: 0-255 centered at 128 + y_norm = y + cr_norm = cr - 128.0 + cb_norm = cb - 128.0 + + # ITU-R BT.601 conversion + r = y_norm + 1.402 * cr_norm + g = y_norm - 0.344136 * cb_norm - 0.714136 * cr_norm + b = y_norm + 1.772 * cb_norm + + # Clip and convert + r = np.clip(r, 0, 255).astype(np.uint8) + g = np.clip(g, 0, 255).astype(np.uint8) + b = np.clip(b, 0, 255).astype(np.uint8) + + rgb = np.stack([r, g, b], axis=-1) + return Image.fromarray(rgb, 'RGB') diff --git a/utils/sstv/modes.py b/utils/sstv/modes.py new file mode 100644 index 0000000..6d2d7b4 --- /dev/null +++ b/utils/sstv/modes.py @@ -0,0 +1,250 @@ +"""SSTV mode specifications. + +Dataclass definitions for each supported SSTV mode, encoding resolution, +color model, line timing, and sync characteristics. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field + + +class ColorModel(enum.Enum): + """Color encoding models used by SSTV modes.""" + RGB = 'rgb' # Sequential R, G, B channels per line + YCRCB = 'ycrcb' # Luminance + chrominance (Robot modes) + YCRCB_DUAL = 'ycrcb_dual' # Dual-luminance YCrCb (PD modes) + + +class SyncPosition(enum.Enum): + """Where the horizontal sync pulse appears in each line.""" + FRONT = 'front' # Sync at start of line (Robot, Martin) + MIDDLE = 'middle' # Sync between G and B channels (Scottie) + FRONT_PD = 'front_pd' # PD-style sync at start + + +@dataclass(frozen=True) +class ChannelTiming: + """Timing for a single color channel within a scanline. + + Attributes: + duration_ms: Duration of this channel's pixel data in milliseconds. + """ + duration_ms: float + + +@dataclass(frozen=True) +class SSTVMode: + """Complete specification of an SSTV mode. + + Attributes: + name: Human-readable mode name (e.g. 'Robot36'). + vis_code: VIS code that identifies this mode. + width: Image width in pixels. + height: Image height in lines. + color_model: Color encoding model. + sync_position: Where the sync pulse falls in each line. + sync_duration_ms: Horizontal sync pulse duration (ms). + sync_porch_ms: Porch (gap) after sync pulse (ms). + channels: Timing for each color channel per line. + line_duration_ms: Total duration of one complete scanline (ms). + has_half_rate_chroma: Whether chroma is sent at half vertical rate + (Robot modes: Cr and Cb alternate every other line). + """ + name: str + vis_code: int + width: int + height: int + color_model: ColorModel + sync_position: SyncPosition + sync_duration_ms: float + sync_porch_ms: float + channels: list[ChannelTiming] = field(default_factory=list) + line_duration_ms: float = 0.0 + has_half_rate_chroma: bool = False + channel_separator_ms: float = 0.0 # Time gap between color channels (ms) + + +# --------------------------------------------------------------------------- +# Robot family +# --------------------------------------------------------------------------- + +ROBOT_36 = SSTVMode( + name='Robot36', + vis_code=8, + width=320, + height=240, + color_model=ColorModel.YCRCB, + sync_position=SyncPosition.FRONT, + sync_duration_ms=9.0, + sync_porch_ms=3.0, + channels=[ + ChannelTiming(duration_ms=88.0), # Y (luminance) + ChannelTiming(duration_ms=44.0), # Cr or Cb (alternating) + ], + line_duration_ms=150.0, + has_half_rate_chroma=True, + channel_separator_ms=6.0, +) + +ROBOT_72 = SSTVMode( + name='Robot72', + vis_code=12, + width=320, + height=240, + color_model=ColorModel.YCRCB, + sync_position=SyncPosition.FRONT, + sync_duration_ms=9.0, + sync_porch_ms=3.0, + channels=[ + ChannelTiming(duration_ms=138.0), # Y (luminance) + ChannelTiming(duration_ms=69.0), # Cr + ChannelTiming(duration_ms=69.0), # Cb + ], + line_duration_ms=300.0, + has_half_rate_chroma=False, + channel_separator_ms=6.0, +) + +# --------------------------------------------------------------------------- +# Martin family +# --------------------------------------------------------------------------- + +MARTIN_1 = SSTVMode( + name='Martin1', + vis_code=44, + width=320, + height=256, + color_model=ColorModel.RGB, + sync_position=SyncPosition.FRONT, + sync_duration_ms=4.862, + sync_porch_ms=0.572, + channels=[ + ChannelTiming(duration_ms=146.432), # Green + ChannelTiming(duration_ms=146.432), # Blue + ChannelTiming(duration_ms=146.432), # Red + ], + line_duration_ms=446.446, +) + +MARTIN_2 = SSTVMode( + name='Martin2', + vis_code=40, + width=320, + height=256, + color_model=ColorModel.RGB, + sync_position=SyncPosition.FRONT, + sync_duration_ms=4.862, + sync_porch_ms=0.572, + channels=[ + ChannelTiming(duration_ms=73.216), # Green + ChannelTiming(duration_ms=73.216), # Blue + ChannelTiming(duration_ms=73.216), # Red + ], + line_duration_ms=226.798, +) + +# --------------------------------------------------------------------------- +# Scottie family +# --------------------------------------------------------------------------- + +SCOTTIE_1 = SSTVMode( + name='Scottie1', + vis_code=60, + width=320, + height=256, + color_model=ColorModel.RGB, + sync_position=SyncPosition.MIDDLE, + sync_duration_ms=9.0, + sync_porch_ms=1.5, + channels=[ + ChannelTiming(duration_ms=138.240), # Green + ChannelTiming(duration_ms=138.240), # Blue + ChannelTiming(duration_ms=138.240), # Red + ], + line_duration_ms=428.220, +) + +SCOTTIE_2 = SSTVMode( + name='Scottie2', + vis_code=56, + width=320, + height=256, + color_model=ColorModel.RGB, + sync_position=SyncPosition.MIDDLE, + sync_duration_ms=9.0, + sync_porch_ms=1.5, + channels=[ + ChannelTiming(duration_ms=88.064), # Green + ChannelTiming(duration_ms=88.064), # Blue + ChannelTiming(duration_ms=88.064), # Red + ], + line_duration_ms=277.692, +) + +# --------------------------------------------------------------------------- +# PD (Pasokon) family +# --------------------------------------------------------------------------- + +PD_120 = SSTVMode( + name='PD120', + vis_code=93, + width=640, + height=496, + color_model=ColorModel.YCRCB_DUAL, + sync_position=SyncPosition.FRONT_PD, + sync_duration_ms=20.0, + sync_porch_ms=2.080, + channels=[ + ChannelTiming(duration_ms=121.600), # Y1 (even line luminance) + ChannelTiming(duration_ms=121.600), # Cr + ChannelTiming(duration_ms=121.600), # Cb + ChannelTiming(duration_ms=121.600), # Y2 (odd line luminance) + ], + line_duration_ms=508.480, +) + +PD_180 = SSTVMode( + name='PD180', + vis_code=95, + width=640, + height=496, + color_model=ColorModel.YCRCB_DUAL, + sync_position=SyncPosition.FRONT_PD, + sync_duration_ms=20.0, + sync_porch_ms=2.080, + channels=[ + ChannelTiming(duration_ms=183.040), # Y1 + ChannelTiming(duration_ms=183.040), # Cr + ChannelTiming(duration_ms=183.040), # Cb + ChannelTiming(duration_ms=183.040), # Y2 + ], + line_duration_ms=754.240, +) + + +# --------------------------------------------------------------------------- +# Mode registry +# --------------------------------------------------------------------------- + +ALL_MODES: dict[int, SSTVMode] = { + m.vis_code: m for m in [ + ROBOT_36, ROBOT_72, + MARTIN_1, MARTIN_2, + SCOTTIE_1, SCOTTIE_2, + PD_120, PD_180, + ] +} + +MODE_BY_NAME: dict[str, SSTVMode] = {m.name: m for m in ALL_MODES.values()} + + +def get_mode(vis_code: int) -> SSTVMode | None: + """Look up an SSTV mode by its VIS code.""" + return ALL_MODES.get(vis_code) + + +def get_mode_by_name(name: str) -> SSTVMode | None: + """Look up an SSTV mode by name.""" + return MODE_BY_NAME.get(name) diff --git a/utils/sstv/sstv_decoder.py b/utils/sstv/sstv_decoder.py new file mode 100644 index 0000000..36834bc --- /dev/null +++ b/utils/sstv/sstv_decoder.py @@ -0,0 +1,905 @@ +"""SSTV decoder orchestrator. + +Provides the SSTVDecoder class that manages the full pipeline: +rtl_fm subprocess -> audio stream -> VIS detection -> image decoding -> PNG output. + +Also contains DopplerTracker and supporting dataclasses migrated from the +original monolithic utils/sstv.py. +""" + +from __future__ import annotations + +import base64 +import contextlib +import io +import subprocess +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Callable + +import numpy as np + +from utils.logging import get_logger + +from .constants import ISS_SSTV_FREQ, SAMPLE_RATE, SPEED_OF_LIGHT +from .dsp import goertzel_mag, normalize_audio +from .image_decoder import SSTVImageDecoder +from .modes import get_mode +from .vis import VISDetector + +logger = get_logger('intercept.sstv') + +try: + from PIL import Image as PILImage +except ImportError: + PILImage = None # type: ignore[assignment,misc] + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class DopplerInfo: + """Doppler shift information.""" + frequency_hz: float + shift_hz: float + range_rate_km_s: float + elevation: float + azimuth: float + timestamp: datetime + + def to_dict(self) -> dict: + return { + 'frequency_hz': self.frequency_hz, + 'shift_hz': round(self.shift_hz, 1), + 'range_rate_km_s': round(self.range_rate_km_s, 3), + 'elevation': round(self.elevation, 1), + 'azimuth': round(self.azimuth, 1), + 'timestamp': self.timestamp.isoformat(), + } + + +@dataclass +class SSTVImage: + """Decoded SSTV image.""" + filename: str + path: Path + mode: str + timestamp: datetime + frequency: float + size_bytes: int = 0 + url_prefix: str = '/sstv' + + def to_dict(self) -> dict: + return { + 'filename': self.filename, + 'path': str(self.path), + 'mode': self.mode, + 'timestamp': self.timestamp.isoformat(), + 'frequency': self.frequency, + 'size_bytes': self.size_bytes, + 'url': f'{self.url_prefix}/images/{self.filename}' + } + + +@dataclass +class DecodeProgress: + """SSTV decode progress update.""" + status: str # 'detecting', 'decoding', 'complete', 'error' + mode: str | None = None + progress_percent: int = 0 + message: str | None = None + image: SSTVImage | None = None + signal_level: int | None = None # 0-100 RMS audio level, None = not measured + sstv_tone: str | None = None # 'leader', 'sync', 'noise', None + vis_state: str | None = None # VIS detector state name + partial_image: str | None = None # base64 data URL of partial decode + + def to_dict(self) -> dict: + result: dict = { + 'type': 'sstv_progress', + 'status': self.status, + 'progress': self.progress_percent, + } + if self.mode: + result['mode'] = self.mode + if self.message: + result['message'] = self.message + if self.image: + result['image'] = self.image.to_dict() + if self.signal_level is not None: + result['signal_level'] = self.signal_level + if self.sstv_tone: + result['sstv_tone'] = self.sstv_tone + if self.vis_state: + result['vis_state'] = self.vis_state + if self.partial_image: + result['partial_image'] = self.partial_image + return result + + +# --------------------------------------------------------------------------- +# DopplerTracker +# --------------------------------------------------------------------------- + +class DopplerTracker: + """Real-time Doppler shift calculator for satellite tracking. + + Uses skyfield to calculate the range rate between observer and satellite, + then computes the Doppler-shifted receive frequency. + """ + + def __init__(self, satellite_name: str = 'ISS'): + self._satellite_name = satellite_name + self._observer_lat: float | None = None + self._observer_lon: float | None = None + self._satellite = None + self._observer = None + self._ts = None + self._enabled = False + + def configure(self, latitude: float, longitude: float) -> bool: + """Configure the Doppler tracker with observer location.""" + try: + from skyfield.api import EarthSatellite, load, wgs84 + + from data.satellites import TLE_SATELLITES + + tle_data = TLE_SATELLITES.get(self._satellite_name) + if not tle_data: + logger.error(f"No TLE data for satellite: {self._satellite_name}") + return False + + self._ts = load.timescale() + self._satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], self._ts) + self._observer = wgs84.latlon(latitude, longitude) + self._observer_lat = latitude + self._observer_lon = longitude + self._enabled = True + + logger.info(f"Doppler tracker configured for {self._satellite_name} at ({latitude}, {longitude})") + return True + + except ImportError: + logger.warning("skyfield not available - Doppler tracking disabled") + return False + except Exception as e: + logger.error(f"Failed to configure Doppler tracker: {e}") + return False + + @property + def is_enabled(self) -> bool: + return self._enabled + + def calculate(self, nominal_freq_mhz: float) -> DopplerInfo | None: + """Calculate current Doppler-shifted frequency.""" + if not self._enabled or not self._satellite or not self._observer: + return None + + try: + t = self._ts.now() + difference = self._satellite - self._observer + topocentric = difference.at(t) + alt, az, distance = topocentric.altaz() + + dt_seconds = 1.0 + t_future = self._ts.utc(t.utc_datetime() + timedelta(seconds=dt_seconds)) + topocentric_future = difference.at(t_future) + _, _, distance_future = topocentric_future.altaz() + + range_rate_km_s = (distance_future.km - distance.km) / dt_seconds + nominal_freq_hz = nominal_freq_mhz * 1_000_000 + doppler_factor = 1 - (range_rate_km_s * 1000 / SPEED_OF_LIGHT) + corrected_freq_hz = nominal_freq_hz * doppler_factor + shift_hz = corrected_freq_hz - nominal_freq_hz + + return DopplerInfo( + frequency_hz=corrected_freq_hz, + shift_hz=shift_hz, + range_rate_km_s=range_rate_km_s, + elevation=alt.degrees, + azimuth=az.degrees, + timestamp=datetime.now(timezone.utc) + ) + + except Exception as e: + logger.error(f"Doppler calculation failed: {e}") + return None + + +# --------------------------------------------------------------------------- +# SSTVDecoder +# --------------------------------------------------------------------------- + +class SSTVDecoder: + """SSTV decoder using pure-Python DSP with Doppler compensation.""" + + RETUNE_THRESHOLD_HZ = 500 + DOPPLER_UPDATE_INTERVAL = 5 + + def __init__(self, output_dir: str | Path | None = None, url_prefix: str = '/sstv'): + self._rtl_process = None + self._running = False + self._lock = threading.Lock() + self._callback: Callable[[DecodeProgress], None] | None = None + self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images') + self._url_prefix = url_prefix + self._images: list[SSTVImage] = [] + self._decode_thread = None + self._doppler_thread = None + self._frequency = ISS_SSTV_FREQ + self._modulation = 'fm' + self._current_tuned_freq_hz: int = 0 + self._device_index = 0 + + # Doppler tracking + self._doppler_tracker = DopplerTracker('ISS') + self._doppler_enabled = False + self._last_doppler_info: DopplerInfo | None = None + + # Ensure output directory exists + self._output_dir.mkdir(parents=True, exist_ok=True) + + @property + def is_running(self) -> bool: + return self._running + + @property + def decoder_available(self) -> str: + """Return name of available decoder. Always available with pure Python.""" + return 'python-sstv' + + def set_callback(self, callback: Callable[[DecodeProgress], None]) -> None: + """Set callback for decode progress updates.""" + self._callback = callback + + def start( + self, + frequency: float = ISS_SSTV_FREQ, + device_index: int = 0, + latitude: float | None = None, + longitude: float | None = None, + modulation: str = 'fm', + ) -> bool: + """Start SSTV decoder listening on specified frequency. + + Args: + frequency: Frequency in MHz (default: 145.800 for ISS). + device_index: RTL-SDR device index. + latitude: Observer latitude for Doppler correction. + longitude: Observer longitude for Doppler correction. + modulation: Demodulation mode for rtl_fm (fm, usb, lsb). + + Returns: + True if started successfully. + """ + with self._lock: + if self._running: + return True + + self._frequency = frequency + self._device_index = device_index + self._modulation = modulation + + # Configure Doppler tracking if location provided + self._doppler_enabled = False + if latitude is not None and longitude is not None: + if self._doppler_tracker.configure(latitude, longitude): + self._doppler_enabled = True + logger.info(f"Doppler tracking enabled for location ({latitude}, {longitude})") + else: + logger.warning("Doppler tracking unavailable - using fixed frequency") + + try: + freq_hz = self._get_doppler_corrected_freq_hz() + self._current_tuned_freq_hz = freq_hz + # Set _running BEFORE starting the pipeline so the decode + # thread sees it as True on its first loop iteration. + self._running = True + self._start_pipeline(freq_hz) + + # Start Doppler tracking thread if enabled + if self._doppler_enabled: + self._doppler_thread = threading.Thread( + target=self._doppler_tracking_loop, daemon=True) + self._doppler_thread.start() + logger.info(f"SSTV decoder started on {frequency} MHz with Doppler tracking") + self._emit_progress(DecodeProgress( + status='detecting', + message=f'Listening on {frequency} MHz with Doppler tracking...' + )) + else: + logger.info(f"SSTV decoder started on {frequency} MHz (no Doppler tracking)") + self._emit_progress(DecodeProgress( + status='detecting', + message=f'Listening on {frequency} MHz...' + )) + + return True + + except Exception as e: + self._running = False + logger.error(f"Failed to start SSTV decoder: {e}") + self._emit_progress(DecodeProgress( + status='error', + message=str(e) + )) + return False + + def _get_doppler_corrected_freq_hz(self) -> int: + """Get the Doppler-corrected frequency in Hz.""" + nominal_freq_hz = int(self._frequency * 1_000_000) + + if self._doppler_enabled: + doppler_info = self._doppler_tracker.calculate(self._frequency) + if doppler_info: + self._last_doppler_info = doppler_info + corrected_hz = int(doppler_info.frequency_hz) + logger.info( + f"Doppler correction: {doppler_info.shift_hz:+.1f} Hz " + f"(range rate: {doppler_info.range_rate_km_s:+.3f} km/s, " + f"el: {doppler_info.elevation:.1f}\u00b0)" + ) + return corrected_hz + + return nominal_freq_hz + + def _start_pipeline(self, freq_hz: int) -> None: + """Start the rtl_fm -> Python decode pipeline.""" + rtl_cmd = [ + 'rtl_fm', + '-d', str(self._device_index), + '-f', str(freq_hz), + '-M', self._modulation, + '-s', str(SAMPLE_RATE), + '-r', str(SAMPLE_RATE), + '-l', '0', # No squelch + '-' + ] + + logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}") + + self._rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + # Start decode thread that reads from rtl_fm stdout + self._decode_thread = threading.Thread( + target=self._decode_audio_stream, daemon=True) + self._decode_thread.start() + + def _decode_audio_stream(self) -> None: + """Read audio from rtl_fm and decode SSTV images. + + Runs in a background thread. Reads 100ms chunks of int16 PCM, + feeds through VIS detector, then image decoder. + """ + chunk_bytes = SAMPLE_RATE // 10 * 2 # 100ms of int16 = 9600 bytes + vis_detector = VISDetector(sample_rate=SAMPLE_RATE) + image_decoder: SSTVImageDecoder | None = None + current_mode_name: str | None = None + chunk_counter = 0 + last_partial_pct = -1 + + logger.info("Audio decode thread started") + rtl_fm_error: str = '' + + while self._running and self._rtl_process: + try: + raw_data = self._rtl_process.stdout.read(chunk_bytes) + if not raw_data: + if self._running: + # Read stderr to diagnose why rtl_fm exited + stderr_msg = '' + if self._rtl_process and self._rtl_process.stderr: + with contextlib.suppress(Exception): + stderr_msg = self._rtl_process.stderr.read().decode( + errors='replace').strip() + rc = self._rtl_process.poll() if self._rtl_process else None + logger.warning( + f"rtl_fm stream ended unexpectedly " + f"(exit code: {rc})" + ) + if stderr_msg: + logger.warning(f"rtl_fm stderr: {stderr_msg}") + rtl_fm_error = stderr_msg + break + + # Convert int16 PCM to float64 + n_samples = len(raw_data) // 2 + if n_samples == 0: + continue + raw_samples = np.frombuffer(raw_data[:n_samples * 2], dtype=np.int16) + samples = normalize_audio(raw_samples) + + chunk_counter += 1 + + if image_decoder is not None: + # Currently decoding an image + complete = image_decoder.feed(samples) + + # Encode partial image every 5% progress + pct = image_decoder.progress_percent + partial_url = None + if pct >= last_partial_pct + 5 or complete: + last_partial_pct = pct + try: + img = image_decoder.get_image() + if img is not None: + buf = io.BytesIO() + img.save(buf, format='JPEG', quality=40) + b64 = base64.b64encode(buf.getvalue()).decode('ascii') + partial_url = f'data:image/jpeg;base64,{b64}' + except Exception: + pass + + # Emit progress + self._emit_progress(DecodeProgress( + status='decoding', + mode=current_mode_name, + progress_percent=pct, + message=f'Decoding {current_mode_name}: {pct}%', + partial_image=partial_url, + )) + + if complete: + # Save image + self._save_decoded_image(image_decoder, current_mode_name) + image_decoder = None + current_mode_name = None + vis_detector.reset() + else: + # Scanning for VIS header + result = vis_detector.feed(samples) + if result is not None: + vis_code, mode_name = result + logger.info(f"VIS detected: code={vis_code}, mode={mode_name}") + + mode_spec = get_mode(vis_code) + if mode_spec: + current_mode_name = mode_name + image_decoder = SSTVImageDecoder( + mode_spec, + sample_rate=SAMPLE_RATE, + ) + self._emit_progress(DecodeProgress( + status='decoding', + mode=mode_name, + progress_percent=0, + message=f'Detected {mode_name} - decoding...' + )) + else: + logger.warning(f"No mode spec for VIS code {vis_code}") + vis_detector.reset() + + # Emit signal level metrics every ~500ms (every 5th 100ms chunk) + if chunk_counter % 5 == 0 and image_decoder is None: + rms = float(np.sqrt(np.mean(samples ** 2))) + signal_level = min(100, int(rms * 500)) + + leader_energy = goertzel_mag(samples, 1900.0, SAMPLE_RATE) + sync_energy = goertzel_mag(samples, 1200.0, SAMPLE_RATE) + noise_floor = max(rms * 0.5, 0.001) + + # Require the tone to both exceed the noise floor AND + # dominate the other tone by 2x to avoid false positives + # from broadband noise. + if (leader_energy > noise_floor * 5 + and leader_energy > sync_energy * 2): + sstv_tone = 'leader' + elif (sync_energy > noise_floor * 5 + and sync_energy > leader_energy * 2): + sstv_tone = 'sync' + elif signal_level > 10: + sstv_tone = 'noise' + else: + sstv_tone = None + + self._emit_progress(DecodeProgress( + status='detecting', + message='Listening...', + signal_level=signal_level, + sstv_tone=sstv_tone, + vis_state=vis_detector.state.value, + )) + + except Exception as e: + logger.error(f"Error in decode thread: {e}") + if not self._running: + break + time.sleep(0.1) + + # Clean up if the thread exits while we thought we were running. + # This prevents a "ghost running" state where is_running is True + # but the thread has already died (e.g. rtl_fm exited). + with self._lock: + was_running = self._running + self._running = False + if was_running and self._rtl_process: + with contextlib.suppress(Exception): + self._rtl_process.terminate() + self._rtl_process.wait(timeout=2) + self._rtl_process = None + + if was_running: + logger.warning("Audio decode thread stopped unexpectedly") + err_detail = rtl_fm_error.split('\n')[-1] if rtl_fm_error else '' + msg = f'rtl_fm failed: {err_detail}' if err_detail else 'Decode pipeline stopped unexpectedly' + self._emit_progress(DecodeProgress( + status='error', + message=msg + )) + else: + logger.info("Audio decode thread stopped") + + def _save_decoded_image(self, decoder: SSTVImageDecoder, + mode_name: str | None) -> None: + """Save a completed decoded image to disk.""" + try: + img = decoder.get_image() + if img is None: + logger.error("Failed to get image from decoder (Pillow not available?)") + self._emit_progress(DecodeProgress( + status='error', + message='Failed to create image - Pillow not installed' + )) + return + + timestamp = datetime.now(timezone.utc) + filename = f"sstv_{timestamp.strftime('%Y%m%d_%H%M%S')}_{mode_name or 'unknown'}.png" + filepath = self._output_dir / filename + img.save(filepath, 'PNG') + + sstv_image = SSTVImage( + filename=filename, + path=filepath, + mode=mode_name or 'Unknown', + timestamp=timestamp, + frequency=self._frequency, + size_bytes=filepath.stat().st_size, + url_prefix=self._url_prefix, + ) + self._images.append(sstv_image) + + logger.info(f"SSTV image saved: {filename} ({sstv_image.size_bytes} bytes)") + self._emit_progress(DecodeProgress( + status='complete', + mode=mode_name, + progress_percent=100, + message='Image decoded', + image=sstv_image, + )) + + except Exception as e: + logger.error(f"Error saving decoded image: {e}") + self._emit_progress(DecodeProgress( + status='error', + message=f'Error saving image: {e}' + )) + + def _doppler_tracking_loop(self) -> None: + """Background thread that monitors Doppler shift and retunes when needed.""" + logger.info("Doppler tracking thread started") + + while self._running and self._doppler_enabled: + time.sleep(self.DOPPLER_UPDATE_INTERVAL) + + if not self._running: + break + + try: + doppler_info = self._doppler_tracker.calculate(self._frequency) + if not doppler_info: + continue + + self._last_doppler_info = doppler_info + new_freq_hz = int(doppler_info.frequency_hz) + freq_diff = abs(new_freq_hz - self._current_tuned_freq_hz) + + logger.debug( + f"Doppler: {doppler_info.shift_hz:+.1f} Hz, " + f"el: {doppler_info.elevation:.1f}\u00b0, " + f"diff from tuned: {freq_diff} Hz" + ) + + self._emit_progress(DecodeProgress( + status='detecting', + message=f'Doppler: {doppler_info.shift_hz:+.0f} Hz, elevation: {doppler_info.elevation:.1f}\u00b0' + )) + + if freq_diff >= self.RETUNE_THRESHOLD_HZ: + logger.info( + f"Retuning: {self._current_tuned_freq_hz} -> {new_freq_hz} Hz " + f"(Doppler shift: {doppler_info.shift_hz:+.1f} Hz)" + ) + self._retune_rtl_fm(new_freq_hz) + + except Exception as e: + logger.error(f"Doppler tracking error: {e}") + + logger.info("Doppler tracking thread stopped") + + def _retune_rtl_fm(self, new_freq_hz: int) -> None: + """Retune rtl_fm to a new frequency by restarting the process.""" + with self._lock: + if not self._running: + return + + if self._rtl_process: + try: + self._rtl_process.terminate() + self._rtl_process.wait(timeout=2) + except Exception: + with contextlib.suppress(Exception): + self._rtl_process.kill() + + rtl_cmd = [ + 'rtl_fm', + '-d', str(self._device_index), + '-f', str(new_freq_hz), + '-M', self._modulation, + '-s', str(SAMPLE_RATE), + '-r', str(SAMPLE_RATE), + '-l', '0', + '-' + ] + + logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}") + + self._rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + self._current_tuned_freq_hz = new_freq_hz + + @property + def last_doppler_info(self) -> DopplerInfo | None: + """Get the most recent Doppler calculation.""" + return self._last_doppler_info + + @property + def doppler_enabled(self) -> bool: + """Check if Doppler tracking is enabled.""" + return self._doppler_enabled + + def stop(self) -> None: + """Stop SSTV decoder.""" + with self._lock: + self._running = False + + if self._rtl_process: + try: + self._rtl_process.terminate() + self._rtl_process.wait(timeout=5) + except Exception: + with contextlib.suppress(Exception): + self._rtl_process.kill() + self._rtl_process = None + + logger.info("SSTV decoder stopped") + + def get_images(self) -> list[SSTVImage]: + """Get list of decoded images.""" + self._scan_images() + return list(self._images) + + def delete_image(self, filename: str) -> bool: + """Delete a single decoded image by filename.""" + filepath = self._output_dir / filename + if not filepath.exists(): + return False + filepath.unlink() + self._images = [img for img in self._images if img.filename != filename] + logger.info(f"Deleted SSTV image: {filename}") + return True + + def delete_all_images(self) -> int: + """Delete all decoded images. Returns count deleted.""" + count = 0 + for filepath in self._output_dir.glob('*.png'): + filepath.unlink() + count += 1 + self._images.clear() + logger.info(f"Deleted all SSTV images ({count} files)") + return count + + def _scan_images(self) -> None: + """Scan output directory for images.""" + known_filenames = {img.filename for img in self._images} + + for filepath in self._output_dir.glob('*.png'): + if filepath.name not in known_filenames: + try: + stat = filepath.stat() + image = SSTVImage( + filename=filepath.name, + path=filepath, + mode='Unknown', + timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + frequency=self._frequency, + size_bytes=stat.st_size, + url_prefix=self._url_prefix, + ) + self._images.append(image) + except Exception as e: + logger.warning(f"Error scanning image {filepath}: {e}") + + def _emit_progress(self, progress: DecodeProgress) -> None: + """Emit progress update to callback.""" + if self._callback: + try: + self._callback(progress) + except Exception as e: + logger.error(f"Error in progress callback: {e}") + + def decode_file(self, audio_path: str | Path) -> list[SSTVImage]: + """Decode SSTV image(s) from an audio file. + + Reads a WAV file and processes it through VIS detection + image + decoding using the pure Python pipeline. + + Args: + audio_path: Path to WAV audio file. + + Returns: + List of decoded images. + """ + import wave + + audio_path = Path(audio_path) + if not audio_path.exists(): + raise FileNotFoundError(f"Audio file not found: {audio_path}") + + images: list[SSTVImage] = [] + + try: + with wave.open(str(audio_path), 'rb') as wf: + n_channels = wf.getnchannels() + sample_width = wf.getsampwidth() + file_sample_rate = wf.getframerate() + n_frames = wf.getnframes() + + logger.info( + f"Decoding WAV: {n_channels}ch, {sample_width*8}bit, " + f"{file_sample_rate}Hz, {n_frames} frames" + ) + + # Read all audio data + raw_data = wf.readframes(n_frames) + + # Convert to float64 mono + if sample_width == 2: + audio = np.frombuffer(raw_data, dtype=np.int16).astype(np.float64) / 32768.0 + elif sample_width == 1: + audio = np.frombuffer(raw_data, dtype=np.uint8).astype(np.float64) / 128.0 - 1.0 + elif sample_width == 4: + audio = np.frombuffer(raw_data, dtype=np.int32).astype(np.float64) / 2147483648.0 + else: + raise ValueError(f"Unsupported sample width: {sample_width}") + + # If stereo, take left channel + if n_channels > 1: + audio = audio[::n_channels] + + # Resample if needed + if file_sample_rate != SAMPLE_RATE: + audio = self._resample(audio, file_sample_rate, SAMPLE_RATE) + + # Process through VIS detector + image decoder + vis_detector = VISDetector(sample_rate=SAMPLE_RATE) + image_decoder: SSTVImageDecoder | None = None + current_mode_name: str | None = None + + chunk_size = SAMPLE_RATE // 10 # 100ms chunks + offset = 0 + + while offset < len(audio): + chunk = audio[offset:offset + chunk_size] + offset += chunk_size + + if image_decoder is not None: + complete = image_decoder.feed(chunk) + if complete: + img = image_decoder.get_image() + if img is not None: + timestamp = datetime.now(timezone.utc) + filename = f"sstv_{timestamp.strftime('%Y%m%d_%H%M%S')}_{current_mode_name or 'unknown'}.png" + filepath = self._output_dir / filename + img.save(filepath, 'PNG') + + sstv_image = SSTVImage( + filename=filename, + path=filepath, + mode=current_mode_name or 'Unknown', + timestamp=timestamp, + frequency=0, + size_bytes=filepath.stat().st_size, + url_prefix=self._url_prefix, + ) + images.append(sstv_image) + self._images.append(sstv_image) + logger.info(f"Decoded image from file: {filename}") + + image_decoder = None + current_mode_name = None + vis_detector.reset() + else: + result = vis_detector.feed(chunk) + if result is not None: + vis_code, mode_name = result + logger.info(f"VIS detected in file: code={vis_code}, mode={mode_name}") + + mode_spec = get_mode(vis_code) + if mode_spec: + current_mode_name = mode_name + image_decoder = SSTVImageDecoder( + mode_spec, + sample_rate=SAMPLE_RATE, + ) + else: + vis_detector.reset() + + except wave.Error as e: + logger.error(f"Error reading WAV file: {e}") + raise + except Exception as e: + logger.error(f"Error decoding audio file: {e}") + raise + + return images + + @staticmethod + def _resample(audio: np.ndarray, from_rate: int, to_rate: int) -> np.ndarray: + """Simple resampling using linear interpolation.""" + if from_rate == to_rate: + return audio + + ratio = to_rate / from_rate + new_length = int(len(audio) * ratio) + indices = np.linspace(0, len(audio) - 1, new_length) + return np.interp(indices, np.arange(len(audio)), audio) + + +# --------------------------------------------------------------------------- +# Module-level singletons +# --------------------------------------------------------------------------- + +_decoder: SSTVDecoder | None = None + + +def get_sstv_decoder() -> SSTVDecoder: + """Get or create the global SSTV decoder instance.""" + global _decoder + if _decoder is None: + _decoder = SSTVDecoder() + return _decoder + + +def is_sstv_available() -> bool: + """Check if SSTV decoding is available. + + Always True with the pure-Python decoder (requires only numpy/Pillow). + """ + return True + + +_general_decoder: SSTVDecoder | None = None + + +def get_general_sstv_decoder() -> SSTVDecoder: + """Get or create the global general SSTV decoder instance.""" + global _general_decoder + if _general_decoder is None: + _general_decoder = SSTVDecoder( + output_dir='instance/sstv_general_images', + url_prefix='/sstv-general', + ) + return _general_decoder diff --git a/utils/sstv/vis.py b/utils/sstv/vis.py new file mode 100644 index 0000000..a7c840a --- /dev/null +++ b/utils/sstv/vis.py @@ -0,0 +1,324 @@ +"""VIS (Vertical Interval Signaling) header detection. + +State machine that processes audio samples to detect the VIS header +that precedes every SSTV image transmission. The VIS header identifies +the SSTV mode (Robot36, Martin1, etc.) via an 8-bit code with even parity. + +VIS header structure: + Leader tone (1900 Hz, ~300ms) + Break (1200 Hz, ~10ms) + Leader tone (1900 Hz, ~300ms) + Start bit (1200 Hz, 30ms) + 8 data bits (1100 Hz = 1, 1300 Hz = 0, 30ms each) + Parity bit (even parity, 30ms) + Stop bit (1200 Hz, 30ms) +""" + +from __future__ import annotations + +import enum + +import numpy as np + +from .constants import ( + FREQ_LEADER, + FREQ_SYNC, + FREQ_VIS_BIT_0, + FREQ_VIS_BIT_1, + SAMPLE_RATE, + VIS_BIT_DURATION, + VIS_CODES, + VIS_LEADER_MAX, + VIS_LEADER_MIN, +) +from .dsp import goertzel, samples_for_duration + +# Use 10ms window (480 samples at 48kHz) for 100Hz frequency resolution. +# This cleanly separates 1100, 1200, 1300, 1500, 1900, 2300 Hz tones. +VIS_WINDOW = 480 + + +class VISState(enum.Enum): + """States of the VIS detection state machine.""" + IDLE = 'idle' + LEADER_1 = 'leader_1' + BREAK = 'break' + LEADER_2 = 'leader_2' + START_BIT = 'start_bit' + DATA_BITS = 'data_bits' + PARITY = 'parity' + STOP_BIT = 'stop_bit' + DETECTED = 'detected' + + +# The four tone classes we need to distinguish in VIS detection. +_VIS_FREQS = [FREQ_VIS_BIT_1, FREQ_SYNC, FREQ_VIS_BIT_0, FREQ_LEADER] +# 1100, 1200, 1300, 1900 Hz + + +def _classify_tone(samples: np.ndarray, + sample_rate: int = SAMPLE_RATE) -> float | None: + """Classify which VIS tone is present in the given samples. + + Computes Goertzel energy at each of the four VIS frequencies and returns + the one with the highest energy, provided it dominates sufficiently. + + Returns: + The detected frequency (1100, 1200, 1300, or 1900), or None. + """ + if len(samples) < 16: + return None + + energies = {f: goertzel(samples, f, sample_rate) for f in _VIS_FREQS} + best_freq = max(energies, key=energies.get) # type: ignore[arg-type] + best_energy = energies[best_freq] + + if best_energy <= 0: + return None + + # Require the best frequency to be at least 2x stronger than the + # next-strongest tone. + others = sorted( + [e for f, e in energies.items() if f != best_freq], reverse=True) + second_best = others[0] if others else 0.0 + + if second_best > 0 and best_energy / second_best < 2.0: + return None + + return best_freq + + +class VISDetector: + """VIS header detection state machine. + + Feed audio samples via ``feed()`` and it returns the detected VIS code + (and mode name) when a valid header is found. + + The state machine uses a simple approach: + + - **Leader detection**: Count consecutive 1900 Hz windows until minimum + leader duration is met. + - **Break/start bit**: Count consecutive 1200 Hz windows. The break is + short; the start bit is one VIS bit duration. + - **Data/parity bits**: Accumulate audio for one bit duration, then + compare 1100 vs 1300 Hz energy to determine bit value. + - **Stop bit**: Count 1200 Hz windows for one bit duration. + + Usage:: + + detector = VISDetector() + for chunk in audio_chunks: + result = detector.feed(chunk) + if result is not None: + vis_code, mode_name = result + """ + + def __init__(self, sample_rate: int = SAMPLE_RATE): + self._sample_rate = sample_rate + self._window = VIS_WINDOW + self._bit_samples = samples_for_duration(VIS_BIT_DURATION, sample_rate) + self._leader_min_samples = samples_for_duration(VIS_LEADER_MIN, sample_rate) + self._leader_max_samples = samples_for_duration(VIS_LEADER_MAX, sample_rate) + + # Pre-calculate window counts + self._leader_min_windows = max(1, self._leader_min_samples // self._window) + self._leader_max_windows = max(1, self._leader_max_samples // self._window) + self._bit_windows = max(1, self._bit_samples // self._window) + + self._state = VISState.IDLE + self._buffer = np.array([], dtype=np.float64) + self._tone_counter = 0 + self._data_bits: list[int] = [] + self._parity_bit: int = 0 + self._bit_accumulator: list[np.ndarray] = [] + + def reset(self) -> None: + """Reset the detector to scan for a new VIS header.""" + self._state = VISState.IDLE + self._buffer = np.array([], dtype=np.float64) + self._tone_counter = 0 + self._data_bits = [] + self._parity_bit = 0 + self._bit_accumulator = [] + + @property + def state(self) -> VISState: + return self._state + + def feed(self, samples: np.ndarray) -> tuple[int, str] | None: + """Feed audio samples and attempt VIS detection. + + Args: + samples: Float64 audio samples (normalized to -1..1). + + Returns: + (vis_code, mode_name) tuple when a valid VIS header is detected, + or None if still scanning. + """ + self._buffer = np.concatenate([self._buffer, samples]) + + while len(self._buffer) >= self._window: + result = self._process_window(self._buffer[:self._window]) + self._buffer = self._buffer[self._window:] + + if result is not None: + return result + + return None + + def _process_window(self, window: np.ndarray) -> tuple[int, str] | None: + """Process a single analysis window through the state machine. + + The key design: when a state transition occurs due to a tone change, + the window that triggers the transition counts as the first window + of the new state (tone_counter = 1). + """ + tone = _classify_tone(window, self._sample_rate) + + if self._state == VISState.IDLE: + if tone == FREQ_LEADER: + self._tone_counter += 1 + if self._tone_counter >= self._leader_min_windows: + self._state = VISState.LEADER_1 + else: + self._tone_counter = 0 + + elif self._state == VISState.LEADER_1: + if tone == FREQ_LEADER: + self._tone_counter += 1 + if self._tone_counter > self._leader_max_windows * 3: + self._tone_counter = 0 + self._state = VISState.IDLE + elif tone == FREQ_SYNC: + # Transition to BREAK; this window counts as break window 1 + self._tone_counter = 1 + self._state = VISState.BREAK + elif tone is None: + pass # Ambiguous window at tone boundary — stay in state + else: + self._tone_counter = 0 + self._state = VISState.IDLE + + elif self._state == VISState.BREAK: + if tone == FREQ_SYNC: + self._tone_counter += 1 + if self._tone_counter > 10: + self._tone_counter = 0 + self._state = VISState.IDLE + elif tone == FREQ_LEADER: + # Transition to LEADER_2; this window counts + self._tone_counter = 1 + self._state = VISState.LEADER_2 + elif tone is None: + pass # Ambiguous window at tone boundary — stay in state + else: + self._tone_counter = 0 + self._state = VISState.IDLE + + elif self._state == VISState.LEADER_2: + if tone == FREQ_LEADER: + self._tone_counter += 1 + if self._tone_counter > self._leader_max_windows * 3: + self._tone_counter = 0 + self._state = VISState.IDLE + elif tone == FREQ_SYNC: + # Transition to START_BIT; this window counts + self._tone_counter = 1 + self._state = VISState.START_BIT + # Check if start bit is already complete (1-window bit) + if self._tone_counter >= self._bit_windows: + self._tone_counter = 0 + self._data_bits = [] + self._bit_accumulator = [] + self._state = VISState.DATA_BITS + elif tone is None: + pass # Ambiguous window at tone boundary — stay in state + else: + self._tone_counter = 0 + self._state = VISState.IDLE + + elif self._state == VISState.START_BIT: + if tone == FREQ_SYNC: + self._tone_counter += 1 + if self._tone_counter >= self._bit_windows: + self._tone_counter = 0 + self._data_bits = [] + self._bit_accumulator = [] + self._state = VISState.DATA_BITS + else: + # Non-sync during start bit: check if we had enough sync + # windows already (tolerant: accept if within 1 window) + if self._tone_counter >= self._bit_windows - 1: + # Close enough - accept and process this window as data + self._data_bits = [] + self._bit_accumulator = [window] + self._tone_counter = 1 + self._state = VISState.DATA_BITS + else: + self._tone_counter = 0 + self._state = VISState.IDLE + + elif self._state == VISState.DATA_BITS: + self._tone_counter += 1 + self._bit_accumulator.append(window) + + if self._tone_counter >= self._bit_windows: + bit_audio = np.concatenate(self._bit_accumulator) + bit_val = self._decode_bit(bit_audio) + self._data_bits.append(bit_val) + self._tone_counter = 0 + self._bit_accumulator = [] + + if len(self._data_bits) == 8: + self._state = VISState.PARITY + + elif self._state == VISState.PARITY: + self._tone_counter += 1 + self._bit_accumulator.append(window) + + if self._tone_counter >= self._bit_windows: + bit_audio = np.concatenate(self._bit_accumulator) + self._parity_bit = self._decode_bit(bit_audio) + self._tone_counter = 0 + self._bit_accumulator = [] + self._state = VISState.STOP_BIT + + elif self._state == VISState.STOP_BIT: + self._tone_counter += 1 + + if self._tone_counter >= self._bit_windows: + result = self._validate_and_decode() + self.reset() + return result + + return None + + def _decode_bit(self, samples: np.ndarray) -> int: + """Decode a single VIS data bit from its audio samples. + + Compares Goertzel energy at 1100 Hz (bit=1) vs 1300 Hz (bit=0). + """ + e1 = goertzel(samples, FREQ_VIS_BIT_1, self._sample_rate) + e0 = goertzel(samples, FREQ_VIS_BIT_0, self._sample_rate) + return 1 if e1 > e0 else 0 + + def _validate_and_decode(self) -> tuple[int, str] | None: + """Validate parity and decode the VIS code. + + Returns: + (vis_code, mode_name) or None if validation fails. + """ + if len(self._data_bits) != 8: + return None + + # Decode VIS code (LSB first) + vis_code = 0 + for i, bit in enumerate(self._data_bits): + vis_code |= bit << i + + # Look up mode + mode_name = VIS_CODES.get(vis_code) + if mode_name is not None: + return vis_code, mode_name + + return None diff --git a/utils/tscm/advanced.py b/utils/tscm/advanced.py index b36efd1..91d17cb 100644 --- a/utils/tscm/advanced.py +++ b/utils/tscm/advanced.py @@ -523,20 +523,22 @@ class BaselineDiff: } -def calculate_baseline_diff( - baseline: dict, - current_wifi: list[dict], - current_bt: list[dict], - current_rf: list[dict], - sweep_id: int -) -> BaselineDiff: +def calculate_baseline_diff( + baseline: dict, + current_wifi: list[dict], + current_wifi_clients: list[dict], + current_bt: list[dict], + current_rf: list[dict], + sweep_id: int +) -> BaselineDiff: """ Calculate comprehensive diff between baseline and current scan. Args: baseline: Baseline dict from database current_wifi: Current WiFi devices - current_bt: Current Bluetooth devices + current_wifi_clients: Current WiFi clients + current_bt: Current Bluetooth devices current_rf: Current RF signals sweep_id: Current sweep ID @@ -564,11 +566,16 @@ def calculate_baseline_diff( diff.is_stale = diff.baseline_age_hours > 72 # Build baseline lookup dicts - baseline_wifi = { - d.get('bssid', d.get('mac', '')).upper(): d - for d in baseline.get('wifi_networks', []) - if d.get('bssid') or d.get('mac') - } + baseline_wifi = { + d.get('bssid', d.get('mac', '')).upper(): d + for d in baseline.get('wifi_networks', []) + if d.get('bssid') or d.get('mac') + } + baseline_wifi_clients = { + d.get('mac', d.get('address', '')).upper(): d + for d in baseline.get('wifi_clients', []) + if d.get('mac') or d.get('address') + } baseline_bt = { d.get('mac', d.get('address', '')).upper(): d for d in baseline.get('bt_devices', []) @@ -580,8 +587,11 @@ def calculate_baseline_diff( if d.get('frequency') } - # Compare WiFi - _compare_wifi(diff, baseline_wifi, current_wifi) + # Compare WiFi + _compare_wifi(diff, baseline_wifi, current_wifi) + + # Compare WiFi clients + _compare_wifi_clients(diff, baseline_wifi_clients, current_wifi_clients) # Compare Bluetooth _compare_bluetooth(diff, baseline_bt, current_bt) @@ -607,7 +617,7 @@ def calculate_baseline_diff( return diff -def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: +def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: """Compare WiFi devices between baseline and current.""" current_macs = { d.get('bssid', d.get('mac', '')).upper(): d @@ -630,7 +640,48 @@ def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> No 'channel': device.get('channel'), 'rssi': device.get('power', device.get('signal')), } - )) + )) + + +def _compare_wifi_clients(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: + """Compare WiFi clients between baseline and current.""" + current_macs = { + d.get('mac', d.get('address', '')).upper(): d + for d in current + if d.get('mac') or d.get('address') + } + + # Find new clients + for mac, device in current_macs.items(): + if mac not in baseline: + name = device.get('vendor', 'WiFi Client') + diff.new_devices.append(DeviceChange( + identifier=mac, + protocol='wifi_client', + change_type='new', + description=f'New WiFi client: {name}', + expected=False, + details={ + 'vendor': name, + 'rssi': device.get('rssi'), + 'associated_bssid': device.get('associated_bssid'), + } + )) + + # Find missing clients + for mac, device in baseline.items(): + if mac not in current_macs: + name = device.get('vendor', 'WiFi Client') + diff.missing_devices.append(DeviceChange( + identifier=mac, + protocol='wifi_client', + change_type='missing', + description=f'Missing WiFi client: {name}', + expected=True, + details={ + 'vendor': name, + } + )) else: # Check for changes baseline_dev = baseline[mac] @@ -796,11 +847,12 @@ def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None: reasons.append(f"Baseline is {diff.baseline_age_hours:.0f} hours old") # Device churn penalty - total_baseline = ( - len(baseline.get('wifi_networks', [])) + - len(baseline.get('bt_devices', [])) + - len(baseline.get('rf_frequencies', [])) - ) + total_baseline = ( + len(baseline.get('wifi_networks', [])) + + len(baseline.get('wifi_clients', [])) + + len(baseline.get('bt_devices', [])) + + len(baseline.get('rf_frequencies', [])) + ) if total_baseline > 0: churn_rate = (diff.total_new + diff.total_missing) / total_baseline diff --git a/utils/tscm/baseline.py b/utils/tscm/baseline.py index 4cb0462..facbd02 100644 --- a/utils/tscm/baseline.py +++ b/utils/tscm/baseline.py @@ -26,12 +26,13 @@ class BaselineRecorder: Records and manages TSCM environment baselines. """ - def __init__(self): - self.recording = False - self.current_baseline_id: int | None = None - self.wifi_networks: dict[str, dict] = {} # BSSID -> network info - self.bt_devices: dict[str, dict] = {} # MAC -> device info - self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info + def __init__(self): + self.recording = False + self.current_baseline_id: int | None = None + self.wifi_networks: dict[str, dict] = {} # BSSID -> network info + self.wifi_clients: dict[str, dict] = {} # MAC -> client info + self.bt_devices: dict[str, dict] = {} # MAC -> device info + self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info def start_recording( self, @@ -50,10 +51,11 @@ class BaselineRecorder: Returns: Baseline ID """ - self.recording = True - self.wifi_networks = {} - self.bt_devices = {} - self.rf_frequencies = {} + self.recording = True + self.wifi_networks = {} + self.wifi_clients = {} + self.bt_devices = {} + self.rf_frequencies = {} # Create baseline in database self.current_baseline_id = create_tscm_baseline( @@ -78,24 +80,27 @@ class BaselineRecorder: self.recording = False # Convert to lists for storage - wifi_list = list(self.wifi_networks.values()) - bt_list = list(self.bt_devices.values()) - rf_list = list(self.rf_frequencies.values()) + wifi_list = list(self.wifi_networks.values()) + wifi_client_list = list(self.wifi_clients.values()) + bt_list = list(self.bt_devices.values()) + rf_list = list(self.rf_frequencies.values()) # Update database - update_tscm_baseline( - self.current_baseline_id, - wifi_networks=wifi_list, - bt_devices=bt_list, - rf_frequencies=rf_list - ) + update_tscm_baseline( + self.current_baseline_id, + wifi_networks=wifi_list, + wifi_clients=wifi_client_list, + bt_devices=bt_list, + rf_frequencies=rf_list + ) - summary = { - 'baseline_id': self.current_baseline_id, - 'wifi_count': len(wifi_list), - 'bt_count': len(bt_list), - 'rf_count': len(rf_list), - } + summary = { + 'baseline_id': self.current_baseline_id, + 'wifi_count': len(wifi_list), + 'wifi_client_count': len(wifi_client_list), + 'bt_count': len(bt_list), + 'rf_count': len(rf_list), + } logger.info( f"Baseline recording complete: {summary['wifi_count']} WiFi, " @@ -135,8 +140,8 @@ class BaselineRecorder: 'last_seen': datetime.now().isoformat(), } - def add_bt_device(self, device: dict) -> None: - """Add a Bluetooth device to the current baseline.""" + def add_bt_device(self, device: dict) -> None: + """Add a Bluetooth device to the current baseline.""" if not self.recording: return @@ -150,7 +155,7 @@ class BaselineRecorder: 'rssi': device.get('rssi', self.bt_devices[mac].get('rssi')), }) else: - self.bt_devices[mac] = { + self.bt_devices[mac] = { 'mac': mac, 'name': device.get('name', ''), 'rssi': device.get('rssi', device.get('signal')), @@ -158,10 +163,37 @@ class BaselineRecorder: 'type': device.get('type', ''), 'first_seen': datetime.now().isoformat(), 'last_seen': datetime.now().isoformat(), - } - - def add_rf_signal(self, signal: dict) -> None: - """Add an RF signal to the current baseline.""" + } + + def add_wifi_client(self, client: dict) -> None: + """Add a WiFi client to the current baseline.""" + if not self.recording: + return + + mac = client.get('mac', client.get('address', '')).upper() + if not mac: + return + + if mac in self.wifi_clients: + self.wifi_clients[mac].update({ + 'last_seen': datetime.now().isoformat(), + 'rssi': client.get('rssi', self.wifi_clients[mac].get('rssi')), + 'associated_bssid': client.get('associated_bssid', self.wifi_clients[mac].get('associated_bssid')), + }) + else: + self.wifi_clients[mac] = { + 'mac': mac, + 'vendor': client.get('vendor', ''), + 'rssi': client.get('rssi'), + 'associated_bssid': client.get('associated_bssid'), + 'probed_ssids': client.get('probed_ssids', []), + 'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))), + 'first_seen': datetime.now().isoformat(), + 'last_seen': datetime.now().isoformat(), + } + + def add_rf_signal(self, signal: dict) -> None: + """Add an RF signal to the current baseline.""" if not self.recording: return @@ -191,15 +223,16 @@ class BaselineRecorder: 'hit_count': 1, } - def get_recording_status(self) -> dict: - """Get current recording status and counts.""" - return { - 'recording': self.recording, - 'baseline_id': self.current_baseline_id, - 'wifi_count': len(self.wifi_networks), - 'bt_count': len(self.bt_devices), - 'rf_count': len(self.rf_frequencies), - } + def get_recording_status(self) -> dict: + """Get current recording status and counts.""" + return { + 'recording': self.recording, + 'baseline_id': self.current_baseline_id, + 'wifi_count': len(self.wifi_networks), + 'wifi_client_count': len(self.wifi_clients), + 'bt_count': len(self.bt_devices), + 'rf_count': len(self.rf_frequencies), + } class BaselineComparator: @@ -220,11 +253,16 @@ class BaselineComparator: for d in baseline.get('wifi_networks', []) if d.get('bssid') or d.get('mac') } - self.baseline_bt = { - d.get('mac', d.get('address', '')).upper(): d - for d in baseline.get('bt_devices', []) - if d.get('mac') or d.get('address') - } + self.baseline_bt = { + d.get('mac', d.get('address', '')).upper(): d + for d in baseline.get('bt_devices', []) + if d.get('mac') or d.get('address') + } + self.baseline_wifi_clients = { + d.get('mac', d.get('address', '')).upper(): d + for d in baseline.get('wifi_clients', []) + if d.get('mac') or d.get('address') + } self.baseline_rf = { round(d.get('frequency', 0), 1): d for d in baseline.get('rf_frequencies', []) @@ -269,8 +307,8 @@ class BaselineComparator: 'matching_count': len(matching_devices), } - def compare_bluetooth(self, current_devices: list[dict]) -> dict: - """Compare current Bluetooth devices against baseline.""" + def compare_bluetooth(self, current_devices: list[dict]) -> dict: + """Compare current Bluetooth devices against baseline.""" current_macs = { d.get('mac', d.get('address', '')).upper(): d for d in current_devices @@ -291,14 +329,45 @@ class BaselineComparator: if mac not in current_macs: missing_devices.append(device) - return { - 'new': new_devices, - 'missing': missing_devices, - 'matching': matching_devices, - 'new_count': len(new_devices), - 'missing_count': len(missing_devices), - 'matching_count': len(matching_devices), - } + return { + 'new': new_devices, + 'missing': missing_devices, + 'matching': matching_devices, + 'new_count': len(new_devices), + 'missing_count': len(missing_devices), + 'matching_count': len(matching_devices), + } + + def compare_wifi_clients(self, current_devices: list[dict]) -> dict: + """Compare current WiFi clients against baseline.""" + current_macs = { + d.get('mac', d.get('address', '')).upper(): d + for d in current_devices + if d.get('mac') or d.get('address') + } + + new_devices = [] + missing_devices = [] + matching_devices = [] + + for mac, device in current_macs.items(): + if mac not in self.baseline_wifi_clients: + new_devices.append(device) + else: + matching_devices.append(device) + + for mac, device in self.baseline_wifi_clients.items(): + if mac not in current_macs: + missing_devices.append(device) + + return { + 'new': new_devices, + 'missing': missing_devices, + 'matching': matching_devices, + 'new_count': len(new_devices), + 'missing_count': len(missing_devices), + 'matching_count': len(matching_devices), + } def compare_rf(self, current_signals: list[dict]) -> dict: """Compare current RF signals against baseline.""" @@ -331,35 +400,42 @@ class BaselineComparator: 'matching_count': len(matching_signals), } - def compare_all( - self, - wifi_devices: list[dict] | None = None, - bt_devices: list[dict] | None = None, - rf_signals: list[dict] | None = None - ) -> dict: + def compare_all( + self, + wifi_devices: list[dict] | None = None, + wifi_clients: list[dict] | None = None, + bt_devices: list[dict] | None = None, + rf_signals: list[dict] | None = None + ) -> dict: """ Compare all current data against baseline. Returns: Dict with comparison results for each category """ - results = { - 'wifi': None, - 'bluetooth': None, - 'rf': None, - 'total_new': 0, - 'total_missing': 0, - } + results = { + 'wifi': None, + 'wifi_clients': None, + 'bluetooth': None, + 'rf': None, + 'total_new': 0, + 'total_missing': 0, + } - if wifi_devices is not None: - results['wifi'] = self.compare_wifi(wifi_devices) - results['total_new'] += results['wifi']['new_count'] - results['total_missing'] += results['wifi']['missing_count'] - - if bt_devices is not None: - results['bluetooth'] = self.compare_bluetooth(bt_devices) - results['total_new'] += results['bluetooth']['new_count'] - results['total_missing'] += results['bluetooth']['missing_count'] + if wifi_devices is not None: + results['wifi'] = self.compare_wifi(wifi_devices) + results['total_new'] += results['wifi']['new_count'] + results['total_missing'] += results['wifi']['missing_count'] + + if wifi_clients is not None: + results['wifi_clients'] = self.compare_wifi_clients(wifi_clients) + results['total_new'] += results['wifi_clients']['new_count'] + results['total_missing'] += results['wifi_clients']['missing_count'] + + if bt_devices is not None: + results['bluetooth'] = self.compare_bluetooth(bt_devices) + results['total_new'] += results['bluetooth']['new_count'] + results['total_missing'] += results['bluetooth']['missing_count'] if rf_signals is not None: results['rf'] = self.compare_rf(rf_signals) @@ -369,11 +445,12 @@ class BaselineComparator: return results -def get_comparison_for_active_baseline( - wifi_devices: list[dict] | None = None, - bt_devices: list[dict] | None = None, - rf_signals: list[dict] | None = None -) -> dict | None: +def get_comparison_for_active_baseline( + wifi_devices: list[dict] | None = None, + wifi_clients: list[dict] | None = None, + bt_devices: list[dict] | None = None, + rf_signals: list[dict] | None = None +) -> dict | None: """ Convenience function to compare against the active baseline. @@ -385,4 +462,4 @@ def get_comparison_for_active_baseline( return None comparator = BaselineComparator(baseline) - return comparator.compare_all(wifi_devices, bt_devices, rf_signals) + return comparator.compare_all(wifi_devices, wifi_clients, bt_devices, rf_signals) diff --git a/utils/tscm/correlation.py b/utils/tscm/correlation.py index e72e34b..d924f4d 100644 --- a/utils/tscm/correlation.py +++ b/utils/tscm/correlation.py @@ -22,7 +22,7 @@ logger = logging.getLogger('intercept.tscm.correlation') class RiskLevel(Enum): """Risk classification levels.""" INFORMATIONAL = 'informational' # Score 0-2 - NEEDS_REVIEW = 'review' # Score 3-5 + NEEDS_REVIEW = 'needs_review' # Score 3-5 HIGH_INTEREST = 'high_interest' # Score 6+ @@ -118,10 +118,15 @@ class DeviceProfile: identifier: str # MAC, BSSID, or frequency protocol: str # 'bluetooth', 'wifi', 'rf' - # Device info - name: Optional[str] = None - manufacturer: Optional[str] = None - device_type: Optional[str] = None + # Device info + name: Optional[str] = None + manufacturer: Optional[str] = None + device_type: Optional[str] = None + tracker_type: Optional[str] = None + tracker_name: Optional[str] = None + tracker_confidence: Optional[str] = None + tracker_confidence_score: Optional[float] = None + tracker_evidence: list[str] = field(default_factory=list) # Bluetooth-specific services: list[str] = field(default_factory=list) @@ -154,12 +159,12 @@ class DeviceProfile: # Correlation correlated_devices: list[str] = field(default_factory=list) - # Output - confidence: float = 0.0 - recommended_action: str = 'monitor' - known_device: bool = False - known_device_name: Optional[str] = None - score_modifier: int = 0 + # Output + confidence: float = 0.0 + recommended_action: str = 'monitor' + known_device: bool = False + known_device_name: Optional[str] = None + score_modifier: int = 0 def add_rssi_sample(self, rssi: int) -> None: """Add an RSSI sample with timestamp.""" @@ -193,9 +198,9 @@ class DeviceProfile: )) self._recalculate_score() - def _recalculate_score(self) -> None: - """Recalculate total score and risk level.""" - self.total_score = sum(i.score for i in self.indicators) + def _recalculate_score(self) -> None: + """Recalculate total score and risk level.""" + self.total_score = sum(i.score for i in self.indicators) if self.total_score >= 6: self.risk_level = RiskLevel.HIGH_INTEREST @@ -207,38 +212,43 @@ class DeviceProfile: self.risk_level = RiskLevel.INFORMATIONAL self.recommended_action = 'monitor' - # Calculate confidence based on number and quality of indicators - indicator_count = len(self.indicators) - self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) - - def apply_score_modifier(self, modifier: int | None) -> None: - """Apply a score modifier (e.g., known-good device adjustment).""" - base_score = sum(i.score for i in self.indicators) - modifier_val = int(modifier) if modifier is not None else 0 - self.score_modifier = modifier_val - self.total_score = max(0, base_score + modifier_val) - - if self.total_score >= 6: - self.risk_level = RiskLevel.HIGH_INTEREST - self.recommended_action = 'investigate' - elif self.total_score >= 3: - self.risk_level = RiskLevel.NEEDS_REVIEW - self.recommended_action = 'review' - else: - self.risk_level = RiskLevel.INFORMATIONAL - self.recommended_action = 'monitor' - - indicator_count = len(self.indicators) - self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) + # Calculate confidence based on number and quality of indicators + indicator_count = len(self.indicators) + self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) - def to_dict(self) -> dict: - """Convert to dictionary for JSON serialization.""" - return { - 'identifier': self.identifier, - 'protocol': self.protocol, - 'name': self.name, - 'manufacturer': self.manufacturer, - 'device_type': self.device_type, + def apply_score_modifier(self, modifier: int | None) -> None: + """Apply a score modifier (e.g., known-good device adjustment).""" + base_score = sum(i.score for i in self.indicators) + modifier_val = int(modifier) if modifier is not None else 0 + self.score_modifier = modifier_val + self.total_score = max(0, base_score + modifier_val) + + if self.total_score >= 6: + self.risk_level = RiskLevel.HIGH_INTEREST + self.recommended_action = 'investigate' + elif self.total_score >= 3: + self.risk_level = RiskLevel.NEEDS_REVIEW + self.recommended_action = 'review' + else: + self.risk_level = RiskLevel.INFORMATIONAL + self.recommended_action = 'monitor' + + indicator_count = len(self.indicators) + self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + 'identifier': self.identifier, + 'protocol': self.protocol, + 'name': self.name, + 'manufacturer': self.manufacturer, + 'device_type': self.device_type, + 'tracker_type': self.tracker_type, + 'tracker_name': self.tracker_name, + 'tracker_confidence': self.tracker_confidence, + 'tracker_confidence_score': self.tracker_confidence_score, + 'tracker_evidence': self.tracker_evidence, 'ssid': self.ssid, 'frequency': self.frequency, 'first_seen': self.first_seen.isoformat() if self.first_seen else None, @@ -254,26 +264,45 @@ class DeviceProfile: } for i in self.indicators ], - 'total_score': self.total_score, - 'score_modifier': self.score_modifier, - 'risk_level': self.risk_level.value, - 'confidence': round(self.confidence, 2), - 'recommended_action': self.recommended_action, - 'correlated_devices': self.correlated_devices, - 'known_device': self.known_device, - 'known_device_name': self.known_device_name, - } + 'total_score': self.total_score, + 'score_modifier': self.score_modifier, + 'risk_level': self.risk_level.value, + 'confidence': round(self.confidence, 2), + 'recommended_action': self.recommended_action, + 'correlated_devices': self.correlated_devices, + 'known_device': self.known_device, + 'known_device_name': self.known_device_name, + } # Known audio-capable BLE service UUIDs -AUDIO_SERVICE_UUIDS = [ - '0000110b-0000-1000-8000-00805f9b34fb', # A2DP Sink - '0000110a-0000-1000-8000-00805f9b34fb', # A2DP Source - '0000111e-0000-1000-8000-00805f9b34fb', # Handsfree - '0000111f-0000-1000-8000-00805f9b34fb', # Handsfree Audio Gateway - '00001108-0000-1000-8000-00805f9b34fb', # Headset - '00001203-0000-1000-8000-00805f9b34fb', # Generic Audio -] +AUDIO_SERVICE_UUIDS = [ + '0000110b-0000-1000-8000-00805f9b34fb', # A2DP Sink + '0000110a-0000-1000-8000-00805f9b34fb', # A2DP Source + '0000111e-0000-1000-8000-00805f9b34fb', # Handsfree + '0000111f-0000-1000-8000-00805f9b34fb', # Handsfree Audio Gateway + '00001108-0000-1000-8000-00805f9b34fb', # Headset + '00001203-0000-1000-8000-00805f9b34fb', # Generic Audio +] + +_BT_BASE_UUID_SUFFIX = '-0000-1000-8000-00805f9b34fb' + + +def _normalize_bt_uuid(value: str) -> str: + """Normalize BLE UUIDs to 16-bit where possible.""" + if not value: + return '' + uuid = str(value).lower().strip() + if uuid.startswith('0x'): + uuid = uuid[2:] + if uuid.endswith(_BT_BASE_UUID_SUFFIX) and len(uuid) >= 8: + return uuid[4:8] + if len(uuid) == 4: + return uuid + return uuid + + +AUDIO_SERVICE_UUIDS_16 = {_normalize_bt_uuid(u) for u in AUDIO_SERVICE_UUIDS} # Generic chipset vendors (often used in covert devices) GENERIC_CHIPSET_VENDORS = [ @@ -308,11 +337,11 @@ class CorrelationEngine: potential surveillance activity patterns. """ - def __init__(self): - self.device_profiles: dict[str, DeviceProfile] = {} - self.meeting_windows: list[tuple[datetime, datetime]] = [] - self.correlation_window = timedelta(minutes=5) - self._known_device_cache: dict[str, dict | None] = {} + def __init__(self): + self.device_profiles: dict[str, DeviceProfile] = {} + self.meeting_windows: list[tuple[datetime, datetime]] = [] + self.correlation_window = timedelta(minutes=5) + self._known_device_cache: dict[str, dict | None] = {} def start_meeting_window(self) -> None: """Mark the start of a sensitive period (meeting).""" @@ -326,64 +355,64 @@ class CorrelationEngine: self.meeting_windows[-1] = (start, datetime.now()) logger.info("Meeting window ended") - def is_during_meeting(self, timestamp: datetime = None) -> bool: - """Check if timestamp falls within a meeting window.""" - ts = timestamp or datetime.now() - for start, end in self.meeting_windows: - if end is None: - if ts >= start: - return True - elif start <= ts <= end: - return True - return False - - def _lookup_known_device(self, identifier: str, protocol: str) -> dict | None: - """Lookup known-good device details with light normalization.""" - cache_key = f"{protocol}:{identifier}" - if cache_key in self._known_device_cache: - return self._known_device_cache[cache_key] - - try: - from utils.database import is_known_good_device - - candidates = [] - if identifier: - candidates.append(str(identifier)) - - if protocol == 'rf': - try: - freq_val = float(identifier) - candidates.append(f"{freq_val:.3f}") - candidates.append(f"{freq_val:.1f}") - except (ValueError, TypeError): - pass - - known = None - for cand in candidates: - if not cand: - continue - known = is_known_good_device(str(cand).upper()) - if known: - break - except Exception: - known = None - - self._known_device_cache[cache_key] = known - return known - - def _apply_known_device_modifier(self, profile: DeviceProfile, identifier: str, protocol: str) -> None: - """Apply known-good score modifier and update profile metadata.""" - known = self._lookup_known_device(identifier, protocol) - if known: - profile.known_device = True - profile.known_device_name = known.get('name') if isinstance(known, dict) else None - modifier = known.get('score_modifier', 0) if isinstance(known, dict) else 0 - else: - profile.known_device = False - profile.known_device_name = None - modifier = 0 - - profile.apply_score_modifier(modifier) + def is_during_meeting(self, timestamp: datetime = None) -> bool: + """Check if timestamp falls within a meeting window.""" + ts = timestamp or datetime.now() + for start, end in self.meeting_windows: + if end is None: + if ts >= start: + return True + elif start <= ts <= end: + return True + return False + + def _lookup_known_device(self, identifier: str, protocol: str) -> dict | None: + """Lookup known-good device details with light normalization.""" + cache_key = f"{protocol}:{identifier}" + if cache_key in self._known_device_cache: + return self._known_device_cache[cache_key] + + try: + from utils.database import is_known_good_device + + candidates = [] + if identifier: + candidates.append(str(identifier)) + + if protocol == 'rf': + try: + freq_val = float(identifier) + candidates.append(f"{freq_val:.3f}") + candidates.append(f"{freq_val:.1f}") + except (ValueError, TypeError): + pass + + known = None + for cand in candidates: + if not cand: + continue + known = is_known_good_device(str(cand).upper()) + if known: + break + except Exception: + known = None + + self._known_device_cache[cache_key] = known + return known + + def _apply_known_device_modifier(self, profile: DeviceProfile, identifier: str, protocol: str) -> None: + """Apply known-good score modifier and update profile metadata.""" + known = self._lookup_known_device(identifier, protocol) + if known: + profile.known_device = True + profile.known_device_name = known.get('name') if isinstance(known, dict) else None + modifier = known.get('score_modifier', 0) if isinstance(known, dict) else 0 + else: + profile.known_device = False + profile.known_device_name = None + modifier = 0 + + profile.apply_score_modifier(modifier) def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile: """Get existing profile or create new one.""" @@ -415,10 +444,24 @@ class CorrelationEngine: # Update profile data profile.name = device.get('name') or profile.name profile.manufacturer = device.get('manufacturer') or profile.manufacturer - profile.device_type = device.get('type') or profile.device_type - profile.services = device.get('services', []) or profile.services - profile.company_id = device.get('company_id') or profile.company_id - profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval + profile.device_type = device.get('type') or profile.device_type + services = device.get('services') + if not services: + services = device.get('service_uuids') + profile.services = services or profile.services + profile.company_id = device.get('company_id') or profile.company_id + profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval + tracker_data = device.get('tracker') or {} + if tracker_data: + profile.tracker_type = tracker_data.get('type') or profile.tracker_type + profile.tracker_name = tracker_data.get('name') or profile.tracker_name + profile.tracker_confidence = tracker_data.get('confidence') or profile.tracker_confidence + profile.tracker_confidence_score = tracker_data.get('confidence_score') or profile.tracker_confidence_score + evidence = tracker_data.get('evidence') + if isinstance(evidence, list): + profile.tracker_evidence = evidence + elif evidence: + profile.tracker_evidence = [str(evidence)] # Add RSSI sample rssi = device.get('rssi', device.get('signal')) @@ -431,15 +474,28 @@ class CorrelationEngine: # Clear previous indicators for fresh analysis profile.indicators = [] - # === Detection Logic === - - # 1. Unknown manufacturer or generic chipset - if not profile.manufacturer: - profile.add_indicator( - IndicatorType.UNKNOWN_DEVICE, - 'Unknown manufacturer', - {'manufacturer': None} - ) + # === Detection Logic === + + # 1. Unknown manufacturer or generic chipset + if not profile.manufacturer and mac and not device.get('is_randomized_mac'): + try: + first_octet = int(mac.split(':')[0], 16) + except (ValueError, IndexError): + first_octet = None + if first_octet is None or not (first_octet & 0x02): + try: + from data.oui import get_manufacturer + vendor = get_manufacturer(mac) + if vendor and vendor != 'Unknown': + profile.manufacturer = vendor + except Exception: + pass + if not profile.manufacturer: + profile.add_indicator( + IndicatorType.UNKNOWN_DEVICE, + 'Unknown manufacturer', + {'manufacturer': None} + ) elif any(v in profile.manufacturer.lower() for v in GENERIC_CHIPSET_VENDORS): profile.add_indicator( IndicatorType.UNKNOWN_DEVICE, @@ -455,16 +511,16 @@ class CorrelationEngine: {'name': profile.name} ) - # 3. Audio-capable services - if profile.services: - audio_services = [s for s in profile.services - if s.lower() in [u.lower() for u in AUDIO_SERVICE_UUIDS]] - if audio_services: - profile.add_indicator( - IndicatorType.AUDIO_CAPABLE, - 'Audio-capable BLE services detected', - {'services': audio_services} - ) + # 3. Audio-capable services + if profile.services: + normalized_services = {_normalize_bt_uuid(s) for s in profile.services if s} + audio_services = [s for s in normalized_services if s in AUDIO_SERVICE_UUIDS_16] + if audio_services: + profile.add_indicator( + IndicatorType.AUDIO_CAPABLE, + 'Audio-capable BLE services detected', + {'services': audio_services} + ) # Check name for audio keywords if profile.name: @@ -518,15 +574,47 @@ class CorrelationEngine: {'mac': mac} ) - # 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32) - mac_prefix = mac[:8] if len(mac) >= 8 else '' - tracker_detected = False - - # Check for tracker flags from BLE scanner (manufacturer ID detection) - if device.get('is_airtag'): - profile.add_indicator( - IndicatorType.AIRTAG_DETECTED, - 'Apple AirTag detected via manufacturer data', + # 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32) + mac_prefix = mac[:8] if len(mac) >= 8 else '' + tracker_detected = False + tracker_data = device.get('tracker') or {} + + if tracker_data.get('is_tracker'): + tracker_detected = True + tracker_label = tracker_data.get('name') or tracker_data.get('type') + if tracker_label: + label_lower = str(tracker_label).lower() + if 'airtag' in label_lower or 'find my' in label_lower: + profile.add_indicator( + IndicatorType.AIRTAG_DETECTED, + f'Tracker detected: {tracker_label}', + {'mac': mac, 'tracker_type': tracker_label} + ) + profile.device_type = 'AirTag' + elif 'tile' in label_lower: + profile.add_indicator( + IndicatorType.TILE_DETECTED, + f'Tracker detected: {tracker_label}', + {'mac': mac, 'tracker_type': tracker_label} + ) + profile.device_type = 'Tile Tracker' + elif 'smarttag' in label_lower or 'samsung' in label_lower: + profile.add_indicator( + IndicatorType.SMARTTAG_DETECTED, + f'Tracker detected: {tracker_label}', + {'mac': mac, 'tracker_type': tracker_label} + ) + profile.device_type = 'Samsung SmartTag' + else: + profile.device_type = tracker_label + elif not profile.device_type: + profile.device_type = 'Tracker' + + # Check for tracker flags from BLE scanner (manufacturer ID detection) + if device.get('is_airtag'): + profile.add_indicator( + IndicatorType.AIRTAG_DETECTED, + 'Apple AirTag detected via manufacturer data', {'mac': mac, 'tracker_type': 'AirTag'} ) profile.device_type = device.get('tracker_type', 'AirTag') @@ -634,59 +722,69 @@ class CorrelationEngine: ) # Also check name for tracker keywords - if profile.name: - name_lower = profile.name.lower() - if 'airtag' in name_lower or 'findmy' in name_lower: - profile.add_indicator( - IndicatorType.AIRTAG_DETECTED, - f'AirTag identified by name: {profile.name}', - {'name': profile.name} - ) - profile.device_type = 'AirTag' - elif 'tile' in name_lower: - profile.add_indicator( - IndicatorType.TILE_DETECTED, - f'Tile tracker identified by name: {profile.name}', - {'name': profile.name} - ) - profile.device_type = 'Tile Tracker' - elif 'smarttag' in name_lower: - profile.add_indicator( - IndicatorType.SMARTTAG_DETECTED, - f'SmartTag identified by name: {profile.name}', - {'name': profile.name} - ) - profile.device_type = 'Samsung SmartTag' - - self._apply_known_device_modifier(profile, mac, 'bluetooth') - - return profile + if profile.name: + name_lower = profile.name.lower() + if 'airtag' in name_lower or 'findmy' in name_lower: + profile.add_indicator( + IndicatorType.AIRTAG_DETECTED, + f'AirTag identified by name: {profile.name}', + {'name': profile.name} + ) + profile.device_type = 'AirTag' + elif 'tile' in name_lower: + profile.add_indicator( + IndicatorType.TILE_DETECTED, + f'Tile tracker identified by name: {profile.name}', + {'name': profile.name} + ) + profile.device_type = 'Tile Tracker' + elif 'smarttag' in name_lower: + profile.add_indicator( + IndicatorType.SMARTTAG_DETECTED, + f'SmartTag identified by name: {profile.name}', + {'name': profile.name} + ) + profile.device_type = 'Samsung SmartTag' - def analyze_wifi_device(self, device: dict) -> DeviceProfile: - """ - Analyze a Wi-Fi device/AP for suspicious indicators. + self._apply_known_device_modifier(profile, mac, 'bluetooth') + + return profile + + def analyze_wifi_device(self, device: dict) -> DeviceProfile: + """ + Analyze a Wi-Fi device/AP for suspicious indicators. Args: device: Dict with bssid, ssid, channel, rssi, encryption, etc. - Returns: - DeviceProfile with risk assessment - """ - bssid = device.get('bssid', device.get('mac', '')).upper() - profile = self.get_or_create_profile(bssid, 'wifi') - - # Update profile data - ssid = device.get('ssid', device.get('essid', '')) - profile.ssid = ssid if ssid else profile.ssid - profile.name = ssid or f'Hidden Network ({bssid[-8:]})' - profile.channel = device.get('channel') or profile.channel - profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption - profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval - profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]'] - - # Extract manufacturer from OUI - if bssid and len(bssid) >= 8: - profile.manufacturer = device.get('vendor') or profile.manufacturer + Returns: + DeviceProfile with risk assessment + """ + bssid = device.get('bssid', device.get('mac', '')).upper() + profile = self.get_or_create_profile(bssid, 'wifi') + is_client = bool(device.get('is_client') or device.get('role') == 'client') + + # Update profile data + ssid = device.get('ssid', device.get('essid', '')) + if is_client: + profile.name = device.get('name') or device.get('vendor') or profile.name or f'Client ({bssid[-8:]})' + profile.device_type = 'client' + profile.ssid = profile.ssid # Clients are not SSIDs + profile.channel = device.get('channel') or profile.channel + profile.encryption = profile.encryption + profile.beacon_interval = profile.beacon_interval + profile.is_hidden = False + else: + profile.ssid = ssid if ssid else profile.ssid + profile.name = ssid or f'Hidden Network ({bssid[-8:]})' + profile.channel = device.get('channel') or profile.channel + profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption + profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval + profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]'] + + # Extract manufacturer from OUI + if bssid and len(bssid) >= 8: + profile.manufacturer = device.get('vendor') or profile.manufacturer # Add RSSI sample rssi = device.get('rssi', device.get('power', device.get('signal'))) @@ -699,82 +797,122 @@ class CorrelationEngine: # Clear previous indicators profile.indicators = [] - # === Detection Logic === - - # 1. Hidden or unnamed SSID - if profile.is_hidden: - profile.add_indicator( - IndicatorType.HIDDEN_IDENTITY, - 'Hidden or empty SSID', - {'ssid': ssid} - ) - - # 2. BSSID not in authorized list (would need baseline) - # For now, mark as unknown if no manufacturer - if not profile.manufacturer: - profile.add_indicator( - IndicatorType.UNKNOWN_DEVICE, - 'Unknown AP manufacturer', - {'bssid': bssid} - ) - - # 3. Consumer device OUI in restricted environment - consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus'] - if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis): - profile.add_indicator( - IndicatorType.ROGUE_AP, - f'Consumer-grade AP detected: {profile.manufacturer}', - {'manufacturer': profile.manufacturer} - ) - - # 4. Camera device patterns - camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze', - 'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi'] - if ssid and any(k in ssid.lower() for k in camera_keywords): - profile.add_indicator( - IndicatorType.AUDIO_CAPABLE, # Cameras often have mics - f'Potential camera device: {ssid}', - {'ssid': ssid} - ) - - # 5. Persistent presence - if profile.detection_count >= 3: - profile.add_indicator( - IndicatorType.PERSISTENT, - f'Persistent AP ({profile.detection_count} detections)', - {'count': profile.detection_count} - ) - - # 6. Stable RSSI (fixed placement) - rssi_stability = profile.get_rssi_stability() - if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5: - profile.add_indicator( - IndicatorType.STABLE_RSSI, - f'Stable signal (stability: {rssi_stability:.0%})', - {'stability': rssi_stability} - ) - - # 7. Meeting correlation - if self.is_during_meeting(): - profile.add_indicator( - IndicatorType.MEETING_CORRELATED, - 'Detected during sensitive period', - {'during_meeting': True} - ) - - # 8. Strong hidden AP (very suspicious) - if profile.is_hidden and profile.rssi_samples: - latest_rssi = profile.rssi_samples[-1][1] - if latest_rssi > -50: + # === Detection Logic === + if is_client: + if not profile.manufacturer: profile.add_indicator( - IndicatorType.ROGUE_AP, - f'Strong hidden AP (RSSI: {latest_rssi} dBm)', - {'rssi': latest_rssi} + IndicatorType.UNKNOWN_DEVICE, + 'Unknown client manufacturer', + {'mac': bssid} ) - self._apply_known_device_modifier(profile, bssid, 'wifi') + if profile.detection_count >= 3: + profile.add_indicator( + IndicatorType.PERSISTENT, + f'Persistent client ({profile.detection_count} detections)', + {'count': profile.detection_count} + ) - return profile + rssi_stability = profile.get_rssi_stability() + if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5: + profile.add_indicator( + IndicatorType.STABLE_RSSI, + f'Stable client signal (stability: {rssi_stability:.0%})', + {'stability': rssi_stability} + ) + + if self.is_during_meeting(): + profile.add_indicator( + IndicatorType.MEETING_CORRELATED, + 'Detected during sensitive period', + {'during_meeting': True} + ) + + try: + first_octet = int(bssid.split(':')[0], 16) + if first_octet & 0x02: + profile.add_indicator( + IndicatorType.MAC_ROTATION, + 'Random/locally administered MAC detected', + {'mac': bssid} + ) + except (ValueError, IndexError): + pass + else: + # 1. Hidden or unnamed SSID + if profile.is_hidden: + profile.add_indicator( + IndicatorType.HIDDEN_IDENTITY, + 'Hidden or empty SSID', + {'ssid': ssid} + ) + + # 2. BSSID not in authorized list (would need baseline) + # For now, mark as unknown if no manufacturer + if not profile.manufacturer: + profile.add_indicator( + IndicatorType.UNKNOWN_DEVICE, + 'Unknown AP manufacturer', + {'bssid': bssid} + ) + + # 3. Consumer device OUI in restricted environment + consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus'] + if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis): + profile.add_indicator( + IndicatorType.ROGUE_AP, + f'Consumer-grade AP detected: {profile.manufacturer}', + {'manufacturer': profile.manufacturer} + ) + + # 4. Camera device patterns + camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze', + 'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi'] + if ssid and any(k in ssid.lower() for k in camera_keywords): + profile.add_indicator( + IndicatorType.AUDIO_CAPABLE, # Cameras often have mics + f'Potential camera device: {ssid}', + {'ssid': ssid} + ) + + # 5. Persistent presence + if profile.detection_count >= 3: + profile.add_indicator( + IndicatorType.PERSISTENT, + f'Persistent AP ({profile.detection_count} detections)', + {'count': profile.detection_count} + ) + + # 6. Stable RSSI (fixed placement) + rssi_stability = profile.get_rssi_stability() + if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5: + profile.add_indicator( + IndicatorType.STABLE_RSSI, + f'Stable signal (stability: {rssi_stability:.0%})', + {'stability': rssi_stability} + ) + + # 7. Meeting correlation + if self.is_during_meeting(): + profile.add_indicator( + IndicatorType.MEETING_CORRELATED, + 'Detected during sensitive period', + {'during_meeting': True} + ) + + # 8. Strong hidden AP (very suspicious) + if profile.is_hidden and profile.rssi_samples: + latest_rssi = profile.rssi_samples[-1][1] + if latest_rssi > -50: + profile.add_indicator( + IndicatorType.ROGUE_AP, + f'Strong hidden AP (RSSI: {latest_rssi} dBm)', + {'rssi': latest_rssi} + ) + + self._apply_known_device_modifier(profile, bssid, 'wifi') + + return profile def analyze_rf_signal(self, signal: dict) -> DeviceProfile: """ @@ -857,16 +995,16 @@ class CorrelationEngine: ) # 5. Meeting correlation - if self.is_during_meeting(): - profile.add_indicator( - IndicatorType.MEETING_CORRELATED, - 'Signal detected during sensitive period', - {'during_meeting': True} - ) - - self._apply_known_device_modifier(profile, freq_key, 'rf') - - return profile + if self.is_during_meeting(): + profile.add_indicator( + IndicatorType.MEETING_CORRELATED, + 'Signal detected during sensitive period', + {'during_meeting': True} + ) + + self._apply_known_device_modifier(profile, freq_key, 'rf') + + return profile def correlate_devices(self) -> list[dict]: """ @@ -953,26 +1091,26 @@ class CorrelationEngine: {'correlated_device': ap.identifier} ) - # Correlation 3: Same vendor BLE + WiFi - for bt in bt_devices: - if bt.manufacturer: - for wifi in wifi_devices: - if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower(): - correlation = { + # Correlation 3: Same vendor BLE + WiFi + for bt in bt_devices: + if bt.manufacturer: + for wifi in wifi_devices: + if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower(): + correlation = { 'type': 'same_vendor_bt_wifi', 'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi', 'devices': [bt.identifier, wifi.identifier], 'protocols': ['bluetooth', 'wifi'], 'score_boost': 2, 'significance': 'medium', - } - correlations.append(correlation) - - # Re-apply known-good modifiers after correlation boosts - for profile in self.device_profiles.values(): - self._apply_known_device_modifier(profile, profile.identifier, profile.protocol) - - return correlations + } + correlations.append(correlation) + + # Re-apply known-good modifiers after correlation boosts + for profile in self.device_profiles.values(): + self._apply_known_device_modifier(profile, profile.identifier, profile.protocol) + + return correlations def get_high_interest_devices(self) -> list[DeviceProfile]: """Get all devices classified as high interest.""" diff --git a/utils/tscm/detector.py b/utils/tscm/detector.py index d09412b..fa94518 100644 --- a/utils/tscm/detector.py +++ b/utils/tscm/detector.py @@ -113,14 +113,18 @@ class ThreatDetector: def _load_baseline(self, baseline: dict) -> None: """Load baseline device identifiers for comparison.""" - # WiFi networks and clients - for network in baseline.get('wifi_networks', []): - if 'bssid' in network: - self.baseline_wifi_macs.add(network['bssid'].upper()) - if 'clients' in network: - for client in network['clients']: - if 'mac' in client: - self.baseline_wifi_macs.add(client['mac'].upper()) + # WiFi networks and clients + for network in baseline.get('wifi_networks', []): + if 'bssid' in network: + self.baseline_wifi_macs.add(network['bssid'].upper()) + if 'clients' in network: + for client in network['clients']: + if 'mac' in client: + self.baseline_wifi_macs.add(client['mac'].upper()) + + for client in baseline.get('wifi_clients', []): + if 'mac' in client: + self.baseline_wifi_macs.add(client['mac'].upper()) # Bluetooth devices for device in baseline.get('bt_devices', []): @@ -476,11 +480,12 @@ class ThreatDetector: mac = device.get('mac', device.get('address', '')).upper() name = device.get('name', '') rssi = device.get('rssi', device.get('signal', -100)) - manufacturer = device.get('manufacturer', '') - device_type = device.get('type', '') - manufacturer_data = device.get('manufacturer_data') - - threats = [] + manufacturer = device.get('manufacturer', '') + device_type = device.get('type', '') + manufacturer_data = device.get('manufacturer_data') + tracker_data = device.get('tracker', {}) or {} + + threats = [] # Check if new device (not in baseline) if self.baseline and mac and mac not in self.baseline_bt_macs: @@ -490,12 +495,25 @@ class ThreatDetector: 'reason': 'Device not present in baseline', }) - # Check for known trackers - tracker_info = is_known_tracker(name, manufacturer_data) - if tracker_info: - threats.append({ - 'type': 'tracker', - 'severity': tracker_info.get('risk', 'high'), + # Check for known trackers (v2 tracker data if available) + if tracker_data.get('is_tracker'): + tracker_label = tracker_data.get('name') or tracker_data.get('type') or 'Tracker' + confidence = str(tracker_data.get('confidence') or '').lower() + severity = 'high' if confidence in ('high', 'medium') else 'medium' + threats.append({ + 'type': 'tracker', + 'severity': severity, + 'reason': f"Tracker detected: {tracker_label}", + 'tracker_type': tracker_label, + 'details': tracker_data.get('evidence', []), + }) + + # Check for known trackers (legacy patterns) + tracker_info = is_known_tracker(name, manufacturer_data) + if tracker_info: + threats.append({ + 'type': 'tracker', + 'severity': tracker_info.get('risk', 'high'), 'reason': f"Known tracker detected: {tracker_info.get('name', 'Unknown')}", 'tracker_type': tracker_info.get('name'), }) diff --git a/utils/tscm/reports.py b/utils/tscm/reports.py index 627bb03..e90b851 100644 --- a/utils/tscm/reports.py +++ b/utils/tscm/reports.py @@ -102,13 +102,14 @@ class TSCMReport: # Meeting window summaries meeting_summaries: list[ReportMeetingSummary] = field(default_factory=list) - # Statistics - total_devices_scanned: int = 0 - wifi_devices: int = 0 - bluetooth_devices: int = 0 - rf_signals: int = 0 - new_devices: int = 0 - missing_devices: int = 0 + # Statistics + total_devices_scanned: int = 0 + wifi_devices: int = 0 + wifi_clients: int = 0 + bluetooth_devices: int = 0 + rf_signals: int = 0 + new_devices: int = 0 + missing_devices: int = 0 # Sweep duration sweep_start: Optional[datetime] = None @@ -200,12 +201,13 @@ def generate_executive_summary(report: TSCMReport) -> str: lines.append("") # Key statistics - lines.append("SCAN STATISTICS:") - lines.append(f" - Total devices scanned: {report.total_devices_scanned}") - lines.append(f" - WiFi access points: {report.wifi_devices}") - lines.append(f" - Bluetooth devices: {report.bluetooth_devices}") - lines.append(f" - RF signals: {report.rf_signals}") - lines.append("") + lines.append("SCAN STATISTICS:") + lines.append(f" - Total devices scanned: {report.total_devices_scanned}") + lines.append(f" - WiFi access points: {report.wifi_devices}") + lines.append(f" - WiFi clients: {report.wifi_clients}") + lines.append(f" - Bluetooth devices: {report.bluetooth_devices}") + lines.append(f" - RF signals: {report.rf_signals}") + lines.append("") # Findings summary lines.append("FINDINGS SUMMARY:") @@ -427,13 +429,14 @@ def generate_technical_annex_json(report: TSCMReport) -> dict: 'capabilities': report.capabilities, 'limitations': report.limitations, - 'statistics': { - 'total_devices': report.total_devices_scanned, - 'wifi_devices': report.wifi_devices, - 'bluetooth_devices': report.bluetooth_devices, - 'rf_signals': report.rf_signals, - 'new_devices': report.new_devices, - 'missing_devices': report.missing_devices, + 'statistics': { + 'total_devices': report.total_devices_scanned, + 'wifi_devices': report.wifi_devices, + 'wifi_clients': report.wifi_clients, + 'bluetooth_devices': report.bluetooth_devices, + 'rf_signals': report.rf_signals, + 'new_devices': report.new_devices, + 'missing_devices': report.missing_devices, 'high_interest_count': len(report.high_interest_findings), 'needs_review_count': len(report.needs_review_findings), 'informational_count': len(report.informational_findings), @@ -781,21 +784,23 @@ class TSCMReportBuilder: self.report.meeting_summaries.append(meeting) return self - def add_statistics( - self, - wifi: int = 0, - bluetooth: int = 0, - rf: int = 0, - new: int = 0, - missing: int = 0 - ) -> 'TSCMReportBuilder': - self.report.wifi_devices = wifi - self.report.bluetooth_devices = bluetooth - self.report.rf_signals = rf - self.report.total_devices_scanned = wifi + bluetooth + rf - self.report.new_devices = new - self.report.missing_devices = missing - return self + def add_statistics( + self, + wifi: int = 0, + wifi_clients: int = 0, + bluetooth: int = 0, + rf: int = 0, + new: int = 0, + missing: int = 0 + ) -> 'TSCMReportBuilder': + self.report.wifi_devices = wifi + self.report.wifi_clients = wifi_clients + self.report.bluetooth_devices = bluetooth + self.report.rf_signals = rf + self.report.total_devices_scanned = wifi + wifi_clients + bluetooth + rf + self.report.new_devices = new + self.report.missing_devices = missing + return self def add_device_timelines(self, timelines: list[dict]) -> 'TSCMReportBuilder': self.report.device_timelines = timelines @@ -890,25 +895,30 @@ def generate_report( builder.add_findings_from_profiles(device_profiles) # Statistics - results = sweep_data.get('results', {}) - wifi_count = results.get('wifi_count') - if wifi_count is None: - wifi_count = len(results.get('wifi_devices', results.get('wifi', []))) - - bt_count = results.get('bt_count') - if bt_count is None: - bt_count = len(results.get('bt_devices', results.get('bluetooth', []))) + results = sweep_data.get('results', {}) + wifi_count = results.get('wifi_count') + if wifi_count is None: + wifi_count = len(results.get('wifi_devices', results.get('wifi', []))) + + wifi_client_count = results.get('wifi_client_count') + if wifi_client_count is None: + wifi_client_count = len(results.get('wifi_clients', [])) + + bt_count = results.get('bt_count') + if bt_count is None: + bt_count = len(results.get('bt_devices', results.get('bluetooth', []))) rf_count = results.get('rf_count') if rf_count is None: rf_count = len(results.get('rf_signals', results.get('rf', []))) - builder.add_statistics( - wifi=wifi_count, - bluetooth=bt_count, - rf=rf_count, - new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0, - missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0, + builder.add_statistics( + wifi=wifi_count, + wifi_clients=wifi_client_count, + bluetooth=bt_count, + rf=rf_count, + new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0, + missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0, ) # Technical data diff --git a/utils/wifi/constants.py b/utils/wifi/constants.py index 1fec3be..ccb4d11 100644 --- a/utils/wifi/constants.py +++ b/utils/wifi/constants.py @@ -414,14 +414,27 @@ VENDOR_OUIS = { } -def get_vendor_from_mac(mac: str) -> str | None: - """Get vendor name from MAC address OUI.""" - if not mac: - return None - # Normalize MAC format - mac_upper = mac.upper().replace('-', ':') - oui = mac_upper[:8] - return VENDOR_OUIS.get(oui) +def get_vendor_from_mac(mac: str) -> str | None: + """Get vendor name from MAC address OUI.""" + if not mac: + return None + # Normalize MAC format + mac_upper = mac.upper().replace('-', ':') + oui = mac_upper[:8] + vendor = VENDOR_OUIS.get(oui) + if vendor: + return vendor + + # Fallback to expanded OUI database if available + try: + from data.oui import get_manufacturer + manufacturer = get_manufacturer(mac_upper) + if manufacturer and manufacturer != 'Unknown': + return manufacturer + except Exception: + return None + + return None # ============================================================================= diff --git a/utils/wifi/models.py b/utils/wifi/models.py index fa2f817..b49e02c 100644 --- a/utils/wifi/models.py +++ b/utils/wifi/models.py @@ -259,16 +259,17 @@ class WiFiAccessPoint: 'in_baseline': self.in_baseline, } - def to_legacy_dict(self) -> dict: - """Convert to legacy format for TSCM compatibility.""" - return { - 'bssid': self.bssid, - 'essid': self.essid or '', - 'power': str(self.rssi_current) if self.rssi_current else '-100', - 'channel': str(self.channel) if self.channel else '', - 'privacy': self.security, - 'first_seen': self.first_seen.isoformat() if self.first_seen else '', - 'last_seen': self.last_seen.isoformat() if self.last_seen else '', + def to_legacy_dict(self) -> dict: + """Convert to legacy format for TSCM compatibility.""" + return { + 'bssid': self.bssid, + 'essid': self.essid or '', + 'vendor': self.vendor, + 'power': str(self.rssi_current) if self.rssi_current else '-100', + 'channel': str(self.channel) if self.channel else '', + 'privacy': self.security, + 'first_seen': self.first_seen.isoformat() if self.first_seen else '', + 'last_seen': self.last_seen.isoformat() if self.last_seen else '', 'beacon_count': str(self.beacon_count), 'lan_ip': '', # Not tracked in new system } diff --git a/utils/wifi/scanner.py b/utils/wifi/scanner.py index 94588e7..9951107 100644 --- a/utils/wifi/scanner.py +++ b/utils/wifi/scanner.py @@ -301,6 +301,73 @@ class UnifiedWiFiScanner: return False + def _ensure_interface_up(self, interface: str) -> bool: + """ + Ensure a WiFi interface is up before scanning. + + Attempts to bring the interface up using 'ip link set up', + falling back to 'ifconfig up'. + + Args: + interface: Network interface name. + + Returns: + True if the interface was brought up (or was already up), + False if we failed to bring it up. + """ + # Check current state via /sys/class/net + operstate_path = f"/sys/class/net/{interface}/operstate" + try: + with open(operstate_path) as f: + state = f.read().strip() + if state == "up": + return True + logger.info(f"Interface {interface} is '{state}', attempting to bring up") + except FileNotFoundError: + # Interface might not exist or /sys not available (non-Linux) + return True + except Exception: + pass + + # Try ip link set up + if shutil.which('ip'): + try: + result = subprocess.run( + ['ip', 'link', 'set', interface, 'up'], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + logger.info(f"Brought interface {interface} up via ip link") + time.sleep(1) # Brief settle time + return True + else: + logger.warning(f"ip link set {interface} up failed: {result.stderr.strip()}") + except Exception as e: + logger.warning(f"Failed to run ip link: {e}") + + # Fallback to ifconfig + if shutil.which('ifconfig'): + try: + result = subprocess.run( + ['ifconfig', interface, 'up'], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + logger.info(f"Brought interface {interface} up via ifconfig") + time.sleep(1) + return True + else: + logger.warning(f"ifconfig {interface} up failed: {result.stderr.strip()}") + except Exception as e: + logger.warning(f"Failed to run ifconfig: {e}") + + logger.error(f"Could not bring interface {interface} up") + return False + # ========================================================================= # Quick Scan # ========================================================================= @@ -362,6 +429,9 @@ class UnifiedWiFiScanner: result.is_complete = True return result else: # Linux - try tools in order with fallback + # Ensure interface is up before scanning + self._ensure_interface_up(iface) + tools_to_try = [] if self._capabilities.has_nmcli: tools_to_try.append(('nmcli', self._scan_with_nmcli)) @@ -375,6 +445,7 @@ class UnifiedWiFiScanner: result.is_complete = True return result + interface_was_down = False for tool_name, scan_func in tools_to_try: try: logger.info(f"Attempting quick scan with {tool_name} on {iface}") @@ -386,8 +457,28 @@ class UnifiedWiFiScanner: error_msg = f"{tool_name}: {str(e)}" errors_encountered.append(error_msg) logger.warning(f"Quick scan with {tool_name} failed: {e}") + if 'is down' in str(e): + interface_was_down = True continue # Try next tool + # If all tools failed because interface was down, try bringing it up and retry + if not tool_used and interface_was_down: + logger.info(f"Interface {iface} appears down, attempting to bring up and retry scan") + if self._ensure_interface_up(iface): + errors_encountered.clear() + for tool_name, scan_func in tools_to_try: + try: + logger.info(f"Retrying scan with {tool_name} on {iface} after bringing interface up") + observations = scan_func(iface, timeout) + tool_used = tool_name + logger.info(f"Retry scan with {tool_name} found {len(observations)} networks") + break + except Exception as e: + error_msg = f"{tool_name}: {str(e)}" + errors_encountered.append(error_msg) + logger.warning(f"Retry scan with {tool_name} failed: {e}") + continue + if not tool_used: # All tools failed result.error = "All scan tools failed. " + "; ".join(errors_encountered) @@ -571,12 +662,13 @@ class UnifiedWiFiScanner: # Deep Scan (airodump-ng) # ========================================================================= - def start_deep_scan( - self, - interface: Optional[str] = None, - band: str = 'all', - channel: Optional[int] = None, - ) -> bool: + def start_deep_scan( + self, + interface: Optional[str] = None, + band: str = 'all', + channel: Optional[int] = None, + channels: Optional[list[int]] = None, + ) -> bool: """ Start continuous deep scan with airodump-ng. @@ -609,11 +701,11 @@ class UnifiedWiFiScanner: # Start airodump-ng in background thread self._deep_scan_stop_event.clear() - self._deep_scan_thread = threading.Thread( - target=self._run_deep_scan, - args=(iface, band, channel), - daemon=True, - ) + self._deep_scan_thread = threading.Thread( + target=self._run_deep_scan, + args=(iface, band, channel, channels), + daemon=True, + ) self._deep_scan_thread.start() self._status = WiFiScanStatus( @@ -675,8 +767,14 @@ class UnifiedWiFiScanner: return True - def _run_deep_scan(self, interface: str, band: str, channel: Optional[int]): - """Background thread for running airodump-ng.""" + def _run_deep_scan( + self, + interface: str, + band: str, + channel: Optional[int], + channels: Optional[list[int]], + ): + """Background thread for running airodump-ng.""" from .parsers.airodump import parse_airodump_csv import tempfile @@ -688,12 +786,14 @@ class UnifiedWiFiScanner: # Build command cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv'] - if channel: - cmd.extend(['-c', str(channel)]) - elif band == '2.4': - cmd.extend(['--band', 'bg']) - elif band == '5': - cmd.extend(['--band', 'a']) + if channels: + cmd.extend(['-c', ','.join(str(c) for c in channels)]) + elif channel: + cmd.extend(['-c', str(channel)]) + elif band == '2.4': + cmd.extend(['--band', 'bg']) + elif band == '5': + cmd.extend(['--band', 'a']) cmd.append(interface)