Merge upstream main: add DMR, WebSDR, HF SSTV, alerts, recordings, waterfall

Merges upstream changes into fork while preserving weather satellite
(NOAA APT/Meteor LRPT via SatDump), rtlamr, multi-arch build, and
decoder console features from our branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mitch Ross
2026-02-07 14:29:09 -05:00
88 changed files with 14535 additions and 1927 deletions
+1
View File
@@ -35,6 +35,7 @@ htmlcov/
# Local Postgres data
pgdata/
pgdata.bak/
# Captured files (don't include in image)
*.cap
+2
View File
@@ -0,0 +1,2 @@
# Uncomment and set to use external storage for ADS-B history
# PGDATA_PATH=/mnt/external/intercept/pgdata
+5
View File
@@ -54,3 +54,8 @@ intercept_agent_*.cfg
# Temporary files
/tmp/
*.tmp
# Env files
.env
.env.*
!.env.example
+38
View File
@@ -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
+31
View File
@@ -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/*
+4
View File
@@ -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
+23 -1
View File
@@ -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")
+19 -1
View File
@@ -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')
+2 -1
View File
@@ -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"]
+132 -50
View File
@@ -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:
+3 -1
View File
@@ -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",
+6 -1
View File
@@ -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
+10
View File
@@ -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
+26 -4
View File
@@ -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:
+5
View File
@@ -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()
+5
View File
@@ -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()
+76
View File
@@ -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/<int:rule_id>', 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/<int:rule_id>', 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
+68 -9
View File
@@ -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:
+7 -13
View File
@@ -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:
+12 -7
View File
@@ -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:
+177 -75
View File
@@ -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:
+513
View File
@@ -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
+45 -2
View File
@@ -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()
+497 -69
View File
@@ -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
+50 -7
View File
@@ -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:
+109
View File
@@ -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('/<session_id>', 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('/<session_id>/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,
)
+41 -3
View File
@@ -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()
+26 -6
View File
@@ -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:
+104 -6
View File
@@ -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/<filename>/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/<filename>', 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:
+339
View File
@@ -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/<filename>')
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/<filename>/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/<filename>', 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
+144 -1
View File
@@ -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
+504
View File
@@ -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/<station_id>/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")
+97 -36
View File
@@ -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:
+50 -28
View File
@@ -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'
+229 -86
View File
@@ -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
+17
View File
@@ -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;
+668
View File
@@ -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; }
}
+192 -1
View File
@@ -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
============================================ */
+22
View File
@@ -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;
+41
View File
@@ -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;
+194
View File
@@ -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 = '<div class="settings-feed-empty">No alerts yet</div>';
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 `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${title}</span>
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
</div>
<div class="settings-feed-meta">${message}</div>
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
</div>
`;
}).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
loadFeed,
enableTrackerAlerts,
disableTrackerAlerts,
addBluetoothWatchlist,
removeBluetoothWatchlist,
isWatchlisted,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof AlertCenter !== 'undefined') {
AlertCenter.init();
}
});
+136
View File
@@ -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 = '<div class="settings-feed-empty">No active recordings</div>';
return;
}
container.innerHTML = active.map(session => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(session.mode)}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
</div>
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
</div>
`;
}).join('');
}
function renderRecordings() {
const container = document.getElementById('recordingList');
if (!container) return;
if (!recordings.length) {
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
return;
}
container.innerHTML = recordings.map(rec => {
return `
<div class="settings-feed-item">
<div class="settings-feed-title">
<span>${escapeHtml(rec.mode)}${rec.label ? `${escapeHtml(rec.label)}` : ''}</span>
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
</div>
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? `${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
<div class="settings-feed-meta">Events: ${rec.event_count || 0} ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
</div>
`;
}).join('');
}
function download(sessionId) {
window.open(`/recordings/${sessionId}/download`, '_blank');
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
return {
init,
refresh,
start,
stop,
stopById,
download,
};
})();
document.addEventListener('DOMContentLoaded', () => {
if (typeof RecordingUI !== 'undefined') {
RecordingUI.init();
}
});
+8
View File
@@ -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();
}
}
}
+75 -32
View File
@@ -366,7 +366,10 @@ const BluetoothMode = (function() {
// Badges
const badgesEl = document.getElementById('btDetailBadges');
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
if (device.seen_before) {
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
}
// 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('<span class="bt-history-badge">SEEN BEFORE</span>');
// Add agent name if not Local
if (agentName !== 'Local') {
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
@@ -1358,9 +1400,10 @@ const BluetoothMode = (function() {
setBaseline,
clearBaseline,
exportData,
selectDevice,
clearSelection,
copyAddress,
selectDevice,
clearSelection,
copyAddress,
toggleWatchlist,
// Agent handling
handleAgentChange,
+504
View File
@@ -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 ? `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Slot</span>
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
</div>` : '';
const callEl = document.getElementById('dmrCurrentCall');
if (callEl) {
callEl.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Talkgroup</span>
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Source ID</span>
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
</div>${slotInfo}
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-muted);">Time</span>
<span style="color: var(--text-primary);">${msg.timestamp}</span>
</div>
`;
}
// 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 = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
return;
}
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
<tr>
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
<td style="padding: 3px 6px;">${call.protocol}</td>
</tr>
`).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;
+546 -2
View File
@@ -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 =>
`<span style="background: rgba(0,200,255,0.15); color: var(--accent-cyan); padding: 1px 6px; border-radius: 3px; font-size: 9px;">${tag}</span>`
).join('');
}
if (altsEl) {
if (result.alternatives && result.alternatives.length > 0) {
altsEl.innerHTML = '<strong>Also:</strong> ' + result.alternatives.map(a =>
`${a.label} <span style="color: ${a.confidence === 'HIGH' ? '#00e676' : a.confidence === 'MEDIUM' ? '#ff9800' : '#9e9e9e'}">(${a.confidence})</span>`
).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;
+601
View File
@@ -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 `
<div class="sstv-general-idle-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="12" cy="12" r="3"/>
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
</svg>
<h4>SSTV Decoder</h4>
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
</div>
`;
}
/**
* 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 = `
<div class="sstv-general-signal-monitor">
<div class="sstv-general-signal-monitor-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
<circle cx="12" cy="18" r="2"/>
<path d="M12 16V12"/>
</svg>
Signal Monitor
</div>
<div class="sstv-general-signal-level-row">
<span class="sstv-general-signal-level-label">LEVEL</span>
<div class="sstv-general-signal-bar-track">
<div class="sstv-general-signal-bar-fill" style="width: 0%"></div>
</div>
<span class="sstv-general-signal-level-value">0</span>
</div>
<div class="sstv-general-signal-status-text">No signal</div>
<div class="sstv-general-signal-vis-state">VIS: idle</div>
</div>`;
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 = `
<div class="sstv-general-decode-container">
<div class="sstv-general-canvas-container">
<img id="sstvGeneralDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-general-decode-info">
<div class="sstv-general-mode-label"></div>
<div class="sstv-general-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-general-status-message"></div>
</div>
</div>
`;
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 = `
<div class="sstv-general-gallery-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>No images decoded yet</p>
</div>
`;
return;
}
gallery.innerHTML = images.map(img => `
<div class="sstv-general-image-card">
<div class="sstv-general-image-card-inner" onclick="SSTVGeneral.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-general-image-preview" loading="lazy">
</div>
<div class="sstv-general-image-info">
<div class="sstv-general-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-general-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
<div class="sstv-general-image-actions">
<button onclick="event.stopPropagation(); SSTVGeneral.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button onclick="event.stopPropagation(); SSTVGeneral.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
`).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 = `
<div class="sstv-general-modal-toolbar">
<button class="sstv-general-modal-btn" id="sstvGeneralModalDownload" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
<button class="sstv-general-modal-btn delete" id="sstvGeneralModalDelete" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
Delete
</button>
</div>
<button class="sstv-general-modal-close" onclick="SSTVGeneral.closeImage()">&times;</button>
<img src="" alt="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
};
})();
+222 -20
View File
@@ -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 = `
<div class="sstv-signal-monitor">
<div class="sstv-signal-monitor-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 12L5 12M5 12C5 12 6 3 12 3C18 3 19 12 19 12M19 12L22 12"/>
<circle cx="12" cy="18" r="2"/>
<path d="M12 16V12"/>
</svg>
Signal Monitor
</div>
<div class="sstv-signal-level-row">
<span class="sstv-signal-level-label">LEVEL</span>
<div class="sstv-signal-bar-track">
<div class="sstv-signal-bar-fill" style="width: 0%"></div>
</div>
<span class="sstv-signal-level-value">0</span>
</div>
<div class="sstv-signal-status-text">No signal</div>
<div class="sstv-signal-vis-state">VIS: idle</div>
</div>`;
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 = `
<div class="sstv-canvas-container">
<canvas id="sstvCanvas" width="320" height="256"></canvas>
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label">${data.mode || 'Detecting mode...'}</div>
<div class="sstv-progress-bar">
<div class="progress" style="width: ${data.progress || 0}%"></div>
let container = liveContent.querySelector('.sstv-decode-container');
if (!container) {
liveContent.innerHTML = `
<div class="sstv-decode-container">
<div class="sstv-canvas-container">
<img id="sstvDecodeImg" width="320" height="256" alt="Decoding..." style="display:block;background:#000;">
</div>
<div class="sstv-decode-info">
<div class="sstv-mode-label"></div>
<div class="sstv-progress-bar">
<div class="progress" style="width: 0%"></div>
</div>
<div class="sstv-status-message"></div>
</div>
</div>
<div class="sstv-status-message">${data.message || 'Decoding...'}</div>
</div>
`;
`;
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 => `
<div class="sstv-image-card" onclick="SSTV.showImage('${escapeHtml(img.url)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
<div class="sstv-image-card">
<div class="sstv-image-card-inner" onclick="SSTV.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')">
<img src="${escapeHtml(img.url)}" alt="SSTV Image" class="sstv-image-preview" loading="lazy">
</div>
<div class="sstv-image-info">
<div class="sstv-image-mode">${escapeHtml(img.mode || 'Unknown')}</div>
<div class="sstv-image-timestamp">${formatTimestamp(img.timestamp)}</div>
</div>
<div class="sstv-image-actions">
<button onclick="event.stopPropagation(); SSTV.downloadImage('${escapeHtml(img.url)}', '${escapeHtml(img.filename)}')" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button onclick="event.stopPropagation(); SSTV.deleteImage('${escapeHtml(img.filename)}')" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
`).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 = `
<div class="sstv-modal-toolbar">
<button class="sstv-modal-btn" id="sstvModalDownload" title="Download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
<button class="sstv-modal-btn delete" id="sstvModalDelete" title="Delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
Delete
</button>
</div>
<button class="sstv-modal-close" onclick="SSTV.closeImage()">&times;</button>
<img src="" alt="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,
+581
View File
@@ -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: '&copy; OpenStreetMap contributors &copy; 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(`
<div style="font-size: 12px; min-width: 200px;">
<strong>${escapeHtmlWebsdr(rx.name)}</strong><br>
${rx.location ? `<span style="color: #aaa;">${escapeHtmlWebsdr(rx.location)}</span><br>` : ''}
<span style="color: #888;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</span><br>
<span style="color: #888;">Users: ${rx.users}/${rx.users_max}</span><br>
<button onclick="selectReceiver(${idx})" style="margin-top: 6px; padding: 4px 12px; background: #00d4ff; color: #000; border: none; border-radius: 3px; cursor: pointer; font-weight: bold;">Listen</button>
</div>
`);
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 = '<div style="color: var(--text-muted); text-align: center; padding: 20px;">No receivers found</div>';
return;
}
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
onclick="selectReceiver(${idx})">
<div style="display: flex; justify-content: space-between; align-items: center;">
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
</div>
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
${rx.location ? escapeHtmlWebsdr(rx.location) + ' · ' : ''}${escapeHtmlWebsdr(rx.antenna || '')}
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
</div>
</div>
`).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 = '<div style="color: var(--text-muted); text-align: center; padding: 10px;">No stations available</div>';
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 `
<div style="padding: 6px 4px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; display: flex; justify-content: space-between; align-items: center;"
onclick="tuneToSpyStation('${escapeHtmlWebsdr(s.id)}', ${freqKhz})"
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'">
<div>
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtmlWebsdr(s.name)}</span>
<span style="color: var(--text-muted); font-size: 9px; margin-left: 4px;">${escapeHtmlWebsdr(s.nickname || '')}</span>
</div>
<span style="color: var(--accent-orange); font-family: var(--font-mono); font-size: 10px;">${freqKhz} kHz</span>
</div>
`;
}).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;
+69 -33
View File
@@ -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();
@@ -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();
}
});
}
})();
+768 -68
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
<!-- DMR / DIGITAL VOICE MODE -->
<div id="dmrMode" class="mode-content">
<div class="section">
<h3>Digital Voice</h3>
<!-- Dependency Warning -->
<div id="dmrToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
<strong>Missing:</strong><br>
<span id="dmrToolsWarningText"></span>
</p>
</div>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="dmrFrequency" value="462.5625" step="0.0001" style="width: 100%;">
</div>
<div class="form-group">
<label>Protocol</label>
<select id="dmrProtocol">
<option value="auto" selected>Auto Detect</option>
<option value="dmr">DMR</option>
<option value="p25">P25</option>
<option value="nxdn">NXDN</option>
<option value="dstar">D-STAR</option>
<option value="provoice">ProVoice</option>
</select>
</div>
<div class="form-group">
<label>Gain</label>
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
</div>
</div>
<!-- Actions -->
<button class="run-btn" id="startDmrBtn" onclick="startDmr()" style="margin-top: 12px;">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none; margin-top: 12px;">
Stop Decoder
</button>
<!-- Current Call -->
<div class="section" style="margin-top: 12px;">
<h3>Current Call</h3>
<div id="dmrCurrentCall" style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
<div style="color: var(--text-muted); text-align: center;">No active call</div>
</div>
</div>
<!-- Status -->
<div class="section" style="margin-top: 12px;">
<h3>Status</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Protocol</span>
<span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Calls</span>
<span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
</div>
</div>
</div>
</div>
@@ -46,6 +46,52 @@
</div>
</div>
<!-- Signal Identification -->
<div class="section">
<h3>Signal Identification</h3>
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
<input type="text" id="signalGuessFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
<button class="preset-btn" onclick="manualSignalGuess()" style="background: var(--accent-cyan); color: #000; padding: 6px 10px; font-weight: 600;">ID</button>
</div>
<div id="signalGuessPanel" style="display: none; background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span id="signalGuessLabel" style="font-weight: bold; color: var(--text-primary);"></span>
<span id="signalGuessBadge" style="padding: 2px 8px; border-radius: 3px; font-size: 9px; font-weight: bold;"></span>
</div>
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
</div>
</div>
<!-- Waterfall Controls -->
<div class="section">
<h3>Waterfall</h3>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">Start (MHz)</label>
<input type="number" id="waterfallStartFreq" value="88" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">End (MHz)</label>
<input type="number" id="waterfallEndFreq" value="108" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<div class="form-group" style="margin-bottom: 6px;">
<label style="font-size: 10px;">Bin Size</label>
<select id="waterfallBinSize" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
<option value="5000">5 kHz</option>
<option value="10000" selected>10 kHz</option>
<option value="25000">25 kHz</option>
<option value="100000">100 kHz</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 10px;">Gain</label>
<input type="number" id="waterfallGain" value="40" min="0" max="50" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
</div>
<button class="run-btn" id="startWaterfallBtn" onclick="startWaterfall()" style="width: 100%; padding: 8px;">Start Waterfall</button>
<button class="stop-btn" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none; width: 100%; padding: 8px; margin-top: 4px;">Stop Waterfall</button>
</div>
<!-- Antenna Guide -->
<div class="section">
<h3>Antenna Guide</h3>
@@ -118,4 +164,5 @@
</div>
</div>
</div>
@@ -0,0 +1,86 @@
<!-- SSTV GENERAL MODE -->
<div id="sstvGeneralMode" class="mode-content">
<div class="section">
<h3>SSTV Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode Slow-Scan Television images on common amateur radio HF/VHF/UHF frequencies.
Select a predefined frequency or enter a custom one.
</p>
<p class="info-text" style="font-size: 10px; color: var(--accent-yellow); margin-bottom: 8px;">
Note: HF frequencies (below 30 MHz) require an upconverter with RTL-SDR.
</p>
</div>
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Preset Frequency</label>
<select id="sstvGeneralPresetFreq" onchange="SSTVGeneral.selectPreset(this.value)" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
<option value="">-- Select frequency --</option>
<optgroup label="80 m (HF)">
<option value="3.845|lsb">3.845 MHz LSB - US calling</option>
<option value="3.730|lsb">3.730 MHz LSB - Europe primary</option>
</optgroup>
<optgroup label="40 m (HF)">
<option value="7.171|lsb">7.171 MHz LSB - International</option>
<option value="7.040|lsb">7.040 MHz LSB - Alt US/EU</option>
</optgroup>
<optgroup label="30 m (HF)">
<option value="10.132|usb">10.132 MHz USB - Narrowband</option>
</optgroup>
<optgroup label="20 m (HF)">
<option value="14.230|usb">14.230 MHz USB - Most popular</option>
<option value="14.233|usb">14.233 MHz USB - Digital SSTV</option>
<option value="14.240|usb">14.240 MHz USB - Europe alt</option>
</optgroup>
<optgroup label="15 m (HF)">
<option value="21.340|usb">21.340 MHz USB - International</option>
</optgroup>
<optgroup label="10 m (HF)">
<option value="28.680|usb">28.680 MHz USB - International</option>
</optgroup>
<optgroup label="6 m (VHF)">
<option value="50.950|usb">50.950 MHz USB - SSTV calling</option>
</optgroup>
<optgroup label="2 m (VHF)">
<option value="145.625|fm">145.625 MHz FM - Simplex</option>
</optgroup>
<optgroup label="70 cm (UHF)">
<option value="433.775|fm">433.775 MHz FM - Simplex</option>
</optgroup>
</select>
</div>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="sstvGeneralFrequency" value="14.230" step="0.001" min="1" max="500">
</div>
<div class="form-group">
<label>Modulation</label>
<select id="sstvGeneralModulation" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
<option value="usb">USB (Upper Sideband)</option>
<option value="lsb">LSB (Lower Sideband)</option>
<option value="fm">FM (Frequency Modulation)</option>
</select>
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://www.sigidwiki.com/wiki/Slow-Scan_Television_(SSTV)" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
SigID Wiki - SSTV
</a>
</div>
</div>
<div class="section">
<h3>About Terrestrial SSTV</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim);">
Amateur radio operators transmit SSTV images on HF bands worldwide.
The most popular frequency is 14.230 MHz USB on the 20m band.
</p>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 8px;">
Common modes: PD120, PD180, Martin1, Scottie1, Robot36
</p>
</div>
</div>
+70
View File
@@ -0,0 +1,70 @@
<!-- WEBSDR MODE -->
<div id="websdrMode" class="mode-content">
<div class="section">
<h3>WebSDR</h3>
<div class="form-group">
<label>Frequency (kHz)</label>
<input type="number" id="websdrFrequency" value="6500" step="1" style="width: 100%;">
</div>
<div class="form-group">
<label>Mode</label>
<select id="websdrMode_select">
<option value="usb">USB</option>
<option value="lsb">LSB</option>
<option value="am" selected>AM</option>
<option value="cw">CW</option>
</select>
</div>
<button class="run-btn" onclick="searchReceivers()" style="width: 100%; margin-top: 8px;">
Find Receivers
</button>
<button class="preset-btn" onclick="searchReceivers(true)" style="width: 100%; margin-top: 4px; font-size: 10px;">
Refresh List
</button>
</div>
<!-- Audio Player -->
<div class="section" style="margin-top: 12px;">
<h3>Audio Player</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="kiwiStatus" style="font-size: 11px; color: var(--text-muted);">DISCONNECTED</span>
</div>
<div id="kiwiReceiverName" style="font-size: 11px; color: var(--accent-cyan); margin-bottom: 6px; display: none; word-break: break-word;"></div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
<span id="kiwiFreqDisplay" style="font-size: 14px; font-family: var(--font-mono); color: var(--text-primary);">--- kHz</span>
</div>
<!-- S-meter -->
<div style="margin-bottom: 8px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">S-Meter</span>
<div style="height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; margin-top: 3px; overflow: hidden;">
<div id="kiwiSmeterBar" style="height: 100%; width: 0%; background: linear-gradient(to right, var(--accent-green), var(--accent-orange), var(--accent-red)); transition: width 0.2s; border-radius: 4px;"></div>
</div>
<div style="text-align: right; font-size: 9px; color: var(--text-muted); margin-top: 2px;">
<span id="kiwiSmeterValue">S0</span>
</div>
</div>
<!-- Volume -->
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
<input type="range" id="kiwiVolume" min="0" max="100" value="80" style="flex: 1;" oninput="setKiwiVolume(this.value)">
</div>
<button id="kiwiDisconnectBtn" class="stop-btn" onclick="disconnectFromReceiver()" style="width: 100%; display: none;">
Disconnect
</button>
</div>
</div>
<!-- Spy Station Presets -->
<div class="section" style="margin-top: 12px;">
<h3>Spy Station Presets</h3>
<div id="websdrSpyPresets" style="max-height: 250px; overflow-y: auto; font-size: 11px;">
<div style="color: var(--text-muted); text-align: center; padding: 10px;">Loading...</div>
</div>
</div>
</div>
+16 -1
View File
@@ -69,7 +69,22 @@
</select>
</div>
<div class="form-group">
<label>Channel (empty = hop)</label>
<label>Channel Preset</label>
<select id="wifiChannelPreset">
<option value="">Auto hop (all)</option>
<option value="2.4-common">2.4 GHz Common (1,6,11)</option>
<option value="2.4-all">2.4 GHz All (1-13)</option>
<option value="5-low">5 GHz Low (36-48)</option>
<option value="5-mid">5 GHz Mid/DFS (52-64)</option>
<option value="5-high">5 GHz High (149-165)</option>
</select>
</div>
<div class="form-group">
<label>Channel List (overrides preset)</label>
<input type="text" id="wifiChannelList" placeholder="e.g., 1,6,11 or 36,40,44,48">
</div>
<div class="form-group">
<label>Channel (single)</label>
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
</div>
</div>
+6
View File
@@ -71,6 +71,8 @@
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mode_item('dmr', 'Digital Voice', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
</div>
</div>
@@ -117,6 +119,7 @@
{% endif %}
{{ mode_item('sstv', 'ISS SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
</div>
</div>
@@ -184,9 +187,12 @@
{% endif %}
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{{ mobile_item('dmr', 'DMR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}
+79
View File
@@ -15,6 +15,8 @@
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
<button class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')">Alerts</button>
<button class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')">Recording</button>
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
</div>
@@ -280,6 +282,83 @@
</div>
</div>
<!-- Alerts Section -->
<div id="settings-alerts" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
<div id="alertsFeedList" class="settings-feed">
<div class="settings-feed-empty">No alerts yet</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Quick Rules</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button class="check-assets-btn" onclick="AlertCenter.enableTrackerAlerts()">Enable Tracker Alerts</button>
<button class="check-assets-btn" onclick="AlertCenter.disableTrackerAlerts()">Disable Tracker Alerts</button>
</div>
<div class="settings-info" style="margin-top: 10px;">
Use Bluetooth device details to add specific device watchlist alerts.
</div>
</div>
</div>
<!-- Recording Section -->
<div id="settings-recording" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Start Recording</div>
<div class="settings-row" style="border-bottom: none; padding-top: 0;">
<div class="settings-label">
<span class="settings-label-text">Mode</span>
<span class="settings-label-desc">Record live events for a mode</span>
</div>
<select id="recordingModeSelect" class="settings-select" style="width: 200px;">
<option value="pager">Pager</option>
<option value="sensor">433 Sensors</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="dsc">DSC</option>
<option value="acars">ACARS</option>
<option value="aprs">APRS</option>
<option value="rtlamr">RTLAMR</option>
<option value="dmr">DMR</option>
<option value="tscm">TSCM</option>
<option value="sstv">SSTV</option>
<option value="sstv_general">SSTV General</option>
<option value="listening_scanner">Listening Post</option>
<option value="waterfall">Waterfall</option>
</select>
</div>
<div class="settings-row" style="border-bottom: none;">
<div class="settings-label">
<span class="settings-label-text">Label</span>
<span class="settings-label-desc">Optional note for the session</span>
</div>
<input type="text" id="recordingLabelInput" class="settings-input" placeholder="Morning sweep" style="width: 200px;">
</div>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button class="check-assets-btn" onclick="RecordingUI.start()">Start</button>
<button class="check-assets-btn" onclick="RecordingUI.stop()">Stop</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Active Sessions</div>
<div id="recordingActiveList" class="settings-feed">
<div class="settings-feed-empty">No active recordings</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Recent Recordings</div>
<div id="recordingList" class="settings-feed">
<div class="settings-feed-empty">No recordings yet</div>
</div>
</div>
</div>
<!-- About Section -->
<div id="settings-about" class="settings-section">
<div class="settings-group">
+4 -2
View File
@@ -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
+175
View File
@@ -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')
+321
View File
@@ -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('<h', s) for s in pcm_samples)
return header + pcm
def test_parse_snd_frame_smeter():
"""Should extract S-meter value from SND frame."""
client = KiwiSDRClient(host='test', port=8073)
audio_data = []
def on_audio(pcm, smeter):
audio_data.append((pcm, smeter))
client._on_audio = on_audio
frame = _make_snd_frame(-730, [100, -100, 200]) # -73.0 dBm = S9
client._parse_snd_frame(frame)
assert client.last_smeter == -730
assert len(audio_data) == 1
assert audio_data[0][1] == -730
def test_parse_snd_frame_pcm_data():
"""Should forward PCM data from SND frame."""
client = KiwiSDRClient(host='test', port=8073)
received_pcm = []
def on_audio(pcm, smeter):
received_pcm.append(pcm)
client._on_audio = on_audio
samples = [1000, -2000, 3000, -4000]
frame = _make_snd_frame(0, samples)
client._parse_snd_frame(frame)
assert len(received_pcm) == 1
# PCM data is 8 bytes (4 samples * 2 bytes each)
assert len(received_pcm[0]) == len(samples) * 2
def test_parse_snd_frame_short():
"""Should ignore frames shorter than header size."""
client = KiwiSDRClient(host='test', port=8073)
client._on_audio = MagicMock()
client._parse_snd_frame(b'SND\x00') # Too short
client._on_audio.assert_not_called()
def test_parse_snd_frame_wrong_magic():
"""Should ignore frames with wrong header magic."""
client = KiwiSDRClient(host='test', port=8073)
client._on_audio = MagicMock()
frame = b'XXX' + b'\x00' * 7 + b'\x00' * 10 # Wrong magic
client._parse_snd_frame(frame)
client._on_audio.assert_not_called()
# ============================================
# Client state tests
# ============================================
def test_client_initial_state():
"""New client should start disconnected."""
client = KiwiSDRClient(host='kiwi.local', port=8073)
assert client.connected is False
assert client.host == 'kiwi.local'
assert client.port == 8073
assert client.frequency_khz == 0
assert client.mode == 'am'
def test_client_tune_when_disconnected():
"""Tune should fail when not connected."""
client = KiwiSDRClient(host='test', port=8073)
assert client.tune(7000, 'usb') is False
def test_client_disconnect_when_not_connected():
"""Disconnect should not raise when already disconnected."""
client = KiwiSDRClient(host='test', port=8073)
client.disconnect() # Should not raise
assert client.connected is False
@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', False)
def test_client_connect_no_websocket():
"""Connect should fail if websocket-client not available."""
client = KiwiSDRClient(host='test', port=8073)
assert client.connect(7000, 'am') is False
# ============================================
# Constants tests
# ============================================
def test_sample_rate():
"""Sample rate should be 12 kHz."""
assert KIWI_SAMPLE_RATE == 12000
def test_snd_header_size():
"""SND header should be 10 bytes."""
assert KIWI_SND_HEADER_SIZE == 10
def test_valid_modes():
"""All expected modes should be in VALID_MODES."""
assert 'am' in VALID_MODES
assert 'usb' in VALID_MODES
assert 'lsb' in VALID_MODES
assert 'cw' in VALID_MODES
def test_mode_filters_defined():
"""All valid modes should have filter definitions."""
for mode in VALID_MODES:
assert mode in MODE_FILTERS
low, high = MODE_FILTERS[mode]
assert low < high
def test_mode_filter_am_symmetric():
"""AM filter should be symmetric."""
low, high = MODE_FILTERS['am']
assert low == -high
def test_mode_filter_usb_positive():
"""USB filter should be in positive passband."""
low, high = MODE_FILTERS['usb']
assert low > 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()
+100
View File
@@ -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)
+798
View File
@@ -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
+80
View File
@@ -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
+170
View File
@@ -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
+443
View File
@@ -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
+13 -10
View File
@@ -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,
+132 -70
View File
@@ -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))
+29
View File
@@ -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
+288
View File
@@ -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
+222
View File
@@ -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
-769
View File
@@ -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
+33
View File
@@ -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',
]
+92
View File
@@ -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
+232
View File
@@ -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
+460
View File
@@ -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')
+250
View File
@@ -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)
+905
View File
@@ -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
+324
View File
@@ -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
+74 -22
View File
@@ -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
+161 -84
View File
@@ -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)
+439 -301
View File
@@ -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."""
+37 -19
View File
@@ -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'),
})
+59 -49
View File
@@ -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
+21 -8
View File
@@ -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
# =============================================================================
+11 -10
View File
@@ -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
}
+119 -19
View File
@@ -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 <iface> up',
falling back to 'ifconfig <iface> 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)