mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
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:
@@ -35,6 +35,7 @@ htmlcov/
|
||||
|
||||
# Local Postgres data
|
||||
pgdata/
|
||||
pgdata.bak/
|
||||
|
||||
# Captured files (don't include in image)
|
||||
*.cap
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Uncomment and set to use external storage for ADS-B history
|
||||
# PGDATA_PATH=/mnt/external/intercept/pgdata
|
||||
@@ -54,3 +54,8 @@ intercept_agent_*.cfg
|
||||
# Temporary files
|
||||
/tmp/
|
||||
*.tmp
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -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
@@ -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/*
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
============================================ */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
loadFeed,
|
||||
enableTrackerAlerts,
|
||||
disableTrackerAlerts,
|
||||
addBluetoothWatchlist,
|
||||
removeBluetoothWatchlist,
|
||||
isWatchlisted,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof AlertCenter !== 'undefined') {
|
||||
AlertCenter.init();
|
||||
}
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
refresh,
|
||||
start,
|
||||
stop,
|
||||
stopById,
|
||||
download,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof RecordingUI !== 'undefined') {
|
||||
RecordingUI.init();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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()">×</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
@@ -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()">×</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,
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* Intercept - WebSDR Mode
|
||||
* HF/Shortwave KiwiSDR Network Integration with In-App Audio
|
||||
*/
|
||||
|
||||
// ============== STATE ==============
|
||||
let websdrMap = null;
|
||||
let websdrMarkers = [];
|
||||
let websdrReceivers = [];
|
||||
let websdrInitialized = false;
|
||||
let websdrSpyStationsLoaded = false;
|
||||
|
||||
// KiwiSDR audio state
|
||||
let kiwiWebSocket = null;
|
||||
let kiwiAudioContext = null;
|
||||
let kiwiScriptProcessor = null;
|
||||
let kiwiGainNode = null;
|
||||
let kiwiAudioBuffer = [];
|
||||
let kiwiConnected = false;
|
||||
let kiwiCurrentFreq = 0;
|
||||
let kiwiCurrentMode = 'am';
|
||||
let kiwiSmeter = 0;
|
||||
let kiwiSmeterInterval = null;
|
||||
let kiwiReceiverName = '';
|
||||
|
||||
const KIWI_SAMPLE_RATE = 12000;
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
function initWebSDR() {
|
||||
if (websdrInitialized) {
|
||||
if (websdrMap) {
|
||||
setTimeout(() => websdrMap.invalidateSize(), 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mapEl = document.getElementById('websdrMap');
|
||||
if (!mapEl || typeof L === 'undefined') return;
|
||||
|
||||
// Calculate minimum zoom so tiles fill the container vertically
|
||||
const mapHeight = mapEl.clientHeight || 500;
|
||||
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
||||
|
||||
websdrMap = L.map('websdrMap', {
|
||||
center: [20, 0],
|
||||
zoom: Math.max(minZoom, 2),
|
||||
minZoom: Math.max(minZoom, 2),
|
||||
zoomControl: true,
|
||||
maxBounds: [[-85, -360], [85, 360]],
|
||||
maxBoundsViscosity: 1.0,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}).addTo(websdrMap);
|
||||
|
||||
// Match background to tile ocean color so any remaining edge is seamless
|
||||
mapEl.style.background = '#1a1d29';
|
||||
|
||||
websdrInitialized = true;
|
||||
|
||||
if (!websdrSpyStationsLoaded) {
|
||||
loadSpyStationPresets();
|
||||
}
|
||||
|
||||
[100, 300, 600, 1000].forEach(delay => {
|
||||
setTimeout(() => {
|
||||
if (websdrMap) websdrMap.invalidateSize();
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
// ============== RECEIVER SEARCH ==============
|
||||
|
||||
function searchReceivers(refresh) {
|
||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 0);
|
||||
|
||||
let url = '/websdr/receivers?available=true';
|
||||
if (freqKhz > 0) url += `&freq_khz=${freqKhz}`;
|
||||
if (refresh) url += '&refresh=true';
|
||||
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
websdrReceivers = data.receivers || [];
|
||||
renderReceiverList(websdrReceivers);
|
||||
plotReceiversOnMap(websdrReceivers);
|
||||
|
||||
const countEl = document.getElementById('websdrReceiverCount');
|
||||
if (countEl) countEl.textContent = `${websdrReceivers.length} found`;
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WEBSDR] Search error:', err));
|
||||
}
|
||||
|
||||
// ============== MAP ==============
|
||||
|
||||
function plotReceiversOnMap(receivers) {
|
||||
if (!websdrMap) return;
|
||||
|
||||
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
||||
websdrMarkers = [];
|
||||
|
||||
receivers.forEach((rx, idx) => {
|
||||
if (rx.lat == null || rx.lon == null) return;
|
||||
|
||||
const marker = L.circleMarker([rx.lat, rx.lon], {
|
||||
radius: 6,
|
||||
fillColor: rx.available ? '#00d4ff' : '#666',
|
||||
color: rx.available ? '#00d4ff' : '#666',
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.6,
|
||||
});
|
||||
|
||||
marker.bindPopup(`
|
||||
<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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user