Compare commits

..

4 Commits

Author SHA1 Message Date
Smittix 243a0f0e7f chore: Bump version to v2.16.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:32:15 +00:00
Smittix 7c3ec9e920 chore: commit all changes and remove large IQ captures from tracking
Add .gitignore entry for data/subghz/captures/ to prevent large
IQ recording files from being committed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:30:37 +00:00
Smittix 4639146f05 fix: Remove incomplete MLAT feature causing ImportError on startup
The partially-added MLAT support was out of sync between config and
routes, causing an ImportError when importing adsb_bp. Remove all MLAT
additions from config, template UI/JS, and docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:55:21 +00:00
Smittix a354fee792 fix: Resolve listening post audio stuttering introduced in v2.15.0
Throttle audio waterfall rendering (50ms→200ms), eliminate per-frame
Array.from() allocation, drain stale pipe buffer before streaming,
increase chunk size to 8192, and remove debug logging from animation
hot paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:24:51 +00:00
56 changed files with 11343 additions and 1484 deletions
+3
View File
@@ -58,6 +58,9 @@ intercept_agent_*.cfg
# Weather satellite runtime data (decoded images, samples, SatDump output) # Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/ data/weather_sat/
# SDR capture files (large IQ recordings)
data/subghz/captures/
# Env files # Env files
.env .env
.env.* .env.*
+54 -40
View File
@@ -182,6 +182,10 @@ dmr_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock() tscm_lock = threading.Lock()
# SubGHz Transceiver (HackRF)
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
subghz_lock = threading.Lock()
# Deauth Attack Detection # Deauth Attack Detection
deauth_detector = None deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -236,12 +240,12 @@ cleanup_manager.register(deauth_alerts)
# SDR DEVICE REGISTRY # SDR DEVICE REGISTRY
# ============================================ # ============================================
# Tracks which mode is using which SDR device to prevent conflicts # Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: {sdr_type: mode_name} # Key: device_index (int), Value: mode_name (str)
sdr_device_registry: dict[int, dict[str, str]] = {} sdr_device_registry: dict[int, str] = {}
sdr_device_registry_lock = threading.Lock() sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None: def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
"""Claim an SDR device for a mode. """Claim an SDR device for a mode.
Checks the in-app registry first, then probes the USB device to Checks the in-app registry first, then probes the USB device to
@@ -251,61 +255,46 @@ def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr'
Args: Args:
device_index: The SDR device index to claim device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr') mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf')
Returns: Returns:
Error message if device is in use, None if successfully claimed Error message if device is in use, None if successfully claimed
""" """
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
with sdr_device_registry_lock: with sdr_device_registry_lock:
device_entry = sdr_device_registry.get(device_index, {}) if device_index in sdr_device_registry:
if sdr_type_key in device_entry: in_use_by = sdr_device_registry[device_index]
in_use_by = device_entry[sdr_type_key]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
# Probe the USB device to catch external processes holding the handle # Probe the USB device to catch external processes holding the handle
# Only relevant for RTL-SDR devices try:
if sdr_type_key == 'rtlsdr': from utils.sdr.detection import probe_rtlsdr_device
try: usb_error = probe_rtlsdr_device(device_index)
from utils.sdr.detection import probe_rtlsdr_device if usb_error:
usb_error = probe_rtlsdr_device(device_index) return usb_error
if usb_error: except Exception:
return usb_error pass # If probe fails, let the caller proceed normally
except Exception:
pass # If probe fails, let the caller proceed normally
if device_index not in sdr_device_registry: sdr_device_registry[device_index] = mode_name
sdr_device_registry[device_index] = {}
sdr_device_registry[device_index][sdr_type_key] = mode_name
return None return None
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None: def release_sdr_device(device_index: int) -> None:
"""Release an SDR device from the registry. """Release an SDR device from the registry.
Args: Args:
device_index: The SDR device index to release device_index: The SDR device index to release
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf')
""" """
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
with sdr_device_registry_lock: with sdr_device_registry_lock:
entry = sdr_device_registry.get(device_index) sdr_device_registry.pop(device_index, None)
if not entry:
return
entry.pop(sdr_type_key, None)
if not entry:
sdr_device_registry.pop(device_index, None)
def get_sdr_device_status() -> dict[int, dict[str, str]]: def get_sdr_device_status() -> dict[int, str]:
"""Get current SDR device allocations. """Get current SDR device allocations.
Returns: Returns:
Dictionary mapping device indices to {sdr_type: mode_name} Dictionary mapping device indices to mode names
""" """
with sdr_device_registry_lock: with sdr_device_registry_lock:
return {idx: dict(modes) for idx, modes in sdr_device_registry.items()} return dict(sdr_device_registry)
# ============================================ # ============================================
@@ -411,11 +400,8 @@ def get_devices_status() -> Response:
result = [] result = []
for device in devices: for device in devices:
d = device.to_dict() d = device.to_dict()
sdr_type_key = device.sdr_type.value if hasattr(device.sdr_type, 'value') else str(device.sdr_type) d['in_use'] = device.index in registry
sdr_type_key = str(sdr_type_key).lower() d['used_by'] = registry.get(device.index)
device_registry = registry.get(device.index, {})
d['in_use'] = sdr_type_key in device_registry
d['used_by'] = device_registry.get(sdr_type_key)
result.append(d) result.append(d)
return jsonify(result) return jsonify(result)
@@ -661,6 +647,25 @@ def export_bluetooth() -> Response:
}) })
def _get_subghz_active() -> bool:
"""Check if SubGHz manager has an active process."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode != 'idle'
except Exception:
return False
def _get_dmr_active() -> bool:
"""Check if Digital Voice decoder has an active process."""
try:
from routes import dmr as dmr_module
proc = dmr_module.dmr_dsd_process
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
except Exception:
return False
@app.route('/health') @app.route('/health')
def health_check() -> Response: def health_check() -> Response:
"""Health check endpoint for monitoring.""" """Health check endpoint for monitoring."""
@@ -679,7 +684,8 @@ def health_check() -> Response:
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), '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), '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), '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), 'dmr': _get_dmr_active(),
'subghz': _get_subghz_active(),
}, },
'data': { 'data': {
'aircraft_count': len(adsb_aircraft), 'aircraft_count': len(adsb_aircraft),
@@ -710,7 +716,8 @@ def kill_all() -> Response:
'airodump-ng', 'aireplay-ng', 'airmon-ng', 'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump', 'dsd', 'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg' 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
] ]
for proc in processes_to_kill: for proc in processes_to_kill:
@@ -779,6 +786,13 @@ def kill_all() -> Response:
except Exception: except Exception:
pass pass
# Reset SubGHz state
try:
from utils.subghz import get_subghz_manager
get_subghz_manager().stop_all()
except Exception:
pass
# Clear SDR device registry # Clear SDR device registry
with sdr_device_registry_lock: with sdr_device_registry_lock:
sdr_device_registry.clear() sdr_device_registry.clear()
+24 -1
View File
@@ -7,10 +7,23 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.15.0" VERSION = "2.16.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"version": "2.16.0",
"date": "February 2026",
"highlights": [
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
"Weather satellite auto-scheduler with polar plot and ground track map",
"SatDump support for local (non-Docker) installs via setup.sh",
"DMR audio output, frequency persistence, and bookmarks",
"Shared waterfall UI across SDR modes",
"Listening post audio stuttering fix and SDR race condition fixes",
"Multi-arch Docker build support (amd64 + arm64)",
]
},
{ {
"version": "2.15.0", "version": "2.15.0",
"date": "February 2026", "date": "February 2026",
@@ -227,6 +240,16 @@ WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30) WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30) WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
# SubGHz transceiver settings (HackRF)
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92)
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000)
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int('SUBGHZ_LNA_GAIN', 32)
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int('SUBGHZ_VGA_GAIN', 20)
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int('SUBGHZ_TX_GAIN', 20)
SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
# Update checking # Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept') GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True) UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
+2
View File
@@ -144,6 +144,8 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
3. View aircraft history and timelines 3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded) 4. Stop tracking when desired (session history is recorded)
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
## Satellite Mode ## Satellite Mode
1. **Set Location** - Choose location source: 1. **Set Location** - Choose location source:
+13 -38
View File
@@ -679,7 +679,6 @@ class ModeManager:
running_modes_detail[mode] = { running_modes_detail[mode] = {
'started_at': info.get('started_at'), 'started_at': info.get('started_at'),
'device': params.get('device', params.get('device_index', 0)), 'device': params.get('device', params.get('device_index', 0)),
'sdr_type': str(params.get('sdr_type', 'rtlsdr')).lower(),
} }
status = { status = {
@@ -699,22 +698,20 @@ class ModeManager:
# Modes that use RTL-SDR devices # Modes that use RTL-SDR devices
SDR_MODES = {'adsb', 'sensor', 'pager', 'ais', 'acars', 'dsc', 'rtlamr', 'listening_post'} SDR_MODES = {'adsb', 'sensor', 'pager', 'ais', 'acars', 'dsc', 'rtlamr', 'listening_post'}
def get_sdr_in_use(self, device: int = 0, sdr_type: str = 'rtlsdr') -> str | None: def get_sdr_in_use(self, device: int = 0) -> str | None:
"""Check if an SDR device is in use by another mode. """Check if an SDR device is in use by another mode.
Returns the mode name using the device, or None if available. Returns the mode name using the device, or None if available.
""" """
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
for mode, info in self.running_modes.items(): for mode, info in self.running_modes.items():
if mode in self.SDR_MODES: if mode in self.SDR_MODES:
mode_device = info.get('params', {}).get('device', 0) mode_device = info.get('params', {}).get('device', 0)
mode_sdr_type = str(info.get('params', {}).get('sdr_type', 'rtlsdr')).lower()
# Normalize to int for comparison # Normalize to int for comparison
try: try:
mode_device = int(mode_device) mode_device = int(mode_device)
except (ValueError, TypeError): except (ValueError, TypeError):
mode_device = 0 mode_device = 0
if mode_device == device and mode_sdr_type == sdr_type_key: if mode_device == device:
return mode return mode
return None return None
@@ -734,8 +731,7 @@ class ModeManager:
device = int(device) device = int(device)
except (ValueError, TypeError): except (ValueError, TypeError):
device = 0 device = 0
sdr_type = str(params.get('sdr_type', 'rtlsdr')).lower() in_use_by = self.get_sdr_in_use(device)
in_use_by = self.get_sdr_in_use(device, sdr_type)
if in_use_by: if in_use_by:
return { return {
'status': 'error', 'status': 'error',
@@ -1104,11 +1100,6 @@ class ModeManager:
# Mode-specific cleanup # Mode-specific cleanup
if mode == 'adsb': if mode == 'adsb':
self.adsb_aircraft.clear() self.adsb_aircraft.clear()
if 'adsb_mlat' in self.output_threads:
thread = self.output_threads['adsb_mlat']
if thread and thread.is_alive():
thread.join(timeout=1)
del self.output_threads['adsb_mlat']
elif mode == 'wifi': elif mode == 'wifi':
self.wifi_networks.clear() self.wifi_networks.clear()
self.wifi_clients.clear() self.wifi_clients.clear()
@@ -1324,16 +1315,10 @@ class ModeManager:
sdr_type_str = params.get('sdr_type', 'rtlsdr') sdr_type_str = params.get('sdr_type', 'rtlsdr')
remote_sbs_host = params.get('remote_sbs_host') remote_sbs_host = params.get('remote_sbs_host')
remote_sbs_port = params.get('remote_sbs_port', 30003) remote_sbs_port = params.get('remote_sbs_port', 30003)
mlat_sbs_host = params.get('mlat_sbs_host')
mlat_sbs_port = params.get('mlat_sbs_port', 30105)
# If remote SBS host provided, just connect to it # If remote SBS host provided, just connect to it
if remote_sbs_host: if remote_sbs_host:
result = self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port, source_tag='adsb', thread_name='adsb') return self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port)
if mlat_sbs_host:
self._start_adsb_sbs_connection(mlat_sbs_host, mlat_sbs_port, source_tag='mlat', thread_name='adsb_mlat')
result['mlat_source'] = f'{mlat_sbs_host}:{mlat_sbs_port}'
return result
# Check if dump1090 already running on port 30003 # Check if dump1090 already running on port 30003
try: try:
@@ -1343,11 +1328,7 @@ class ModeManager:
sock.close() sock.close()
if result == 0: if result == 0:
logger.info("dump1090 already running, connecting to SBS port") logger.info("dump1090 already running, connecting to SBS port")
result = self._start_adsb_sbs_connection('localhost', 30003, source_tag='adsb', thread_name='adsb') return self._start_adsb_sbs_connection('localhost', 30003)
if mlat_sbs_host:
self._start_adsb_sbs_connection(mlat_sbs_host, mlat_sbs_port, source_tag='mlat', thread_name='adsb_mlat')
result['mlat_source'] = f'{mlat_sbs_host}:{mlat_sbs_port}'
return result
except Exception: except Exception:
pass pass
@@ -1404,11 +1385,7 @@ class ModeManager:
return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'} return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'}
# Connect to SBS port # Connect to SBS port
result = self._start_adsb_sbs_connection('localhost', 30003, source_tag='adsb', thread_name='adsb') return self._start_adsb_sbs_connection('localhost', 30003)
if mlat_sbs_host:
self._start_adsb_sbs_connection(mlat_sbs_host, mlat_sbs_port, source_tag='mlat', thread_name='adsb_mlat')
result['mlat_source'] = f'{mlat_sbs_host}:{mlat_sbs_port}'
return result
except FileNotFoundError: except FileNotFoundError:
return {'status': 'error', 'message': 'dump1090 not found'} return {'status': 'error', 'message': 'dump1090 not found'}
@@ -1437,15 +1414,15 @@ class ModeManager:
return path return path
return None return None
def _start_adsb_sbs_connection(self, host: str, port: int, *, source_tag: str = 'adsb', thread_name: str = 'adsb') -> dict: def _start_adsb_sbs_connection(self, host: str, port: int) -> dict:
"""Connect to SBS port and start parsing.""" """Connect to SBS port and start parsing."""
thread = threading.Thread( thread = threading.Thread(
target=self._adsb_sbs_reader, target=self._adsb_sbs_reader,
args=(host, port, source_tag), args=(host, port),
daemon=True daemon=True
) )
thread.start() thread.start()
self.output_threads[thread_name] = thread self.output_threads['adsb'] = thread
return { return {
'status': 'started', 'status': 'started',
@@ -1454,7 +1431,7 @@ class ModeManager:
'gps_enabled': gps_manager.is_running 'gps_enabled': gps_manager.is_running
} }
def _adsb_sbs_reader(self, host: str, port: int, source_tag: str = 'adsb'): def _adsb_sbs_reader(self, host: str, port: int):
"""Read and parse SBS data from dump1090.""" """Read and parse SBS data from dump1090."""
mode = 'adsb' mode = 'adsb'
stop_event = self.stop_events.get(mode) stop_event = self.stop_events.get(mode)
@@ -1466,7 +1443,7 @@ class ModeManager:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5.0) sock.settimeout(5.0)
sock.connect((host, port)) sock.connect((host, port))
logger.info(f"Connected to SBS at {host}:{port} ({source_tag})") logger.info(f"Connected to SBS at {host}:{port}")
retry_count = 0 retry_count = 0
buffer = "" buffer = ""
@@ -1481,7 +1458,7 @@ class ModeManager:
while '\n' in buffer: while '\n' in buffer:
line, buffer = buffer.split('\n', 1) line, buffer = buffer.split('\n', 1)
self._parse_sbs_line(line.strip(), source_tag) self._parse_sbs_line(line.strip())
except socket.timeout: except socket.timeout:
continue continue
@@ -1498,7 +1475,7 @@ class ModeManager:
logger.info("ADS-B SBS reader stopped") logger.info("ADS-B SBS reader stopped")
def _parse_sbs_line(self, line: str, source_tag: str = 'adsb'): def _parse_sbs_line(self, line: str):
"""Parse SBS format line and update aircraft dict.""" """Parse SBS format line and update aircraft dict."""
if not line: if not line:
return return
@@ -1532,8 +1509,6 @@ class ModeManager:
if parts[14] and parts[15]: if parts[14] and parts[15]:
aircraft['lat'] = float(parts[14]) aircraft['lat'] = float(parts[14])
aircraft['lon'] = float(parts[15]) aircraft['lon'] = float(parts[15])
if source_tag:
aircraft['position_source'] = source_tag
elif msg_type == '4' and len(parts) > 16: elif msg_type == '4' and len(parts) > 16:
if parts[12]: if parts[12]:
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.15.0" version = "2.16.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+2
View File
@@ -32,6 +32,7 @@ def register_blueprints(app):
from .websdr import websdr_bp from .websdr import websdr_bp
from .alerts import alerts_bp from .alerts import alerts_bp
from .recordings import recordings_bp from .recordings import recordings_bp
from .subghz import subghz_bp
app.register_blueprint(pager_bp) app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp) app.register_blueprint(sensor_bp)
@@ -63,6 +64,7 @@ def register_blueprints(app):
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
app.register_blueprint(alerts_bp) # Cross-mode alerts app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
# Initialize TSCM state with queue and lock from app # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
+25 -83
View File
@@ -35,9 +35,6 @@ from config import (
ADSB_DB_USER, ADSB_DB_USER,
ADSB_AUTO_START, ADSB_AUTO_START,
ADSB_HISTORY_ENABLED, ADSB_HISTORY_ENABLED,
ADSB_MLAT_ENABLED,
ADSB_MLAT_SBS_HOST,
ADSB_MLAT_SBS_PORT,
SHARED_OBSERVER_LOCATION_ENABLED, SHARED_OBSERVER_LOCATION_ENABLED,
) )
from utils.logging import adsb_logger as logger from utils.logging import adsb_logger as logger
@@ -74,10 +71,7 @@ adsb_last_message_time = None
adsb_bytes_received = 0 adsb_bytes_received = 0
adsb_lines_received = 0 adsb_lines_received = 0
adsb_active_device = None # Track which device index is being used adsb_active_device = None # Track which device index is being used
adsb_active_sdr_type = None
_sbs_error_logged = False # Suppress repeated connection error logs _sbs_error_logged = False # Suppress repeated connection error logs
adsb_connected_sources: set[str] = set()
_adsb_connection_lock = threading.Lock()
# Track ICAOs already looked up in aircraft database (avoid repeated lookups) # Track ICAOs already looked up in aircraft database (avoid repeated lookups)
_looked_up_icaos: set[str] = set() _looked_up_icaos: set[str] = set()
@@ -324,29 +318,7 @@ def check_dump1090_service():
return None return None
def _reset_adsb_state() -> None: def parse_sbs_stream(service_addr):
global adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
adsb_connected = False
adsb_messages_received = 0
adsb_last_message_time = None
adsb_bytes_received = 0
adsb_lines_received = 0
_sbs_error_logged = False
with _adsb_connection_lock:
adsb_connected_sources.clear()
def _set_adsb_connected(source_key: str, connected: bool) -> None:
global adsb_connected
with _adsb_connection_lock:
if connected:
adsb_connected_sources.add(source_key)
else:
adsb_connected_sources.discard(source_key)
adsb_connected = bool(adsb_connected_sources)
def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
"""Parse SBS format data from dump1090 SBS port.""" """Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
@@ -355,23 +327,26 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
host, port = service_addr.split(':') host, port = service_addr.split(':')
port = int(port) port = int(port)
source_label = source_tag or 'adsb'
logger.info(f"SBS stream parser started ({source_label}), connecting to {host}:{port}") logger.info(f"SBS stream parser started, connecting to {host}:{port}")
adsb_connected = False
adsb_messages_received = 0
_sbs_error_logged = False
while adsb_using_service: while adsb_using_service:
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(SBS_SOCKET_TIMEOUT) sock.settimeout(SBS_SOCKET_TIMEOUT)
sock.connect((host, port)) sock.connect((host, port))
_set_adsb_connected(service_addr, True) adsb_connected = True
_sbs_error_logged = False # Reset so we log next error _sbs_error_logged = False # Reset so we log next error
logger.info(f"Connected to SBS stream ({source_label})") logger.info("Connected to SBS stream")
buffer = "" buffer = ""
last_update = time.time() last_update = time.time()
pending_updates = set() pending_updates = set()
local_lines_received = 0 adsb_bytes_received = 0
adsb_lines_received = 0
while adsb_using_service: while adsb_using_service:
try: try:
@@ -389,14 +364,13 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
continue continue
adsb_lines_received += 1 adsb_lines_received += 1
local_lines_received += 1
# Log first few lines for debugging # Log first few lines for debugging
if local_lines_received <= 3: if adsb_lines_received <= 3:
logger.info(f"SBS line ({source_label}) {local_lines_received}: {line[:100]}") logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
parts = line.split(',') parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG': if len(parts) < 11 or parts[0] != 'MSG':
if local_lines_received <= 5: if adsb_lines_received <= 5:
logger.debug(f"Skipping non-MSG line: {line[:50]}") logger.debug(f"Skipping non-MSG line: {line[:50]}")
continue continue
@@ -447,8 +421,6 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
try: try:
aircraft['lat'] = float(parts[14]) aircraft['lat'] = float(parts[14])
aircraft['lon'] = float(parts[15]) aircraft['lon'] = float(parts[15])
if source_label:
aircraft['position_source'] = source_label
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@@ -522,26 +494,18 @@ def parse_sbs_stream(service_addr: str, source_tag: str | None = None):
continue continue
sock.close() sock.close()
_set_adsb_connected(service_addr, False) adsb_connected = False
except OSError as e: except OSError as e:
_set_adsb_connected(service_addr, False) adsb_connected = False
if not _sbs_error_logged: if not _sbs_error_logged:
logger.warning(f"SBS connection error: {e}, reconnecting...") logger.warning(f"SBS connection error: {e}, reconnecting...")
_sbs_error_logged = True _sbs_error_logged = True
time.sleep(SBS_RECONNECT_DELAY) time.sleep(SBS_RECONNECT_DELAY)
_set_adsb_connected(service_addr, False) adsb_connected = False
logger.info("SBS stream parser stopped") logger.info("SBS stream parser stopped")
def _start_mlat_stream(host: str, port: int) -> str:
mlat_addr = f"{host}:{port}"
logger.info(f"Connecting to MLAT SBS at {mlat_addr}")
thread = threading.Thread(target=parse_sbs_stream, args=(mlat_addr, 'mlat'), daemon=True)
thread.start()
return mlat_addr
@adsb_bp.route('/tools') @adsb_bp.route('/tools')
def check_adsb_tools(): def check_adsb_tools():
"""Check for ADS-B decoding tools and hardware.""" """Check for ADS-B decoding tools and hardware."""
@@ -616,7 +580,7 @@ def adsb_session():
@adsb_bp.route('/start', methods=['POST']) @adsb_bp.route('/start', methods=['POST'])
def start_adsb(): def start_adsb():
"""Start ADS-B tracking.""" """Start ADS-B tracking."""
global adsb_using_service, adsb_active_device, adsb_active_sdr_type global adsb_using_service, adsb_active_device
with app_module.adsb_lock: with app_module.adsb_lock:
if adsb_using_service: if adsb_using_service:
@@ -637,22 +601,10 @@ def start_adsb():
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
_reset_adsb_state()
# Check for remote SBS connection (e.g., remote dump1090) # Check for remote SBS connection (e.g., remote dump1090)
remote_sbs_host = data.get('remote_sbs_host') remote_sbs_host = data.get('remote_sbs_host')
remote_sbs_port = data.get('remote_sbs_port', 30003) remote_sbs_port = data.get('remote_sbs_port', 30003)
mlat_sbs_host = (data.get('mlat_sbs_host') or '').strip()
mlat_sbs_port = data.get('mlat_sbs_port', ADSB_MLAT_SBS_PORT)
if not mlat_sbs_host and ADSB_MLAT_ENABLED and ADSB_MLAT_SBS_HOST:
mlat_sbs_host = ADSB_MLAT_SBS_HOST
mlat_sbs_port = ADSB_MLAT_SBS_PORT
if mlat_sbs_host:
try:
mlat_sbs_host = validate_rtl_tcp_host(mlat_sbs_host)
mlat_sbs_port = validate_rtl_tcp_port(mlat_sbs_port)
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
if remote_sbs_host: if remote_sbs_host:
# Validate and connect to remote dump1090 SBS output # Validate and connect to remote dump1090 SBS output
@@ -665,10 +617,8 @@ def start_adsb():
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}" remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}") logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
adsb_using_service = True adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr, 'adsb'), daemon=True) thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
thread.start() thread.start()
if mlat_sbs_host:
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
session = _record_session_start( session = _record_session_start(
device_index=device, device_index=device,
sdr_type='remote', sdr_type='remote',
@@ -688,10 +638,8 @@ def start_adsb():
if existing_service: if existing_service:
logger.info(f"Found existing dump1090 service at {existing_service}") logger.info(f"Found existing dump1090 service at {existing_service}")
adsb_using_service = True adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service, 'adsb'), daemon=True) thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
thread.start() thread.start()
if mlat_sbs_host:
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
session = _record_session_start( session = _record_session_start(
device_index=device, device_index=device,
sdr_type='external', sdr_type='external',
@@ -741,7 +689,7 @@ def start_adsb():
# Check if device is available before starting local dump1090 # Check if device is available before starting local dump1090
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type.value) error = app_module.claim_sdr_device(device_int, 'adsb')
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -778,7 +726,7 @@ def start_adsb():
if app_module.adsb_process.poll() is not None: if app_module.adsb_process.poll() is not None:
# Process exited - release device and get error message # Process exited - release device and get error message
app_module.release_sdr_device(device_int, sdr_type.value) app_module.release_sdr_device(device_int)
stderr_output = '' stderr_output = ''
if app_module.adsb_process.stderr: if app_module.adsb_process.stderr:
try: try:
@@ -824,12 +772,9 @@ def start_adsb():
}) })
adsb_using_service = True adsb_using_service = True
adsb_active_device = device # Track which device index is being used adsb_active_device = device # Track which device is being used
adsb_active_sdr_type = sdr_type.value thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}', 'adsb'), daemon=True)
thread.start() thread.start()
if mlat_sbs_host:
_start_mlat_stream(mlat_sbs_host, mlat_sbs_port)
session = _record_session_start( session = _record_session_start(
device_index=device, device_index=device,
@@ -847,14 +792,14 @@ def start_adsb():
}) })
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type.value) app_module.release_sdr_device(device_int)
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@adsb_bp.route('/stop', methods=['POST']) @adsb_bp.route('/stop', methods=['POST'])
def stop_adsb(): def stop_adsb():
"""Stop ADS-B tracking.""" """Stop ADS-B tracking."""
global adsb_using_service, adsb_active_device, adsb_active_sdr_type global adsb_using_service, adsb_active_device
data = request.json or {} data = request.json or {}
stop_source = data.get('source') stop_source = data.get('source')
stopped_by = request.remote_addr stopped_by = request.remote_addr
@@ -878,12 +823,10 @@ def stop_adsb():
# Release device from registry # Release device from registry
if adsb_active_device is not None: if adsb_active_device is not None:
app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(adsb_active_device)
adsb_using_service = False adsb_using_service = False
adsb_active_device = None adsb_active_device = None
adsb_active_sdr_type = None
_reset_adsb_state()
app_module.adsb_aircraft.clear() app_module.adsb_aircraft.clear()
_looked_up_icaos.clear() _looked_up_icaos.clear()
@@ -925,7 +868,6 @@ def adsb_dashboard():
'adsb_dashboard.html', 'adsb_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED, shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
adsb_auto_start=ADSB_AUTO_START, adsb_auto_start=ADSB_AUTO_START,
adsb_mlat_enabled=ADSB_MLAT_ENABLED,
) )
+5 -8
View File
@@ -44,7 +44,6 @@ ais_connected = False
ais_messages_received = 0 ais_messages_received = 0
ais_last_message_time = None ais_last_message_time = None
ais_active_device = None ais_active_device = None
ais_active_sdr_type = None
_ais_error_logged = True _ais_error_logged = True
# Common installation paths for AIS-catcher # Common installation paths for AIS-catcher
@@ -327,7 +326,7 @@ def ais_status():
@ais_bp.route('/start', methods=['POST']) @ais_bp.route('/start', methods=['POST'])
def start_ais(): def start_ais():
"""Start AIS tracking.""" """Start AIS tracking."""
global ais_running, ais_active_device, ais_active_sdr_type global ais_running, ais_active_device
with app_module.ais_lock: with app_module.ais_lock:
if ais_running: if ais_running:
@@ -374,7 +373,7 @@ def start_ais():
# Check if device is available # Check if device is available
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type.value) error = app_module.claim_sdr_device(device_int, 'ais')
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -413,7 +412,7 @@ def start_ais():
if app_module.ais_process.poll() is not None: if app_module.ais_process.poll() is not None:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type.value) app_module.release_sdr_device(device_int)
stderr_output = '' stderr_output = ''
if app_module.ais_process.stderr: if app_module.ais_process.stderr:
try: try:
@@ -427,7 +426,6 @@ def start_ais():
ais_running = True ais_running = True
ais_active_device = device ais_active_device = device
ais_active_sdr_type = sdr_type.value
# Start TCP parser thread # Start TCP parser thread
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True) thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
@@ -441,7 +439,7 @@ def start_ais():
}) })
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
app_module.release_sdr_device(device_int, sdr_type.value) app_module.release_sdr_device(device_int)
logger.error(f"Failed to start AIS-catcher: {e}") logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500 return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -468,11 +466,10 @@ def stop_ais():
# Release device from registry # Release device from registry
if ais_active_device is not None: if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(ais_active_device)
ais_running = False ais_running = False
ais_active_device = None ais_active_device = None
ais_active_sdr_type = None
app_module.ais_vessels.clear() app_module.ais_vessels.clear()
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+52 -30
View File
@@ -23,6 +23,7 @@ from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm 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.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
@@ -77,6 +78,11 @@ def find_rtl_fm() -> Optional[str]:
return shutil.which('rtl_fm') return shutil.which('rtl_fm')
def find_rx_fm() -> Optional[str]:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_rtl_power() -> Optional[str]: def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning.""" """Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power') return shutil.which('rtl_power')
@@ -1417,14 +1423,17 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
def check_aprs_tools() -> Response: def check_aprs_tools() -> Response:
"""Check for APRS decoding tools.""" """Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None has_rtl_fm = find_rtl_fm() is not None
has_rx_fm = find_rx_fm() is not None
has_direwolf = find_direwolf() is not None has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None has_multimon = find_multimon_ng() is not None
has_fm_demod = has_rtl_fm or has_rx_fm
return jsonify({ return jsonify({
'rtl_fm': has_rtl_fm, 'rtl_fm': has_rtl_fm,
'rx_fm': has_rx_fm,
'direwolf': has_direwolf, 'direwolf': has_direwolf,
'multimon_ng': has_multimon, 'multimon_ng': has_multimon,
'ready': has_rtl_fm and (has_direwolf or has_multimon), 'ready': has_fm_demod and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None) 'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
}) })
@@ -1467,14 +1476,6 @@ def start_aprs() -> Response:
'message': 'APRS decoder already running' 'message': 'APRS decoder already running'
}), 409 }), 409
# Check for required tools
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
# Check for decoder (prefer direwolf, fallback to multimon-ng) # Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf() direwolf_path = find_direwolf()
multimon_path = find_multimon_ng() multimon_path = find_multimon_ng()
@@ -1495,6 +1496,25 @@ def start_aprs() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
else:
if find_rx_fm() is None:
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400
# Reserve SDR device to prevent conflicts with other modes # Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs') error = app_module.claim_sdr_device(device, 'aprs')
if error: if error:
@@ -1525,28 +1545,29 @@ def start_aprs() -> Response:
aprs_last_packet_time = None aprs_last_packet_time = None
aprs_stations = {} aprs_stations = {}
# Build rtl_fm command for APRS (narrowband FM at 22050 Hz for AFSK1200) # Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
freq_hz = f"{float(frequency)}M" try:
rtl_cmd = [ sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
rtl_fm_path, builder = SDRFactory.get_builder(sdr_type)
'-f', freq_hz, rtl_cmd = builder.build_fm_demod_command(
'-M', 'nfm', # Narrowband FM for APRS device=sdr_device,
'-s', '22050', # Sample rate matching direwolf -r 22050 frequency_mhz=float(frequency),
'-E', 'dc', # Enable DC blocking filter for cleaner audio sample_rate=22050,
'-A', 'fast', # Fast AGC for packet bursts gain=float(gain) if gain and str(gain) != '0' else None,
'-d', str(device), ppm=int(ppm) if ppm and str(ppm) != '0' else None,
] modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
# Gain: 0 means auto, otherwise set specific gain if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
if gain and str(gain) != '0': # APRS benefits from DC blocking + fast AGC on rtl_fm.
rtl_cmd.extend(['-g', str(gain)]) rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e:
# PPM frequency correction if aprs_active_device is not None:
if ppm and str(ppm) != '0': app_module.release_sdr_device(aprs_active_device)
rtl_cmd.extend(['-p', str(ppm)]) aprs_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Output raw audio to stdout
rtl_cmd.append('-')
# Build decoder command # Build decoder command
if direwolf_path: if direwolf_path:
@@ -1674,6 +1695,7 @@ def start_aprs() -> Response:
'frequency': frequency, 'frequency': frequency,
'region': region, 'region': region,
'device': device, 'device': device,
'sdr_type': sdr_type.value,
'decoder': decoder_name 'decoder': decoder_name
}) })
+7 -1
View File
@@ -41,6 +41,12 @@ def find_ffmpeg():
return shutil.which('ffmpeg') return shutil.which('ffmpeg')
def _rtl_fm_demod_mode(modulation):
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def kill_audio_processes(): def kill_audio_processes():
"""Kill any running audio processes.""" """Kill any running audio processes."""
global audio_process, rtl_process global audio_process, rtl_process
@@ -106,7 +112,7 @@ def start_audio_stream(config):
rtl_cmd = [ rtl_cmd = [
rtl_fm, rtl_fm,
'-M', mod, '-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz), '-f', str(freq_hz),
'-s', str(sample_rate), '-s', str(sample_rate),
'-r', str(resample_rate), '-r', str(resample_rate),
+140 -237
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import json
import queue import queue
import re import re
import select import select
@@ -22,6 +21,7 @@ from utils.sse import format_sse
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.process import register_process, unregister_process from utils.process import register_process, unregister_process
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
@@ -45,14 +45,14 @@ dmr_lock = threading.Lock()
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_active_device: Optional[int] = None dmr_active_device: Optional[int] = None
# Audio mux: the sole reader of dsd-fme stdout. Writes to an ffmpeg # Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
# stdin when a streaming client is connected, discards otherwise. # active ffmpeg stdin sinks when streaming clients are connected.
# This prevents dsd-fme from blocking on stdout (which would also # This prevents dsd-fme from blocking on stdout (which would also
# freeze stderr / text data output). # freeze stderr / text data output).
_active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint _ffmpeg_sinks: set[object] = set()
_ffmpeg_sinks_lock = threading.Lock()
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'p25p2', 'nxdn', 'dstar', 'provoice'] VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
VALID_DEMODS = ['nfm', 'fm']
# Classic dsd flags # Classic dsd flags
_DSD_PROTOCOL_FLAGS = { _DSD_PROTOCOL_FLAGS = {
@@ -70,10 +70,9 @@ _DSD_PROTOCOL_FLAGS = {
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1, # -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
# -ft = XDMA multi-protocol decoder # -ft = XDMA multi-protocol decoder
_DSD_FME_PROTOCOL_FLAGS = { _DSD_FME_PROTOCOL_FLAGS = {
'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF 'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!) 'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!) 'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
'p25p2': ['-f2'], # P25 Phase 2
'nxdn': ['-fn'], # NXDN96 'nxdn': ['-fn'], # NXDN96
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!) 'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv) 'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
@@ -83,8 +82,6 @@ _DSD_FME_PROTOCOL_FLAGS = {
# sync reliability vs letting dsd-fme auto-detect modulation type. # sync reliability vs letting dsd-fme auto-detect modulation type.
_DSD_FME_MODULATION = { _DSD_FME_MODULATION = {
'dmr': ['-mc'], # C4FM 'dmr': ['-mc'], # C4FM
'p25': ['-mc'], # C4FM (Phase 1)
'p25p2': ['-mq'], # CQPSK (Phase 2)
'nxdn': ['-mc'], # C4FM 'nxdn': ['-mc'], # C4FM
} }
@@ -113,83 +110,16 @@ def find_rtl_fm() -> str | None:
return shutil.which('rtl_fm') return shutil.which('rtl_fm')
def find_rx_fm() -> str | None:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_ffmpeg() -> str | None: def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding.""" """Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg') return shutil.which('ffmpeg')
def _coerce_int(value) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _parse_dsd_json(payload: dict, ts: str) -> dict | None:
"""Parse JSON output lines from dsd-fme into events."""
event_type = str(payload.get('type') or payload.get('event') or payload.get('msg') or payload.get('kind') or '').lower()
nested = payload.get('data') if isinstance(payload.get('data'), dict) else {}
def first_of(keys):
for obj in (payload, nested):
for key in keys:
if key in obj and obj[key] is not None:
return obj[key]
return None
talkgroup = _coerce_int(first_of([
'tg', 'tgt', 'talkgroup', 'talk_group', 'tgid',
'group', 'group_id', 'groupId', 'dst', 'dest',
'destination', 'target'
]))
source = _coerce_int(first_of([
'src', 'source', 'src_id', 'source_id', 'sourceId',
'uid', 'unit', 'radio', 'rid', 'radio_id', 'radioId'
]))
slot = _coerce_int(first_of(['slot', 'timeslot', 'time_slot', 'ts']))
nac = first_of(['nac'])
protocol = first_of(['protocol', 'mode', 'system', 'sys', 'network'])
if talkgroup is not None and source is not None:
event = {
'type': 'call',
'talkgroup': talkgroup,
'source_id': source,
'timestamp': ts,
}
if slot is not None:
event['slot'] = slot
if protocol:
event['protocol'] = str(protocol)
return event
if nac is not None:
return {'type': 'nac', 'nac': str(nac), 'timestamp': ts}
if 'sync' in event_type:
return {
'type': 'sync',
'protocol': str(protocol or event_type),
'timestamp': ts,
}
voice_flag = first_of(['voice', 'voice_frame', 'voiceFrame'])
if 'voice' in event_type or voice_flag is True:
event = {
'type': 'voice',
'detail': str(first_of(['detail', 'text']) or event_type or 'voice'),
'timestamp': ts,
}
if slot is not None:
event['slot'] = slot
return event
if protocol:
return {'type': 'sync', 'protocol': str(protocol), 'timestamp': ts}
return None
def parse_dsd_output(line: str) -> dict | None: def parse_dsd_output(line: str) -> dict | None:
"""Parse a line of DSD stderr output into a structured event. """Parse a line of DSD stderr output into a structured event.
@@ -200,46 +130,6 @@ def parse_dsd_output(line: str) -> dict | None:
if not line: if not line:
return None return None
ts = datetime.now().strftime('%H:%M:%S')
# Frame-level error / OK indicators (useful for quality metrics)
if re.search(r'\bDUID\s+ERR\b', line, re.IGNORECASE):
return {
'type': 'frame_error',
'kind': 'duid',
'detail': line[:200],
'timestamp': ts,
}
if re.search(r'\bR-?S\s+ERR\b', line, re.IGNORECASE):
return {
'type': 'frame_error',
'kind': 'rs',
'detail': line[:200],
'timestamp': ts,
}
if re.search(r'\bP25p2\b.*\b4V\b', line, re.IGNORECASE):
return {
'type': 'frame_ok',
'kind': 'p25p2',
'timestamp': ts,
}
# If dsd-fme is emitting JSON (via -J), parse it first.
if line.startswith('{') and line.endswith('}'):
try:
payload = json.loads(line)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
parsed = _parse_dsd_json(payload, ts)
if parsed:
return parsed
return {
'type': 'raw',
'text': line[:200],
'timestamp': ts,
}
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.) # Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
# Only filter lines that are purely decorative — dsd-fme uses box-drawing # Only filter lines that are purely decorative — dsd-fme uses box-drawing
# characters (│, ─) as column separators in DATA lines, so we must not # characters (│, ─) as column separators in DATA lines, so we must not
@@ -250,6 +140,8 @@ def parse_dsd_output(line: str) -> dict | None:
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line): if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
return None return None
ts = datetime.now().strftime('%H:%M:%S')
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1" # Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line) sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
if sync_match: if sync_match:
@@ -328,14 +220,66 @@ _HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
_SILENCE_CHUNK = b'\x00' * 1600 _SILENCE_CHUNK = b'\x00' * 1600
def _register_audio_sink(sink: object) -> None:
"""Register an ffmpeg stdin sink for mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.add(sink)
def _unregister_audio_sink(sink: object) -> None:
"""Remove an ffmpeg stdin sink from mux fanout."""
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.discard(sink)
def _get_audio_sinks() -> tuple[object, ...]:
"""Snapshot current audio sinks for lock-free iteration."""
with _ffmpeg_sinks_lock:
return tuple(_ffmpeg_sinks)
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
"""Terminate and unregister a subprocess if present."""
if not proc:
return
if proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=2)
except Exception:
try:
proc.kill()
except Exception:
pass
unregister_process(proc)
def _reset_runtime_state(*, release_device: bool) -> None:
"""Reset process + runtime state and optionally release SDR ownership."""
global dmr_rtl_process, dmr_dsd_process
global dmr_running, dmr_has_audio, dmr_active_device
_stop_process(dmr_dsd_process)
_stop_process(dmr_rtl_process)
dmr_rtl_process = None
dmr_dsd_process = None
dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
if release_device and dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None
def _dsd_audio_mux(dsd_stdout): def _dsd_audio_mux(dsd_stdout):
"""Mux thread: sole reader of dsd-fme stdout. """Mux thread: sole reader of dsd-fme stdout.
Always drains dsd-fme's audio output to prevent the process from Always drains dsd-fme's audio output to prevent the process from
blocking on stdout writes (which would also freeze stderr / text blocking on stdout writes (which would also freeze stderr / text
data). When an audio streaming client is connected, forwards audio data). When streaming clients are connected, forwards data to all
to its ffmpeg stdin with silence fill during voice gaps. When no active ffmpeg stdin sinks with silence fill during voice gaps.
client is connected, simply discards the data.
""" """
try: try:
while dmr_running: while dmr_running:
@@ -344,22 +288,22 @@ def _dsd_audio_mux(dsd_stdout):
data = os.read(dsd_stdout.fileno(), 4096) data = os.read(dsd_stdout.fileno(), 4096)
if not data: if not data:
break break
sink = _active_ffmpeg_stdin sinks = _get_audio_sinks()
if sink: for sink in sinks:
try: try:
sink.write(data) sink.write(data)
sink.flush() sink.flush()
except (BrokenPipeError, OSError, ValueError): except (BrokenPipeError, OSError, ValueError):
pass _unregister_audio_sink(sink)
else: else:
# No audio from decoder — feed silence if client connected # No audio from decoder — feed silence if client connected
sink = _active_ffmpeg_stdin sinks = _get_audio_sinks()
if sink: for sink in sinks:
try: try:
sink.write(_SILENCE_CHUNK) sink.write(_SILENCE_CHUNK)
sink.flush() sink.flush()
except (BrokenPipeError, OSError, ValueError): except (BrokenPipeError, OSError, ValueError):
pass _unregister_audio_sink(sink)
except (OSError, ValueError): except (OSError, ValueError):
pass pass
@@ -430,7 +374,11 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
logger.error(f"DSD stream error: {e}") logger.error(f"DSD stream error: {e}")
finally: finally:
global dmr_active_device, dmr_rtl_process, dmr_dsd_process global dmr_active_device, dmr_rtl_process, dmr_dsd_process
global dmr_has_audio
dmr_running = False dmr_running = False
dmr_has_audio = False
with _ffmpeg_sinks_lock:
_ffmpeg_sinks.clear()
# Capture exit info for diagnostics # Capture exit info for diagnostics
rc = dsd_process.poll() rc = dsd_process.poll()
reason = 'stopped' reason = 'stopped'
@@ -445,18 +393,8 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
pass pass
logger.warning(f"DSD process exited with code {rc}: {detail}") logger.warning(f"DSD process exited with code {rc}: {detail}")
# Cleanup decoder + demod processes # Cleanup decoder + demod processes
for proc in [dsd_process, rtl_process]: _stop_process(dsd_process)
if proc and proc.poll() is None: _stop_process(rtl_process)
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_rtl_process = None
dmr_dsd_process = None dmr_dsd_process = None
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail}) _queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
@@ -476,12 +414,14 @@ def check_tools() -> Response:
"""Check for required tools.""" """Check for required tools."""
dsd_path, _ = find_dsd() dsd_path, _ = find_dsd()
rtl_fm = find_rtl_fm() rtl_fm = find_rtl_fm()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg() ffmpeg = find_ffmpeg()
return jsonify({ return jsonify({
'dsd': dsd_path is not None, 'dsd': dsd_path is not None,
'rtl_fm': rtl_fm is not None, 'rtl_fm': rtl_fm is not None,
'rx_fm': rx_fm is not None,
'ffmpeg': ffmpeg is not None, 'ffmpeg': ffmpeg is not None,
'available': dsd_path is not None and rtl_fm is not None, 'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None),
'protocols': VALID_PROTOCOLS, 'protocols': VALID_PROTOCOLS,
}) })
@@ -492,18 +432,10 @@ def start_dmr() -> Response:
global dmr_rtl_process, dmr_dsd_process, dmr_thread global dmr_rtl_process, dmr_dsd_process, dmr_thread
global dmr_running, dmr_has_audio, dmr_active_device global dmr_running, dmr_has_audio, dmr_active_device
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dsd_path, is_fme = find_dsd() dsd_path, is_fme = find_dsd()
if not dsd_path: if not dsd_path:
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503 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 {} data = request.json or {}
try: try:
@@ -512,19 +444,27 @@ def start_dmr() -> Response:
device = validate_device_index(data.get('device', 0)) device = validate_device_index(data.get('device', 0))
protocol = str(data.get('protocol', 'auto')).lower() protocol = str(data.get('protocol', 'auto')).lower()
ppm = validate_ppm(data.get('ppm', 0)) ppm = validate_ppm(data.get('ppm', 0))
fine_tune = int(data.get('fineTune', 0) or 0)
demod = str(data.get('demod', 'nfm')).lower()
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if protocol not in VALID_PROTOCOLS: if protocol not in VALID_PROTOCOLS:
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400 return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
if demod not in VALID_DEMODS:
return jsonify({'status': 'error', 'message': f'Invalid demod. Use: {", ".join(VALID_DEMODS)}'}), 400 if sdr_type == SDRType.RTL_SDR:
if protocol == 'p25p2' and not is_fme: if not find_rtl_fm():
return jsonify({'status': 'error', 'message': 'P25 Phase 2 requires dsd-fme.'}), 400 return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
if abs(fine_tune) > 20000: else:
return jsonify({'status': 'error', 'message': 'Fine tune offset too large (max +/- 20000 Hz).'}), 400 if not find_rx_fm():
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 503
# Clear stale queue # Clear stale queue
try: try:
@@ -533,32 +473,45 @@ def start_dmr() -> Response:
except queue.Empty: except queue.Empty:
pass pass
# Reserve running state before we start claiming resources/processes
# so concurrent /start requests cannot race each other.
with dmr_lock:
if dmr_running:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
dmr_running = True
dmr_has_audio = False
# Claim SDR device — use protocol name so the device panel shows # Claim SDR device — use protocol name so the device panel shows
# "D-STAR", "P25", etc. instead of always "DMR" # "D-STAR", "P25", etc. instead of always "DMR"
mode_label = protocol.upper() if protocol != 'auto' else 'DMR' mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
error = app_module.claim_sdr_device(device, mode_label) error = app_module.claim_sdr_device(device, mode_label)
if error: if error:
with dmr_lock:
dmr_running = False
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
dmr_active_device = device dmr_active_device = device
freq_hz = int((frequency * 1e6) + fine_tune) # Build FM demodulation command via SDR abstraction.
try:
# Build rtl_fm command (48kHz sample rate for DSD). sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream builder = SDRFactory.get_builder(sdr_type)
# mid-frame, destroying DSD sync. The decoder handles silence rtl_cmd = builder.build_fm_demod_command(
# internally via its own frame-sync detection. device=sdr_device,
rtl_cmd = [ frequency_mhz=frequency,
rtl_fm_path, sample_rate=48000,
'-M', demod, gain=float(gain) if gain > 0 else None,
'-f', str(freq_hz), ppm=int(ppm) if ppm != 0 else None,
'-s', '48000', modulation='fm',
'-g', str(gain), squelch=None,
'-d', str(device), bias_t=bool(data.get('bias_t', False)),
'-l', '0', )
] if sdr_type == SDRType.RTL_SDR:
if ppm != 0: # Keep squelch fully open for digital bitstreams.
rtl_cmd.extend(['-p', str(ppm)]) rtl_cmd.extend(['-l', '0'])
except Exception as e:
_reset_runtime_state(release_device=True)
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build DSD command # Build DSD command
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for # Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
@@ -606,9 +559,6 @@ def start_dmr() -> Response:
# Allow rtl_fm to send directly to dsd # Allow rtl_fm to send directly to dsd
dmr_rtl_process.stdout.close() dmr_rtl_process.stdout.close()
# Mark running before starting mux so it doesn't exit immediately.
dmr_running = True
# Start mux thread: always drains dsd-fme stdout to prevent the # Start mux thread: always drains dsd-fme stdout to prevent the
# process from blocking (which would freeze stderr / text data). # process from blocking (which would freeze stderr / text data).
# ffmpeg is started lazily per-client in /dmr/audio/stream. # ffmpeg is started lazily per-client in /dmr/audio/stream.
@@ -633,26 +583,8 @@ def start_dmr() -> Response:
if dmr_dsd_process.stderr: if dmr_dsd_process.stderr:
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500] 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}") logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
# Terminate surviving processes and unregister all # Terminate surviving processes and release resources.
dmr_running = False _reset_runtime_state(release_device=True)
dmr_has_audio = 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
# Surface a clear error to the user # Surface a clear error to the user
detail = rtl_err.strip() or dsd_err.strip() detail = rtl_err.strip() or dsd_err.strip()
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err: if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
@@ -684,48 +616,21 @@ def start_dmr() -> Response:
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
'protocol': protocol, 'protocol': protocol,
'sdr_type': sdr_type.value,
'has_audio': dmr_has_audio, 'has_audio': dmr_has_audio,
}) })
except Exception as e: except Exception as e:
logger.error(f"Failed to start DMR: {e}") logger.error(f"Failed to start DMR: {e}")
dmr_running = False _reset_runtime_state(release_device=True)
dmr_has_audio = False
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 return jsonify({'status': 'error', 'message': str(e)}), 500
@dmr_bp.route('/stop', methods=['POST']) @dmr_bp.route('/stop', methods=['POST'])
def stop_dmr() -> Response: def stop_dmr() -> Response:
"""Stop digital voice decoding.""" """Stop digital voice decoding."""
global dmr_rtl_process, dmr_dsd_process
global dmr_running, dmr_has_audio, dmr_active_device
with dmr_lock: with dmr_lock:
dmr_running = False _reset_runtime_state(release_device=True)
dmr_has_audio = 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'}) return jsonify({'status': 'stopped'})
@@ -752,8 +657,6 @@ def stream_dmr_audio() -> Response:
it, back-pressuring the entire pipeline and freezing stderr/text it, back-pressuring the entire pipeline and freezing stderr/text
data output). data output).
""" """
global _active_ffmpeg_stdin
if not dmr_running or not dmr_has_audio: if not dmr_running or not dmr_has_audio:
return Response(b'', mimetype='audio/wav', status=204) return Response(b'', mimetype='audio/wav', status=204)
@@ -780,11 +683,10 @@ def stream_dmr_audio() -> Response:
args=(audio_proc,), daemon=True, args=(audio_proc,), daemon=True,
).start() ).start()
# Tell the mux thread to start writing to this ffmpeg if audio_proc.stdin:
_active_ffmpeg_stdin = audio_proc.stdin _register_audio_sink(audio_proc.stdin)
def generate(): def generate():
global _active_ffmpeg_stdin
try: try:
while dmr_running and audio_proc.poll() is None: while dmr_running and audio_proc.poll() is None:
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0) ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
@@ -803,7 +705,8 @@ def stream_dmr_audio() -> Response:
logger.error(f"DMR audio stream error: {e}") logger.error(f"DMR audio stream error: {e}")
finally: finally:
# Disconnect mux → ffmpeg, then clean up # Disconnect mux → ffmpeg, then clean up
_active_ffmpeg_stdin = None if audio_proc.stdin:
_unregister_audio_sink(audio_proc.stdin)
try: try:
audio_proc.stdin.close() audio_proc.stdin.close()
except Exception: except Exception:
+45 -38
View File
@@ -51,9 +51,7 @@ scanner_lock = threading.Lock()
scanner_paused = False scanner_paused = False
scanner_current_freq = 0.0 scanner_current_freq = 0.0
scanner_active_device: Optional[int] = None scanner_active_device: Optional[int] = None
scanner_active_sdr_type: Optional[str] = None
listening_active_device: Optional[int] = None listening_active_device: Optional[int] = None
listening_active_sdr_type: Optional[str] = None
scanner_power_process: Optional[subprocess.Popen] = None scanner_power_process: Optional[subprocess.Popen] = None
scanner_config = { scanner_config = {
'start_freq': 88.0, 'start_freq': 88.0,
@@ -115,6 +113,12 @@ def normalize_modulation(value: str) -> str:
return mod return mod
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def add_activity_log(event_type: str, frequency: float, details: str = ''): def add_activity_log(event_type: str, frequency: float, details: str = ''):
@@ -211,7 +215,7 @@ def scanner_loop():
# Don't use squelch in rtl_fm - we want to analyze raw audio # Don't use squelch in rtl_fm - we want to analyze raw audio
rtl_cmd = [ rtl_cmd = [
rtl_fm_path, rtl_fm_path,
'-M', mod, '-M', _rtl_fm_demod_mode(mod),
'-f', str(freq_hz), '-f', str(freq_hz),
'-s', str(sample_rate), '-s', str(sample_rate),
'-r', str(resample_rate), '-r', str(resample_rate),
@@ -683,7 +687,7 @@ def _start_audio_stream(frequency: float, modulation: str):
freq_hz = int(frequency * 1e6) freq_hz = int(frequency * 1e6)
sdr_cmd = [ sdr_cmd = [
rtl_fm_path, rtl_fm_path,
'-M', modulation, '-M', _rtl_fm_demod_mode(modulation),
'-f', str(freq_hz), '-f', str(freq_hz),
'-s', str(sample_rate), '-s', str(sample_rate),
'-r', str(resample_rate), '-r', str(resample_rate),
@@ -939,7 +943,6 @@ def check_tools() -> Response:
def start_scanner() -> Response: def start_scanner() -> Response:
"""Start the frequency scanner.""" """Start the frequency scanner."""
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
global scanner_active_sdr_type, listening_active_sdr_type
with scanner_lock: with scanner_lock:
if scanner_running: if scanner_running:
@@ -1006,11 +1009,10 @@ def start_scanner() -> Response:
}), 503 }), 503
# Release listening device if active # Release listening device if active
if listening_active_device is not None: if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(listening_active_device)
listening_active_device = None listening_active_device = None
listening_active_sdr_type = None
# Claim device for scanner # Claim device for scanner
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type) error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -1018,7 +1020,6 @@ def start_scanner() -> Response:
'message': error 'message': error
}), 409 }), 409
scanner_active_device = scanner_config['device'] scanner_active_device = scanner_config['device']
scanner_active_sdr_type = sdr_type
scanner_running = True scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True) scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
scanner_thread.start() scanner_thread.start()
@@ -1036,10 +1037,9 @@ def start_scanner() -> Response:
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.' 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
}), 503 }), 503
if listening_active_device is not None: if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(listening_active_device)
listening_active_device = None listening_active_device = None
listening_active_sdr_type = None error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -1047,7 +1047,6 @@ def start_scanner() -> Response:
'message': error 'message': error
}), 409 }), 409
scanner_active_device = scanner_config['device'] scanner_active_device = scanner_config['device']
scanner_active_sdr_type = sdr_type
scanner_running = True scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop, daemon=True) scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
@@ -1062,7 +1061,7 @@ def start_scanner() -> Response:
@listening_post_bp.route('/scanner/stop', methods=['POST']) @listening_post_bp.route('/scanner/stop', methods=['POST'])
def stop_scanner() -> Response: def stop_scanner() -> Response:
"""Stop the frequency scanner.""" """Stop the frequency scanner."""
global scanner_running, scanner_active_device, scanner_power_process, scanner_active_sdr_type global scanner_running, scanner_active_device, scanner_power_process
scanner_running = False scanner_running = False
_stop_audio_stream() _stop_audio_stream()
@@ -1077,9 +1076,8 @@ def stop_scanner() -> Response:
pass pass
scanner_power_process = None scanner_power_process = None
if scanner_active_device is not None: if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(scanner_active_device)
scanner_active_device = None scanner_active_device = None
scanner_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -1251,15 +1249,13 @@ def get_presets() -> Response:
def start_audio() -> Response: def start_audio() -> Response:
"""Start audio at specific frequency (manual mode).""" """Start audio at specific frequency (manual mode)."""
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
global scanner_active_sdr_type, listening_active_sdr_type, waterfall_active_sdr_type
# Stop scanner if running # Stop scanner if running
if scanner_running: if scanner_running:
scanner_running = False scanner_running = False
if scanner_active_device is not None: if scanner_active_device is not None:
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(scanner_active_device)
scanner_active_device = None scanner_active_device = None
scanner_active_sdr_type = None
if scanner_thread and scanner_thread.is_alive(): if scanner_thread and scanner_thread.is_alive():
try: try:
scanner_thread.join(timeout=2.0) scanner_thread.join(timeout=2.0)
@@ -1316,7 +1312,7 @@ def start_audio() -> Response:
scanner_config['sdr_type'] = sdr_type scanner_config['sdr_type'] = sdr_type
# Stop waterfall if it's using the same SDR (SSE path) # Stop waterfall if it's using the same SDR (SSE path)
if waterfall_running and waterfall_active_device == device and waterfall_active_sdr_type == sdr_type: if waterfall_running and waterfall_active_device == device:
_stop_waterfall_internal() _stop_waterfall_internal()
time.sleep(0.2) time.sleep(0.2)
@@ -1326,9 +1322,8 @@ def start_audio() -> Response:
# to give the USB device time to be fully released. # to give the USB device time to be fully released.
if listening_active_device is None or listening_active_device != device: if listening_active_device is None or listening_active_device != device:
if listening_active_device is not None: if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(listening_active_device)
listening_active_device = None listening_active_device = None
listening_active_sdr_type = None
error = None error = None
max_claim_attempts = 6 max_claim_attempts = 6
@@ -1337,10 +1332,10 @@ def start_audio() -> Response:
# attempt — the WebSocket handler may not have finished # attempt — the WebSocket handler may not have finished
# cleanup yet. # cleanup yet.
device_status = app_module.get_sdr_device_status() device_status = app_module.get_sdr_device_status()
if device_status.get(device, {}).get(sdr_type) == 'waterfall': if device_status.get(device) == 'waterfall':
app_module.release_sdr_device(device, sdr_type) app_module.release_sdr_device(device)
error = app_module.claim_sdr_device(device, 'listening', sdr_type) error = app_module.claim_sdr_device(device, 'listening')
if not error: if not error:
break break
if attempt < max_claim_attempts - 1: if attempt < max_claim_attempts - 1:
@@ -1357,7 +1352,6 @@ def start_audio() -> Response:
'message': error 'message': error
}), 409 }), 409
listening_active_device = device listening_active_device = device
listening_active_sdr_type = sdr_type
_start_audio_stream(frequency, modulation) _start_audio_stream(frequency, modulation)
@@ -1377,12 +1371,11 @@ def start_audio() -> Response:
@listening_post_bp.route('/audio/stop', methods=['POST']) @listening_post_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response: def stop_audio() -> Response:
"""Stop audio.""" """Stop audio."""
global listening_active_device, listening_active_sdr_type global listening_active_device
_stop_audio_stream() _stop_audio_stream()
if listening_active_device is not None: if listening_active_device is not None:
app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(listening_active_device)
listening_active_device = None listening_active_device = None
listening_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -1469,13 +1462,30 @@ def stream_audio() -> Response:
if not proc or not proc.stdout: if not proc or not proc.stdout:
return return
try: try:
# First byte timeout to avoid hanging clients forever # Drain stale audio that accumulated in the pipe buffer
# between pipeline start and stream connection. Keep the
# first chunk (contains WAV header) and discard the rest
# so the browser starts close to real-time.
header_chunk = None
while True:
ready, _, _ = select.select([proc.stdout], [], [], 0)
if not ready:
break
chunk = proc.stdout.read(8192)
if not chunk:
break
if header_chunk is None:
header_chunk = chunk
if header_chunk:
yield header_chunk
# Stream real-time audio
first_chunk_deadline = time.time() + 3.0 first_chunk_deadline = time.time() + 3.0
while audio_running and proc.poll() is None: while audio_running and proc.poll() is None:
# Use select to avoid blocking forever # Use select to avoid blocking forever
ready, _, _ = select.select([proc.stdout], [], [], 2.0) ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready: if ready:
chunk = proc.stdout.read(4096) chunk = proc.stdout.read(8192)
if chunk: if chunk:
yield chunk yield chunk
else: else:
@@ -1562,7 +1572,6 @@ waterfall_running = False
waterfall_lock = threading.Lock() waterfall_lock = threading.Lock()
waterfall_queue: queue.Queue = queue.Queue(maxsize=200) waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
waterfall_active_device: Optional[int] = None waterfall_active_device: Optional[int] = None
waterfall_active_sdr_type: Optional[str] = None
waterfall_config = { waterfall_config = {
'start_freq': 88.0, 'start_freq': 88.0,
'end_freq': 108.0, 'end_freq': 108.0,
@@ -1739,7 +1748,7 @@ def _waterfall_loop():
def _stop_waterfall_internal() -> None: def _stop_waterfall_internal() -> None:
"""Stop the waterfall display and release resources.""" """Stop the waterfall display and release resources."""
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type global waterfall_running, waterfall_process, waterfall_active_device
waterfall_running = False waterfall_running = False
if waterfall_process and waterfall_process.poll() is None: if waterfall_process and waterfall_process.poll() is None:
@@ -1754,15 +1763,14 @@ def _stop_waterfall_internal() -> None:
waterfall_process = None waterfall_process = None
if waterfall_active_device is not None: if waterfall_active_device is not None:
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(waterfall_active_device)
waterfall_active_device = None waterfall_active_device = None
waterfall_active_sdr_type = None
@listening_post_bp.route('/waterfall/start', methods=['POST']) @listening_post_bp.route('/waterfall/start', methods=['POST'])
def start_waterfall() -> Response: def start_waterfall() -> Response:
"""Start the waterfall/spectrogram display.""" """Start the waterfall/spectrogram display."""
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device, waterfall_active_sdr_type global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
with waterfall_lock: with waterfall_lock:
if waterfall_running: if waterfall_running:
@@ -1803,12 +1811,11 @@ def start_waterfall() -> Response:
pass pass
# Claim SDR device # Claim SDR device
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr') error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall')
if error: if error:
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
waterfall_active_device = waterfall_config['device'] waterfall_active_device = waterfall_config['device']
waterfall_active_sdr_type = 'rtlsdr'
waterfall_running = True waterfall_running = True
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True) waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
waterfall_thread.start() waterfall_thread.start()
+16 -23
View File
@@ -34,7 +34,6 @@ pager_bp = Blueprint('pager', __name__)
# Track which device is being used # Track which device is being used
pager_active_device: int | None = None pager_active_device: int | None = None
pager_active_sdr_type: str | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None: def parse_multimon_output(line: str) -> dict[str, str] | None:
@@ -206,7 +205,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
except Exception as e: except Exception as e:
app_module.output_queue.put({'type': 'error', 'text': str(e)}) app_module.output_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global pager_active_device, pager_active_sdr_type global pager_active_device
try: try:
os.close(master_fd) os.close(master_fd)
except OSError: except OSError:
@@ -235,14 +234,13 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
app_module.current_process = None app_module.current_process = None
# Release SDR device # Release SDR device
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
@pager_bp.route('/start', methods=['POST']) @pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response: def start_decoding() -> Response:
global pager_active_device, pager_active_sdr_type global pager_active_device
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -267,13 +265,6 @@ def start_decoding() -> Response:
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400 return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection # Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234) rtl_tcp_port = data.get('rtl_tcp_port', 1234)
@@ -281,7 +272,7 @@ def start_decoding() -> Response:
# Claim local device if not using remote rtl_tcp # Claim local device if not using remote rtl_tcp
if not rtl_tcp_host: if not rtl_tcp_host:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type.value) error = app_module.claim_sdr_device(device_int, 'pager')
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -289,16 +280,14 @@ def start_decoding() -> Response:
'message': error 'message': error
}), 409 }), 409
pager_active_device = device_int pager_active_device = device_int
pager_active_sdr_type = sdr_type.value
# Validate protocols # Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'] valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols) protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list): if not isinstance(protocols, list):
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols] protocols = [p for p in protocols if p in valid_protocols]
if not protocols: if not protocols:
@@ -323,6 +312,13 @@ def start_decoding() -> Response:
elif proto == 'FLEX': elif proto == 'FLEX':
decoders.extend(['-a', 'FLEX']) decoders.extend(['-a', 'FLEX'])
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if rtl_tcp_host: if rtl_tcp_host:
# Validate and create network device # Validate and create network device
try: try:
@@ -432,9 +428,8 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e: except Exception as e:
# Kill orphaned rtl_fm process if it was started # Kill orphaned rtl_fm process if it was started
@@ -448,15 +443,14 @@ def start_decoding() -> Response:
pass pass
# Release device on failure # Release device on failure
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST']) @pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response: def stop_decoding() -> Response:
global pager_active_device, pager_active_sdr_type global pager_active_device
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -493,9 +487,8 @@ def stop_decoding() -> Response:
# Release device from registry # Release device from registry
if pager_active_device is not None: if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(pager_active_device)
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+1 -13
View File
@@ -170,15 +170,11 @@ def predict_passes():
norad_to_name = { norad_to_name = {
25544: 'ISS', 25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2', 40069: 'METEOR-M2',
57166: 'METEOR-M2-3' 57166: 'METEOR-M2-3'
} }
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19']) sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
satellites = [] satellites = []
for sat in sat_input: for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name: if isinstance(sat, int) and sat in norad_to_name:
@@ -189,10 +185,6 @@ def predict_passes():
passes = [] passes = []
colors = { colors = {
'ISS': '#00ffff', 'ISS': '#00ffff',
'NOAA-15': '#00ff00',
'NOAA-18': '#ff6600',
'NOAA-19': '#ff3366',
'NOAA-20': '#00ffaa',
'METEOR-M2': '#9370DB', 'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff' 'METEOR-M2-3': '#ff00ff'
} }
@@ -329,10 +321,6 @@ def get_satellite_position():
norad_to_name = { norad_to_name = {
25544: 'ISS', 25544: 'ISS',
25338: 'NOAA-15',
28654: 'NOAA-18',
33591: 'NOAA-19',
43013: 'NOAA-20',
40069: 'METEOR-M2', 40069: 'METEOR-M2',
57166: 'METEOR-M2-3' 57166: 'METEOR-M2-3'
} }
+15 -21
View File
@@ -27,7 +27,6 @@ sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used # Track which device is being used
sensor_active_device: int | None = None sensor_active_device: int | None = None
sensor_active_sdr_type: str | None = None
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
@@ -76,7 +75,7 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e: except Exception as e:
app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
finally: finally:
global sensor_active_device, sensor_active_sdr_type global sensor_active_device
# Ensure process is terminated # Ensure process is terminated
try: try:
process.terminate() process.terminate()
@@ -92,9 +91,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
app_module.sensor_process = None app_module.sensor_process = None
# Release SDR device # Release SDR device
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
@sensor_bp.route('/sensor/status') @sensor_bp.route('/sensor/status')
@@ -107,7 +105,7 @@ def sensor_status() -> Response:
@sensor_bp.route('/start_sensor', methods=['POST']) @sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response: def start_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type global sensor_active_device
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
@@ -124,13 +122,6 @@ def start_sensor() -> Response:
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection # Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234) rtl_tcp_port = data.get('rtl_tcp_port', 1234)
@@ -138,7 +129,7 @@ def start_sensor() -> Response:
# Claim local device if not using remote rtl_tcp # Claim local device if not using remote rtl_tcp
if not rtl_tcp_host: if not rtl_tcp_host:
device_int = int(device) device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type.value) error = app_module.claim_sdr_device(device_int, 'sensor')
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -146,7 +137,6 @@ def start_sensor() -> Response:
'message': error 'message': error
}), 409 }), 409
sensor_active_device = device_int sensor_active_device = device_int
sensor_active_sdr_type = sdr_type.value
# Clear queue # Clear queue
while not app_module.sensor_queue.empty(): while not app_module.sensor_queue.empty():
@@ -155,6 +145,13 @@ def start_sensor() -> Response:
except queue.Empty: except queue.Empty:
break break
# Get SDR type and build command via abstraction layer
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if rtl_tcp_host: if rtl_tcp_host:
# Validate and create network device # Validate and create network device
try: try:
@@ -220,22 +217,20 @@ def start_sensor() -> Response:
except FileNotFoundError: except FileNotFoundError:
# Release device on failure # Release device on failure
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e: except Exception as e:
# Release device on failure # Release device on failure
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'error', 'message': str(e)}) return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST']) @sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response: def stop_sensor() -> Response:
global sensor_active_device, sensor_active_sdr_type global sensor_active_device
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
@@ -248,9 +243,8 @@ def stop_sensor() -> Response:
# Release device from registry # Release device from registry
if sensor_active_device is not None: if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+424
View File
@@ -0,0 +1,424 @@
"""SubGHz transceiver routes.
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
signal replay/transmit, and wideband spectrum analysis.
"""
from __future__ import annotations
import queue
from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger
from utils.sse import sse_stream
from utils.subghz import get_subghz_manager
from utils.constants import (
SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_MHZ,
SUBGHZ_LNA_GAIN_MAX,
SUBGHZ_VGA_GAIN_MAX,
SUBGHZ_TX_VGA_GAIN_MAX,
SUBGHZ_TX_MAX_DURATION,
SUBGHZ_SAMPLE_RATES,
SUBGHZ_PRESETS,
)
logger = get_logger('intercept.subghz')
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
# SSE queue for streaming events to frontend
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
def _event_callback(event: dict) -> None:
"""Forward SubGhzManager events to the SSE queue."""
try:
_subghz_queue.put_nowait(event)
except queue.Full:
try:
_subghz_queue.get_nowait()
_subghz_queue.put_nowait(event)
except queue.Empty:
pass
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
raw = data.get(key)
if raw is None:
return None, f'{key} is required'
try:
freq_hz = int(raw)
freq_mhz = freq_hz / 1_000_000
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
return freq_hz, None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_serial(data: dict) -> str | None:
"""Extract and validate optional HackRF device serial."""
serial = data.get('device_serial', '')
if not serial or not isinstance(serial, str):
return None
# HackRF serials are hex strings
serial = serial.strip()
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
return serial
return None
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
"""Validate integer parameter with bounds clamping."""
try:
val = int(data.get(key, default))
return max(min_val, min(max_val, val))
except (ValueError, TypeError):
return default
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
profile = data.get('decode_profile', default)
if not isinstance(profile, str):
return default
profile = profile.strip().lower()
if profile in {'weather', 'all'}:
return profile
return default
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
raw = data.get(key)
if raw is None or raw == '':
return None, None
try:
return float(raw), None
except (ValueError, TypeError):
return None, f'Invalid {key}'
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
raw = data.get(key, default)
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
return default
# ------------------------------------------------------------------
# STATUS
# ------------------------------------------------------------------
@subghz_bp.route('/status')
def get_status():
manager = get_subghz_manager()
return jsonify(manager.get_status())
@subghz_bp.route('/presets')
def get_presets():
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
# ------------------------------------------------------------------
# RECEIVE
# ------------------------------------------------------------------
@subghz_bp.route('/receive/start', methods=['POST'])
def start_receive():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_receive(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
trigger_enabled=trigger_enabled,
trigger_pre_ms=trigger_pre_ms,
trigger_post_ms=trigger_post_ms,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/receive/stop', methods=['POST'])
def stop_receive():
manager = get_subghz_manager()
result = manager.stop_receive()
return jsonify(result)
# ------------------------------------------------------------------
# DECODE
# ------------------------------------------------------------------
@subghz_bp.route('/decode/start', methods=['POST'])
def start_decode():
data = request.get_json(silent=True) or {}
freq_hz, err = _validate_frequency_hz(data)
if err:
return jsonify({'status': 'error', 'message': err}), 400
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
decode_profile = _validate_decode_profile(data)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_decode(
frequency_hz=freq_hz,
sample_rate=sample_rate,
lna_gain=lna_gain,
vga_gain=vga_gain,
decode_profile=decode_profile,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/decode/stop', methods=['POST'])
def stop_decode():
manager = get_subghz_manager()
result = manager.stop_decode()
return jsonify(result)
# ------------------------------------------------------------------
# TRANSMIT
# ------------------------------------------------------------------
@subghz_bp.route('/transmit', methods=['POST'])
def start_transmit():
data = request.get_json(silent=True) or {}
capture_id = data.get('capture_id')
if not capture_id or not isinstance(capture_id, str):
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
# Sanitize capture_id
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.transmit(
capture_id=capture_id,
tx_gain=tx_gain,
max_duration=max_duration,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 400
return jsonify(result), status_code
@subghz_bp.route('/transmit/stop', methods=['POST'])
def stop_transmit():
manager = get_subghz_manager()
result = manager.stop_transmit()
return jsonify(result)
# ------------------------------------------------------------------
# SWEEP
# ------------------------------------------------------------------
@subghz_bp.route('/sweep/start', methods=['POST'])
def start_sweep():
data = request.get_json(silent=True) or {}
try:
freq_start = float(data.get('freq_start_mhz', 300))
freq_end = float(data.get('freq_end_mhz', 928))
if freq_start >= freq_end:
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
device_serial = _validate_serial(data)
manager = get_subghz_manager()
manager.set_callback(_event_callback)
result = manager.start_sweep(
freq_start_mhz=freq_start,
freq_end_mhz=freq_end,
bin_width=bin_width,
device_serial=device_serial,
)
status_code = 200 if result.get('status') != 'error' else 409
return jsonify(result), status_code
@subghz_bp.route('/sweep/stop', methods=['POST'])
def stop_sweep():
manager = get_subghz_manager()
result = manager.stop_sweep()
return jsonify(result)
# ------------------------------------------------------------------
# CAPTURES LIBRARY
# ------------------------------------------------------------------
@subghz_bp.route('/captures')
def list_captures():
manager = get_subghz_manager()
captures = manager.list_captures()
return jsonify({
'status': 'ok',
'captures': [c.to_dict() for c in captures],
'count': len(captures),
})
@subghz_bp.route('/captures/<capture_id>')
def get_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
capture = manager.get_capture(capture_id)
if not capture:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
@subghz_bp.route('/captures/<capture_id>/download')
def download_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
path = manager.get_capture_path(capture_id)
if not path:
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
return send_file(
path,
mimetype='application/octet-stream',
as_attachment=True,
download_name=path.name,
)
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
def trim_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
if start_err:
return jsonify({'status': 'error', 'message': start_err}), 400
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
if duration_err:
return jsonify({'status': 'error', 'message': duration_err}), 400
label = data.get('label', '')
if label is None:
label = ''
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
result = manager.trim_capture(
capture_id=capture_id,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
label=label,
)
if result.get('status') == 'ok':
return jsonify(result), 200
message = str(result.get('message') or 'Trim failed')
status_code = 404 if 'not found' in message.lower() else 400
return jsonify(result), status_code
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
def delete_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
manager = get_subghz_manager()
if manager.delete_capture(capture_id):
return jsonify({'status': 'deleted', 'id': capture_id})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
def update_capture(capture_id: str):
if not capture_id.isalnum():
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
data = request.get_json(silent=True) or {}
label = data.get('label', '')
if not isinstance(label, str) or len(label) > 100:
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
manager = get_subghz_manager()
if manager.update_capture_label(capture_id, label):
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
# ------------------------------------------------------------------
# SSE STREAM
# ------------------------------------------------------------------
@subghz_bp.route('/stream')
def stream():
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+6 -13
View File
@@ -87,7 +87,6 @@ def init_waterfall_websocket(app: Flask):
reader_thread = None reader_thread = None
stop_event = threading.Event() stop_event = threading.Event()
claimed_device = None claimed_device = None
claimed_sdr_type = None
# Queue for outgoing messages — only the main loop touches ws.send() # Queue for outgoing messages — only the main loop touches ws.send()
send_queue = queue.Queue(maxsize=120) send_queue = queue.Queue(maxsize=120)
@@ -142,9 +141,8 @@ def init_waterfall_websocket(app: Flask):
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr') app_module.release_sdr_device(claimed_device)
claimed_device = None claimed_device = None
claimed_sdr_type = None
stop_event.clear() stop_event.clear()
# Flush stale frames from previous capture # Flush stale frames from previous capture
while not send_queue.empty(): while not send_queue.empty():
@@ -187,7 +185,7 @@ def init_waterfall_websocket(app: Flask):
end_freq = center_freq + effective_span_mhz / 2 end_freq = center_freq + effective_span_mhz / 2
# Claim the device # Claim the device
claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type.value) claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
if claim_err: if claim_err:
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
@@ -196,7 +194,6 @@ def init_waterfall_websocket(app: Flask):
})) }))
continue continue
claimed_device = device_index claimed_device = device_index
claimed_sdr_type = sdr_type.value
# Build I/Q capture command # Build I/Q capture command
try: try:
@@ -211,9 +208,8 @@ def init_waterfall_websocket(app: Flask):
bias_t=bias_t, bias_t=bias_t,
) )
except NotImplementedError as e: except NotImplementedError as e:
app_module.release_sdr_device(device_index, sdr_type.value) app_module.release_sdr_device(device_index)
claimed_device = None claimed_device = None
claimed_sdr_type = None
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
'message': str(e), 'message': str(e),
@@ -259,9 +255,8 @@ def init_waterfall_websocket(app: Flask):
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
app_module.release_sdr_device(device_index, sdr_type.value) app_module.release_sdr_device(device_index)
claimed_device = None claimed_device = None
claimed_sdr_type = None
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
'message': f'Failed to start I/Q capture: {e}', 'message': f'Failed to start I/Q capture: {e}',
@@ -355,9 +350,8 @@ def init_waterfall_websocket(app: Flask):
unregister_process(iq_process) unregister_process(iq_process)
iq_process = None iq_process = None
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr') app_module.release_sdr_device(claimed_device)
claimed_device = None claimed_device = None
claimed_sdr_type = None
stop_event.clear() stop_event.clear()
ws.send(json.dumps({'status': 'stopped'})) ws.send(json.dumps({'status': 'stopped'}))
@@ -372,8 +366,7 @@ def init_waterfall_websocket(app: Flask):
safe_terminate(iq_process) safe_terminate(iq_process)
unregister_process(iq_process) unregister_process(iq_process)
if claimed_device is not None: if claimed_device is not None:
app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr') app_module.release_sdr_device(claimed_device)
claimed_sdr_type = None
# Complete WebSocket close handshake, then shut down the # Complete WebSocket close handshake, then shut down the
# raw socket so Werkzeug cannot write its HTTP 200 response # raw socket so Werkzeug cannot write its HTTP 200 response
# on top of the WebSocket stream (which browsers see as # on top of the WebSocket stream (which browsers see as
+8
View File
@@ -214,6 +214,8 @@ check_tools() {
check_required "multimon-ng" "Pager decoder" multimon-ng check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433 check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
check_optional "hackrf_transfer" "HackRF SubGHz transceiver" hackrf_transfer
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
check_required "dump1090" "ADS-B decoder" dump1090 check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
@@ -746,6 +748,9 @@ install_macos_packages() {
progress "Installing rtl_433" progress "Installing rtl_433"
brew_install rtl_433 brew_install rtl_433
progress "Installing HackRF tools"
brew_install hackrf
progress "Installing rtlamr (optional)" progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring # rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then if ! cmd_exists rtlamr; then
@@ -1169,6 +1174,9 @@ install_debian_packages() {
progress "Installing rtl_433" progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available" apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing HackRF tools"
apt_install hackrf || warn "hackrf tools not available"
progress "Installing rtlamr (optional)" progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring # rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then if ! cmd_exists rtlamr; then
+115 -3
View File
@@ -1448,6 +1448,7 @@ header h1 .tagline {
height: calc(100dvh - 96px); height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */ height: calc(100vh - 96px); /* Fallback */
overflow: hidden; overflow: hidden;
position: relative;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@@ -1457,6 +1458,18 @@ header h1 .tagline {
height: calc(100dvh - 96px); height: calc(100dvh - 96px);
height: calc(100vh - 96px); /* Fallback */ height: calc(100vh - 96px); /* Fallback */
} }
.main-content.sidebar-collapsed {
grid-template-columns: 0 1fr;
}
.main-content.sidebar-collapsed > .sidebar {
width: 0;
min-width: 0;
padding: 0;
border-right: 0;
overflow: hidden;
}
} }
.sidebar { .sidebar {
@@ -1480,6 +1493,63 @@ header h1 .tagline {
display: none; display: none;
} }
.sidebar-collapse-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sidebar-collapse-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.sidebar-expand-handle {
display: none;
position: absolute;
top: 12px;
left: 10px;
z-index: 12;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--accent-cyan);
border-radius: 6px;
cursor: pointer;
}
.main-content.sidebar-collapsed .sidebar-expand-handle {
display: inline-flex;
}
/* Reserve space for the expand handle so it doesn't overlap mode titles */
.main-content.sidebar-collapsed .output-header {
padding-left: 48px;
}
@media (max-width: 1023px) {
.sidebar-collapse-btn,
.sidebar-expand-handle {
display: none !important;
}
}
.section { .section {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -1528,8 +1598,10 @@ header h1 .tagline {
.section.collapsed h3 { .section.collapsed h3 {
border-bottom: none; border-bottom: none;
margin-bottom: 0; margin-bottom: 0 !important;
padding-bottom: 10px; min-height: 0 !important;
padding-top: 10px !important;
padding-bottom: 10px !important;
} }
.section.collapsed h3::after { .section.collapsed h3::after {
@@ -1538,7 +1610,8 @@ header h1 .tagline {
} }
.section.collapsed { .section.collapsed {
padding-bottom: 0; padding-bottom: 0 !important;
min-height: 0;
} }
.section.collapsed>*:not(h3) { .section.collapsed>*:not(h3) {
@@ -2313,6 +2386,45 @@ header h1 .tagline {
display: block; display: block;
} }
/* Normalize spacing for all sidebar mode panels */
.sidebar .mode-content.active:not(#meshtasticMode) {
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > * {
margin: 0 !important;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .section {
margin: 0 !important;
}
.mode-actions-bottom {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar .mode-content.active:not(#meshtasticMode) > .mode-actions-bottom {
margin-top: auto !important;
}
#btMessageContainer:empty {
display: none;
}
.alpha-mode-notice {
padding: 8px 10px;
border: 1px solid rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.12);
color: var(--accent-yellow);
border-radius: 6px;
font-size: 10px;
line-height: 1.45;
}
/* Aircraft (ADS-B) Styles */ /* Aircraft (ADS-B) Styles */
.aircraft-card { .aircraft-card {
padding: 12px; padding: 12px;
+47
View File
@@ -326,3 +326,50 @@
.aprs-meter-status.no-signal { .aprs-meter-status.no-signal {
color: var(--accent-yellow); color: var(--accent-yellow);
} }
/* APRS map markers (flat SVG icons) */
.aprs-map-marker-wrap {
background: transparent;
border: none;
}
.aprs-map-marker {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 7px 2px 5px;
border-radius: 999px;
border: 1px solid rgba(74, 158, 255, 0.35);
background: rgba(10, 18, 28, 0.88);
color: var(--text-primary);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.aprs-map-marker-icon {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--accent-cyan);
}
.aprs-map-marker-icon svg {
width: 14px;
height: 14px;
display: block;
fill: currentColor;
}
.aprs-map-marker-label {
font-size: 9px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
}
.aprs-map-marker.vehicle .aprs-map-marker-icon {
color: var(--accent-green);
}
.aprs-map-marker.tower .aprs-map-marker-icon {
color: var(--accent-cyan);
}
+3 -1
View File
@@ -340,7 +340,9 @@
MODE VISIBILITY - Ensure sidebar shows when active MODE VISIBILITY - Ensure sidebar shows when active
============================================ */ ============================================ */
#spystationsMode.active { #spystationsMode.active {
display: block !important; display: flex !important;
flex-direction: column;
gap: 10px;
} }
/* ============================================ /* ============================================
+3 -1
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY MODE VISIBILITY
============================================ */ ============================================ */
#sstvGeneralMode.active { #sstvGeneralMode.active {
display: block !important; display: flex !important;
flex-direction: column;
gap: 10px;
} }
/* ============================================ /* ============================================
+3 -1
View File
@@ -7,7 +7,9 @@
MODE VISIBILITY MODE VISIBILITY
============================================ */ ============================================ */
#sstvMode.active { #sstvMode.active {
display: block !important; display: flex !important;
flex-direction: column;
gap: 10px;
} }
/* ============================================ /* ============================================
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -114,7 +114,7 @@
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: min(320px, 85vw); width: min(360px, 100vw);
height: 100dvh; height: 100dvh;
height: 100vh; /* Fallback */ height: 100vh; /* Fallback */
background: var(--bg-secondary, #0f1218); background: var(--bg-secondary, #0f1218);
@@ -381,6 +381,33 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.sidebar {
padding: 10px;
gap: 10px;
}
.output-panel {
min-height: 58vh;
}
.output-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-controls {
width: 100%;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.header-controls .stats {
min-width: max-content;
}
/* Container should not clip content */ /* Container should not clip content */
.container { .container {
overflow: visible; overflow: visible;
+28 -4
View File
@@ -24,7 +24,7 @@
background: var(--bg-dark, #0a0a0f); background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e); border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px; border-radius: 8px;
max-width: 600px; max-width: 900px;
width: 100%; width: 100%;
max-height: calc(100vh - 80px); max-height: calc(100vh - 80px);
display: flex; display: flex;
@@ -74,22 +74,28 @@
/* Settings Tabs */ /* Settings Tabs */
.settings-tabs { .settings-tabs {
display: flex; display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
border-bottom: 1px solid var(--border-color, #1a1a2e); border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px; padding: 0 20px;
gap: 4px; gap: 0;
} }
.settings-tab { .settings-tab {
background: none; background: none;
border: none; border: none;
padding: 12px 16px; padding: 12px 10px;
color: var(--text-muted, #666); color: var(--text-muted, #666);
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transition: color 0.2s; transition: color 0.2s;
min-width: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.settings-tab:hover { .settings-tab:hover {
@@ -474,6 +480,12 @@
} }
/* Responsive */ /* Responsive */
@media (max-width: 960px) {
.settings-tabs {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.settings-modal.active { .settings-modal.active {
padding: 20px 10px; padding: 20px 10px;
@@ -483,6 +495,18 @@
max-width: 100%; max-width: 100%;
} }
.settings-tabs {
padding: 0 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-tab {
padding: 10px 6px;
font-size: 11px;
white-space: normal;
line-height: 1.2;
}
.settings-row { .settings-row {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
+5 -3
View File
@@ -488,10 +488,12 @@ function initApp() {
}); });
}); });
// Collapse all sections by default (except SDR Device which is first) // Collapse sidebar menu sections by default, but skip headerless utility blocks.
document.querySelectorAll('.section').forEach((section, index) => { document.querySelectorAll('.sidebar .section').forEach((section) => {
if (index > 0) { if (section.querySelector('h3')) {
section.classList.add('collapsed'); section.classList.add('collapsed');
} else {
section.classList.remove('collapsed');
} }
}); });
+97 -145
View File
@@ -12,14 +12,12 @@ let dmrCallHistory = [];
let dmrCurrentProtocol = '--'; let dmrCurrentProtocol = '--';
let dmrModeLabel = 'dmr'; // Protocol label for device reservation let dmrModeLabel = 'dmr'; // Protocol label for device reservation
let dmrHasAudio = false; let dmrHasAudio = false;
let dmrQualitySamples = [];
let dmrQualityScore = null;
let dmrSweepInProgress = false;
// ============== BOOKMARKS ============== // ============== BOOKMARKS ==============
let dmrBookmarks = []; let dmrBookmarks = [];
const DMR_BOOKMARKS_KEY = 'dmrBookmarks'; const DMR_BOOKMARKS_KEY = 'dmrBookmarks';
const DMR_SETTINGS_KEY = 'dmrSettings'; const DMR_SETTINGS_KEY = 'dmrSettings';
const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']);
// ============== SYNTHESIZER STATE ============== // ============== SYNTHESIZER STATE ==============
let dmrSynthCanvas = null; let dmrSynthCanvas = null;
@@ -47,9 +45,16 @@ function checkDmrTools() {
const warningText = document.getElementById('dmrToolsWarningText'); const warningText = document.getElementById('dmrToolsWarningText');
if (!warning) return; if (!warning) return;
const selectedType = (typeof getSelectedSDRType === 'function')
? getSelectedSDRType()
: 'rtlsdr';
const missing = []; const missing = [];
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)'); if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)'); if (selectedType === 'rtlsdr') {
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
} else if (!data.rx_fm) {
missing.push('rx_fm (SoapySDR demodulator)');
}
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)'); if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
if (missing.length > 0) { if (missing.length > 0) {
@@ -72,10 +77,9 @@ function startDmr() {
const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
const gain = parseInt(document.getElementById('dmrGain')?.value || 40); const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0); const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
const fineTune = parseInt(document.getElementById('dmrFineTune')?.value || 0);
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false; const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
const demod = document.getElementById('dmrDemod')?.value || 'nfm';
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc. // Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr'; dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
@@ -88,22 +92,19 @@ function startDmr() {
// Save settings to localStorage for persistence // Save settings to localStorage for persistence
try { try {
localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({ localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({
frequency, protocol, gain, ppm, fineTune, relaxCrc, demod frequency, protocol, gain, ppm, relaxCrc
})); }));
} catch (e) { /* localStorage unavailable */ } } catch (e) { /* localStorage unavailable */ }
return fetch('/dmr/start', { fetch('/dmr/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, protocol, gain, device, ppm, fineTune, relaxCrc, demod }) body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.status === 'started') { if (data.status === 'started') {
isDmrRunning = true; isDmrRunning = true;
dmrQualitySamples = [];
dmrQualityScore = null;
updateDmrQualityUI();
dmrCallCount = 0; dmrCallCount = 0;
dmrSyncCount = 0; dmrSyncCount = 0;
dmrCallHistory = []; dmrCallHistory = [];
@@ -152,14 +153,11 @@ function startDmr() {
function stopDmr() { function stopDmr() {
stopDmrAudio(); stopDmrAudio();
return fetch('/dmr/stop', { method: 'POST' }) fetch('/dmr/stop', { method: 'POST' })
.then(r => r.json()) .then(r => r.json())
.then(() => { .then(() => {
isDmrRunning = false; isDmrRunning = false;
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
dmrQualitySamples = [];
dmrQualityScore = null;
updateDmrQualityUI();
updateDmrUI(); updateDmrUI();
dmrEventType = 'stopped'; dmrEventType = 'stopped';
dmrActivityTarget = 0; dmrActivityTarget = 0;
@@ -205,7 +203,6 @@ function handleDmrMessage(msg) {
const syncCountEl = document.getElementById('dmrSyncCount'); const syncCountEl = document.getElementById('dmrSyncCount');
if (syncCountEl) syncCountEl.textContent = dmrSyncCount; if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
} else if (msg.type === 'call') { } else if (msg.type === 'call') {
recordDmrQuality(true);
dmrCallCount++; dmrCallCount++;
const countEl = document.getElementById('dmrCallCount'); const countEl = document.getElementById('dmrCallCount');
if (countEl) countEl.textContent = dmrCallCount; if (countEl) countEl.textContent = dmrCallCount;
@@ -248,14 +245,8 @@ function handleDmrMessage(msg) {
} else if (msg.type === 'slot') { } else if (msg.type === 'slot') {
// Update slot info in current call // Update slot info in current call
} else if (msg.type === 'frame_ok') {
recordDmrQuality(true);
} else if (msg.type === 'frame_error') {
recordDmrQuality(false);
} else if (msg.type === 'raw') { } else if (msg.type === 'raw') {
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse // Raw DSD output — triggers synthesizer activity via dmrSynthPulse
} else if (msg.type === 'voice') {
recordDmrQuality(true);
} else if (msg.type === 'heartbeat') { } else if (msg.type === 'heartbeat') {
// Decoder is alive and listening — keep synthesizer in listening state // Decoder is alive and listening — keep synthesizer in listening state
if (isDmrRunning && dmrSynthInitialized) { if (isDmrRunning && dmrSynthInitialized) {
@@ -298,111 +289,6 @@ function handleDmrMessage(msg) {
} }
} }
// ============== QUALITY METER ==============
function recordDmrQuality(ok) {
dmrQualitySamples.push(!!ok);
if (dmrQualitySamples.length > 200) dmrQualitySamples.shift();
const total = dmrQualitySamples.length;
if (total < 5) {
dmrQualityScore = null;
updateDmrQualityUI();
return;
}
const errors = dmrQualitySamples.reduce((sum, v) => sum + (v ? 0 : 1), 0);
dmrQualityScore = Math.max(0, Math.min(100, Math.round(100 * (1 - (errors / total)))));
updateDmrQualityUI();
}
function updateDmrQualityUI() {
const textEl = document.getElementById('dmrQualityText');
const barEl = document.getElementById('dmrQualityBar');
if (!textEl || !barEl) return;
if (dmrQualityScore == null) {
textEl.textContent = '--';
barEl.style.width = '0%';
barEl.style.background = 'var(--text-muted)';
return;
}
textEl.textContent = `${dmrQualityScore}%`;
barEl.style.width = `${dmrQualityScore}%`;
if (dmrQualityScore >= 80) {
barEl.style.background = 'var(--accent-green)';
} else if (dmrQualityScore >= 50) {
barEl.style.background = 'var(--accent-amber, #f59e0b)';
} else {
barEl.style.background = 'var(--accent-red)';
}
}
// ============== FINE TUNE SWEEP ==============
async function sweepDmrFineTune() {
if (!isDmrRunning) {
if (typeof showNotification === 'function') {
showNotification('Digital Voice', 'Start the decoder before sweeping fine tune.');
}
return;
}
if (dmrSweepInProgress) return;
dmrSweepInProgress = true;
const freqEl = document.getElementById('dmrFrequency');
const protoEl = document.getElementById('dmrProtocol');
const gainEl = document.getElementById('dmrGain');
const ppmEl = document.getElementById('dmrPPM');
const fineEl = document.getElementById('dmrFineTune');
const crcEl = document.getElementById('dmrRelaxCrc');
const demodEl = document.getElementById('dmrDemod');
const sweepBtn = document.getElementById('dmrFineTuneSweepBtn');
const original = {
frequency: freqEl?.value,
protocol: protoEl?.value,
gain: gainEl?.value,
ppm: ppmEl?.value,
fineTune: fineEl?.value,
relaxCrc: crcEl?.checked,
demod: demodEl?.value,
};
if (sweepBtn) {
sweepBtn.disabled = true;
sweepBtn.textContent = 'Sweeping...';
}
const offsets = [-2000, -1500, -1000, -500, 0, 500, 1000, 1500, 2000];
let best = { offset: parseInt(original.fineTune || 0, 10) || 0, score: -1 };
for (const offset of offsets) {
if (fineEl) fineEl.value = offset;
await stopDmr();
await new Promise(r => setTimeout(r, 300));
await startDmr();
dmrQualitySamples = [];
dmrQualityScore = null;
updateDmrQualityUI();
await new Promise(r => setTimeout(r, 700));
await new Promise(r => setTimeout(r, 2500));
const score = dmrQualityScore == null ? 0 : dmrQualityScore;
if (score > best.score) best = { offset, score };
}
if (fineEl) fineEl.value = best.offset;
await stopDmr();
await new Promise(r => setTimeout(r, 300));
await startDmr();
if (sweepBtn) {
sweepBtn.disabled = false;
sweepBtn.textContent = 'Sweep Fine Tune';
}
dmrSweepInProgress = false;
if (typeof showNotification === 'function') {
showNotification('Digital Voice', `Sweep complete: best offset ${best.offset} Hz (${best.score}%)`);
}
}
// ============== UI ============== // ============== UI ==============
function updateDmrUI() { function updateDmrUI() {
@@ -739,16 +625,12 @@ function restoreDmrSettings() {
const protoEl = document.getElementById('dmrProtocol'); const protoEl = document.getElementById('dmrProtocol');
const gainEl = document.getElementById('dmrGain'); const gainEl = document.getElementById('dmrGain');
const ppmEl = document.getElementById('dmrPPM'); const ppmEl = document.getElementById('dmrPPM');
const fineTuneEl = document.getElementById('dmrFineTune');
const crcEl = document.getElementById('dmrRelaxCrc'); const crcEl = document.getElementById('dmrRelaxCrc');
const demodEl = document.getElementById('dmrDemod');
if (freqEl && s.frequency != null) freqEl.value = s.frequency; if (freqEl && s.frequency != null) freqEl.value = s.frequency;
if (protoEl && s.protocol) protoEl.value = s.protocol; if (protoEl && s.protocol) protoEl.value = s.protocol;
if (gainEl && s.gain != null) gainEl.value = s.gain; if (gainEl && s.gain != null) gainEl.value = s.gain;
if (ppmEl && s.ppm != null) ppmEl.value = s.ppm; if (ppmEl && s.ppm != null) ppmEl.value = s.ppm;
if (fineTuneEl && s.fineTune != null) fineTuneEl.value = s.fineTune;
if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc; if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc;
if (demodEl && s.demod) demodEl.value = s.demod;
} catch (e) { /* localStorage unavailable */ } } catch (e) { /* localStorage unavailable */ }
} }
@@ -757,7 +639,26 @@ function restoreDmrSettings() {
function loadDmrBookmarks() { function loadDmrBookmarks() {
try { try {
const saved = localStorage.getItem(DMR_BOOKMARKS_KEY); const saved = localStorage.getItem(DMR_BOOKMARKS_KEY);
dmrBookmarks = saved ? JSON.parse(saved) : []; const parsed = saved ? JSON.parse(saved) : [];
if (!Array.isArray(parsed)) {
dmrBookmarks = [];
} else {
dmrBookmarks = parsed
.map((entry) => {
const freq = Number(entry?.freq);
if (!Number.isFinite(freq) || freq <= 0) return null;
const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol);
const rawLabel = String(entry?.label || '').trim();
const label = rawLabel || `${freq.toFixed(4)} MHz`;
return {
freq,
protocol,
label,
added: entry?.added,
};
})
.filter(Boolean);
}
} catch (e) { } catch (e) {
dmrBookmarks = []; dmrBookmarks = [];
} }
@@ -770,6 +671,11 @@ function saveDmrBookmarks() {
} catch (e) { /* localStorage unavailable */ } } catch (e) { /* localStorage unavailable */ }
} }
function sanitizeDmrBookmarkProtocol(protocol) {
const value = String(protocol || 'auto').toLowerCase();
return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto';
}
function addDmrBookmark() { function addDmrBookmark() {
const freqInput = document.getElementById('dmrBookmarkFreq'); const freqInput = document.getElementById('dmrBookmarkFreq');
const labelInput = document.getElementById('dmrBookmarkLabel'); const labelInput = document.getElementById('dmrBookmarkLabel');
@@ -783,7 +689,7 @@ function addDmrBookmark() {
return; return;
} }
const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto');
const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`; const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`;
// Duplicate check // Duplicate check
@@ -823,26 +729,73 @@ function removeDmrBookmark(index) {
function dmrQuickTune(freq, protocol) { function dmrQuickTune(freq, protocol) {
const freqEl = document.getElementById('dmrFrequency'); const freqEl = document.getElementById('dmrFrequency');
const protoEl = document.getElementById('dmrProtocol'); const protoEl = document.getElementById('dmrProtocol');
if (freqEl) freqEl.value = freq; if (freqEl && Number.isFinite(freq)) freqEl.value = freq;
if (protoEl) protoEl.value = protocol; if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol);
} }
function renderDmrBookmarks() { function renderDmrBookmarks() {
const container = document.getElementById('dmrBookmarksList'); const container = document.getElementById('dmrBookmarksList');
if (!container) return; if (!container) return;
container.replaceChildren();
if (dmrBookmarks.length === 0) { if (dmrBookmarks.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>'; const emptyEl = document.createElement('div');
emptyEl.style.color = 'var(--text-muted)';
emptyEl.style.textAlign = 'center';
emptyEl.style.padding = '10px';
emptyEl.style.fontSize = '11px';
emptyEl.textContent = 'No bookmarks saved';
container.appendChild(emptyEl);
return; return;
} }
container.innerHTML = dmrBookmarks.map((b, i) => ` dmrBookmarks.forEach((b, i) => {
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 3px; margin-bottom: 3px;"> const row = document.createElement('div');
<span style="cursor: pointer; color: var(--accent-cyan); font-size: 11px; flex: 1;" onclick="dmrQuickTune(${b.freq}, '${b.protocol}')" title="${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})">${b.label}</span> row.style.display = 'flex';
<span style="color: var(--text-muted); font-size: 9px; margin: 0 6px;">${b.protocol.toUpperCase()}</span> row.style.justifyContent = 'space-between';
<button onclick="removeDmrBookmark(${i})" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 12px; padding: 0 4px;">&times;</button> row.style.alignItems = 'center';
</div> row.style.padding = '4px 6px';
`).join(''); row.style.background = 'rgba(0,0,0,0.2)';
row.style.borderRadius = '3px';
row.style.marginBottom = '3px';
const tuneBtn = document.createElement('button');
tuneBtn.type = 'button';
tuneBtn.style.cursor = 'pointer';
tuneBtn.style.color = 'var(--accent-cyan)';
tuneBtn.style.fontSize = '11px';
tuneBtn.style.flex = '1';
tuneBtn.style.background = 'none';
tuneBtn.style.border = 'none';
tuneBtn.style.textAlign = 'left';
tuneBtn.style.padding = '0';
tuneBtn.textContent = b.label;
tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`;
tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol));
const protocolEl = document.createElement('span');
protocolEl.style.color = 'var(--text-muted)';
protocolEl.style.fontSize = '9px';
protocolEl.style.margin = '0 6px';
protocolEl.textContent = b.protocol.toUpperCase();
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.style.background = 'none';
deleteBtn.style.border = 'none';
deleteBtn.style.color = 'var(--accent-red)';
deleteBtn.style.cursor = 'pointer';
deleteBtn.style.fontSize = '12px';
deleteBtn.style.padding = '0 4px';
deleteBtn.textContent = '\u00d7';
deleteBtn.addEventListener('click', () => removeDmrBookmark(i));
row.appendChild(tuneBtn);
row.appendChild(protocolEl);
row.appendChild(deleteBtn);
container.appendChild(row);
});
} }
// ============== STATUS SYNC ============== // ============== STATUS SYNC ==============
@@ -897,4 +850,3 @@ window.addDmrBookmark = addDmrBookmark;
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark; window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
window.removeDmrBookmark = removeDmrBookmark; window.removeDmrBookmark = removeDmrBookmark;
window.dmrQuickTune = dmrQuickTune; window.dmrQuickTune = dmrQuickTune;
window.sweepDmrFineTune = sweepDmrFineTune;
+6 -30
View File
@@ -1742,9 +1742,6 @@ function initSynthesizer() {
drawSynthesizer(); drawSynthesizer();
} }
// Debug: log signal level periodically
let lastSynthDebugLog = 0;
function drawSynthesizer() { function drawSynthesizer() {
if (!synthCtx || !synthCanvas) return; if (!synthCtx || !synthCanvas) return;
@@ -1760,19 +1757,6 @@ function drawSynthesizer() {
let activityLevel = 0; let activityLevel = 0;
let signalIntensity = 0; let signalIntensity = 0;
// Debug logging every 2 seconds
const now = Date.now();
if (now - lastSynthDebugLog > 2000) {
console.log('[SYNTH] State:', {
isScannerRunning,
isDirectListening,
scannerSignalActive,
currentSignalLevel,
visualizerAnalyser: !!visualizerAnalyser
});
lastSynthDebugLog = now;
}
if (isScannerRunning && !isScannerPaused) { if (isScannerRunning && !isScannerPaused) {
// Use actual signal level data (0-5000 range, normalize to 0-1) // Use actual signal level data (0-5000 range, normalize to 0-1)
signalIntensity = Math.min(1, currentSignalLevel / 3000); signalIntensity = Math.min(1, currentSignalLevel / 3000);
@@ -1864,13 +1848,6 @@ function drawSynthesizer() {
synthCtx.lineTo(width, height / 2); synthCtx.lineTo(width, height / 2);
synthCtx.stroke(); synthCtx.stroke();
// Debug: show signal level value
if (isScannerRunning || isDirectListening) {
synthCtx.fillStyle = 'rgba(255, 255, 255, 0.5)';
synthCtx.font = '9px monospace';
synthCtx.fillText(`lvl:${Math.round(currentSignalLevel)}`, 4, 10);
}
synthAnimationId = requestAnimationFrame(drawSynthesizer); synthAnimationId = requestAnimationFrame(drawSynthesizer);
} }
@@ -3109,7 +3086,7 @@ let waterfallEndFreq = 108;
let waterfallRowImage = null; let waterfallRowImage = null;
let waterfallPalette = null; let waterfallPalette = null;
let lastWaterfallDraw = 0; let lastWaterfallDraw = 0;
const WATERFALL_MIN_INTERVAL_MS = 50; const WATERFALL_MIN_INTERVAL_MS = 200;
let waterfallInteractionBound = false; let waterfallInteractionBound = false;
let waterfallResizeObserver = null; let waterfallResizeObserver = null;
let waterfallMode = 'rf'; let waterfallMode = 'rf';
@@ -3296,7 +3273,7 @@ async function syncWaterfallToFrequency(freq, options = {}) {
span_mhz: Math.max(0.1, ef - sf), span_mhz: Math.max(0.1, ef - sf),
gain: g, gain: g,
device: dev, device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr', sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fft, fft_size: fft,
fps: 25, fps: 25,
avg_count: 4, avg_count: 4,
@@ -3341,7 +3318,7 @@ async function zoomWaterfall(direction) {
span_mhz: Math.max(0.1, ef - sf), span_mhz: Math.max(0.1, ef - sf),
gain: g, gain: g,
device: dev, device: dev,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr', sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fft, fft_size: fft,
fps: 25, fps: 25,
avg_count: 4, avg_count: 4,
@@ -3436,9 +3413,8 @@ function startAudioWaterfall() {
if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) { if (ts - lastAudioWaterfallDraw >= WATERFALL_MIN_INTERVAL_MS) {
lastAudioWaterfallDraw = ts; lastAudioWaterfallDraw = ts;
visualizerAnalyser.getByteFrequencyData(dataArray); visualizerAnalyser.getByteFrequencyData(dataArray);
const bins = Array.from(dataArray, v => v); drawWaterfallRow(dataArray);
drawWaterfallRow(bins); drawSpectrumLine(dataArray, 0, maxFreqKhz, 'kHz');
drawSpectrumLine(bins, 0, maxFreqKhz, 'kHz');
} }
audioWaterfallAnimId = requestAnimationFrame(drawFrame); audioWaterfallAnimId = requestAnimationFrame(drawFrame);
}; };
@@ -3841,7 +3817,7 @@ async function startWaterfall(options = {}) {
span_mhz: spanMhz, span_mhz: spanMhz,
gain: gain, gain: gain,
device: device, device: device,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr', sdr_type: (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr',
fft_size: fftSize, fft_size: fftSize,
fps: 25, fps: 25,
avg_count: 4, avg_count: 4,
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -160,7 +160,7 @@ const WeatherSat = (function() {
const biasTInput = document.getElementById('weatherSatBiasT'); const biasTInput = document.getElementById('weatherSatBiasT');
const deviceSelect = document.getElementById('deviceSelect'); const deviceSelect = document.getElementById('deviceSelect');
const satellite = satSelect?.value || 'NOAA-18'; const satellite = satSelect?.value || 'METEOR-M2-3';
const gain = parseFloat(gainInput?.value || '40'); const gain = parseFloat(gainInput?.value || '40');
const biasT = biasTInput?.checked || false; const biasT = biasTInput?.checked || false;
const device = parseInt(deviceSelect?.value || '0', 10); const device = parseInt(deviceSelect?.value || '0', 10);
@@ -237,7 +237,7 @@ const WeatherSat = (function() {
const fileInput = document.getElementById('wxsatTestFilePath'); const fileInput = document.getElementById('wxsatTestFilePath');
const rateSelect = document.getElementById('wxsatTestSampleRate'); const rateSelect = document.getElementById('wxsatTestSampleRate');
const satellite = satSelect?.value || 'NOAA-18'; const satellite = satSelect?.value || 'METEOR-M2-3';
const inputFile = (fileInput?.value || '').trim(); const inputFile = (fileInput?.value || '').trim();
const sampleRate = parseInt(rateSelect?.value || '1000000', 10); const sampleRate = parseInt(rateSelect?.value || '1000000', 10);
-2
View File
@@ -2537,7 +2537,6 @@ sudo make install</code>
// Check for remote dump1090 config (only for local mode) // Check for remote dump1090 config (only for local mode)
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null; const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
if (remoteConfig === false) return; if (remoteConfig === false) return;
// Check for agent SDR conflicts // Check for agent SDR conflicts
if (useAgent && typeof checkAgentModeConflict === 'function') { if (useAgent && typeof checkAgentModeConflict === 'function') {
if (!checkAgentModeConflict('adsb')) { if (!checkAgentModeConflict('adsb')) {
@@ -2556,7 +2555,6 @@ sudo make install</code>
requestBody.remote_sbs_host = remoteConfig.host; requestBody.remote_sbs_host = remoteConfig.host;
requestBody.remote_sbs_port = remoteConfig.port; requestBody.remote_sbs_port = remoteConfig.port;
} }
try { try {
// Route through agent proxy if using remote agent // Route through agent proxy if using remote agent
const url = useAgent const url = useAgent
+26 -51
View File
@@ -449,10 +449,7 @@
devices.forEach((d, i) => { devices.forEach((d, i) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.index; opt.value = d.index;
const sdrType = (d.sdr_type || d.type || 'rtlsdr').toLowerCase(); opt.textContent = `SDR ${d.index}: ${d.name}`;
const sdrLabel = sdrType.toUpperCase();
opt.dataset.sdrType = sdrType;
opt.textContent = `SDR ${d.index} (${sdrLabel}): ${d.name}`;
aisSelect.appendChild(opt); aisSelect.appendChild(opt);
}); });
} }
@@ -460,23 +457,18 @@
// Populate DSC device selector // Populate DSC device selector
const dscSelect = document.getElementById('dscDeviceSelect'); const dscSelect = document.getElementById('dscDeviceSelect');
dscSelect.innerHTML = ''; dscSelect.innerHTML = '';
const dscDevices = devices.filter(d => { if (devices.length === 0) {
const sdrType = (d.sdr_type || d.type || 'rtlsdr').toLowerCase(); dscSelect.innerHTML = '<option value="0">No devices</option>';
return sdrType === 'rtlsdr';
});
if (dscDevices.length === 0) {
dscSelect.innerHTML = '<option value="0">No RTL-SDR found</option>';
} else { } else {
dscDevices.forEach((d, i) => { devices.forEach((d, i) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.index; opt.value = d.index;
opt.dataset.sdrType = 'rtlsdr'; opt.textContent = `SDR ${d.index}: ${d.name}`;
opt.textContent = `SDR ${d.index} (RTLSDR): ${d.name}`;
dscSelect.appendChild(opt); dscSelect.appendChild(opt);
}); });
// Default to second device if available // Default to second device if available
if (dscDevices.length > 1) { if (devices.length > 1) {
dscSelect.value = dscDevices[1].index; dscSelect.value = devices[1].index;
} }
} }
}) })
@@ -554,9 +546,7 @@
} }
function startTracking() { function startTracking() {
const aisSelect = document.getElementById('aisDeviceSelect'); const device = document.getElementById('aisDeviceSelect').value;
const device = aisSelect.value;
const sdrType = (aisSelect.selectedOptions[0]?.dataset?.sdrType || 'rtlsdr').toLowerCase();
const gain = document.getElementById('aisGain').value; const gain = document.getElementById('aisGain').value;
// Check if using agent mode // Check if using agent mode
@@ -571,7 +561,7 @@
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, { fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled(), sdr_type: sdrType }) body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
}) })
.then(r => r.json()) .then(r => r.json())
.then(result => { .then(result => {
@@ -596,7 +586,7 @@
fetch('/ais/start', { fetch('/ais/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled(), sdr_type: sdrType }) body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -1180,9 +1170,7 @@
} }
function startDscTracking() { function startDscTracking() {
const dscSelect = document.getElementById('dscDeviceSelect'); const device = document.getElementById('dscDeviceSelect').value;
const device = dscSelect.value;
const sdrType = (dscSelect.selectedOptions[0]?.dataset?.sdrType || 'rtlsdr').toLowerCase();
const gain = document.getElementById('dscGain').value; const gain = document.getElementById('dscGain').value;
// Check if using agent mode // Check if using agent mode
@@ -1197,7 +1185,7 @@
fetch(endpoint, { fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain, sdr_type: sdrType }) body: JSON.stringify({ device, gain })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -1629,32 +1617,21 @@
const aisSelect = document.getElementById('aisDeviceSelect'); const aisSelect = document.getElementById('aisDeviceSelect');
const dscSelect = document.getElementById('dscDeviceSelect'); const dscSelect = document.getElementById('dscDeviceSelect');
const aisDevices = devices || []; [aisSelect, dscSelect].forEach(select => {
const dscDevices = aisDevices.filter(device => {
const sdrType = (device.sdr_type || device.type || 'rtlsdr').toLowerCase();
return sdrType === 'rtlsdr';
});
const fillSelect = (select, list, emptyLabel) => {
if (!select) return; if (!select) return;
select.innerHTML = ''; select.innerHTML = '';
if (list.length === 0) {
select.innerHTML = `<option value=\"0\">${emptyLabel}</option>`;
return;
}
list.forEach(device => {
const opt = document.createElement('option');
const sdrType = (device.sdr_type || device.type || 'rtlsdr').toLowerCase();
const sdrLabel = sdrType.toUpperCase();
opt.value = device.index;
opt.dataset.sdrType = sdrType;
opt.textContent = `Device ${device.index} (${sdrLabel}): ${device.name || device.type || 'SDR'}`;
select.appendChild(opt);
});
};
fillSelect(aisSelect, aisDevices, 'No SDR found'); if (devices.length === 0) {
fillSelect(dscSelect, dscDevices, 'No RTL-SDR found'); select.innerHTML = '<option value="0">No SDR found</option>';
} else {
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.index;
opt.textContent = `Device ${device.index}: ${device.name || device.type || 'SDR'}`;
select.appendChild(opt);
});
}
});
} }
// Override startTracking for agent support // Override startTracking for agent support
@@ -1668,15 +1645,13 @@
return; return;
} }
const aisSelect = document.getElementById('aisDeviceSelect'); const device = document.getElementById('aisDeviceSelect').value;
const device = aisSelect.value;
const sdrType = (aisSelect.selectedOptions[0]?.dataset?.sdrType || 'rtlsdr').toLowerCase();
const gain = document.getElementById('aisGain').value; const gain = document.getElementById('aisGain').value;
fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, { fetch(`/controller/agents/${aisCurrentAgent}/ais/start`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled(), sdr_type: sdrType }) body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled() })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
+416 -27
View File
@@ -63,6 +63,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
@@ -190,9 +191,9 @@
<span class="mode-icon icon"><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></span> <span class="mode-icon icon"><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></span>
<span class="mode-name">Meshtastic</span> <span class="mode-name">Meshtastic</span>
</button> </button>
<button class="mode-card mode-card-sm" onclick="selectMode('dmr')"> <button class="mode-card mode-card-sm" onclick="selectMode('subghz')">
<span class="mode-icon icon"><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></span> <span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg></span>
<span class="mode-name">Digital Voice</span> <span class="mode-name">SubGHz</span>
</button> </button>
<button class="mode-card mode-card-sm" onclick="selectMode('websdr')"> <button class="mode-card mode-card-sm" onclick="selectMode('websdr')">
<span class="mode-icon icon"><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></span> <span class="mode-icon icon"><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></span>
@@ -387,6 +388,10 @@
<div class="container"> <div class="container">
<div class="main-content"> <div class="main-content">
<div class="sidebar mobile-drawer" id="mainSidebar"> <div class="sidebar mobile-drawer" id="mainSidebar">
<button class="sidebar-collapse-btn" id="sidebarCollapseBtn" onclick="toggleMainSidebarCollapse()" title="Collapse sidebar">
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></span>
Collapse Sidebar
</button>
<!-- Agent Selector --> <!-- Agent Selector -->
<div class="section" id="agentSection"> <div class="section" id="agentSection">
<h3>Signal Source</h3> <h3>Signal Source</h3>
@@ -547,6 +552,8 @@
{% include 'partials/modes/websdr.html' %} {% include 'partials/modes/websdr.html' %}
{% include 'partials/modes/subghz.html' %}
<button class="preset-btn" onclick="killAll()" <button class="preset-btn" onclick="killAll()"
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;"> style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
Kill All Processes Kill All Processes
@@ -554,6 +561,9 @@
</div> </div>
<div class="output-panel"> <div class="output-panel">
<button class="sidebar-expand-handle" id="sidebarExpandHandle" onclick="toggleMainSidebarCollapse()" title="Expand sidebar">
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></span>
</button>
<div class="output-header"> <div class="output-header">
<h3 id="outputTitle">Pager Decoder</h3> <h3 id="outputTitle">Pager Decoder</h3>
<div class="header-controls"> <div class="header-controls">
@@ -1759,6 +1769,301 @@
</div> </div>
</div> </div>
<!-- SubGHz Transceiver Dashboard -->
<div id="subghzVisuals" class="subghz-visuals-container" style="display: none;">
<!-- Stats Strip -->
<div class="subghz-stats-strip">
<div class="subghz-strip-group">
<span class="subghz-strip-device-badge" id="subghzStripDevice">
<span class="subghz-strip-device-dot" id="subghzStripDeviceDot"></span>
HackRF
</span>
<div class="subghz-strip-status">
<span class="subghz-strip-dot" id="subghzStripDot"></span>
<span class="subghz-strip-status-text" id="subghzStripStatus">Idle</span>
</div>
</div>
<div class="subghz-strip-divider"></div>
<div class="subghz-strip-group">
<div class="subghz-strip-stat">
<span class="subghz-strip-value accent-cyan" id="subghzStripFreq">--</span>
<span class="subghz-strip-label">MHZ</span>
</div>
<div class="subghz-strip-stat">
<span class="subghz-strip-value" id="subghzStripMode">--</span>
<span class="subghz-strip-label">MODE</span>
</div>
</div>
<div class="subghz-strip-divider"></div>
<div class="subghz-strip-group">
<div class="subghz-strip-stat">
<span class="subghz-strip-value accent-green" id="subghzStripSignals">0</span>
<span class="subghz-strip-label">SIGNALS</span>
</div>
<div class="subghz-strip-stat">
<span class="subghz-strip-value accent-orange" id="subghzStripCaptures">0</span>
<span class="subghz-strip-label">CAPTURES</span>
</div>
</div>
<div class="subghz-strip-divider"></div>
<div class="subghz-strip-group">
<span class="subghz-strip-timer" id="subghzStripTimer"></span>
</div>
</div>
<!-- Signal Console (collapsible) -->
<div class="subghz-signal-console" id="subghzConsole" style="display: none;">
<div class="subghz-console-header" onclick="SubGhz.toggleConsole()">
<div class="subghz-phase-strip">
<span class="subghz-phase-step" id="subghzPhaseTuning">TUNING</span>
<span class="subghz-phase-arrow">&#9656;</span>
<span class="subghz-phase-step" id="subghzPhaseListening">LISTENING</span>
<span class="subghz-phase-arrow">&#9656;</span>
<span class="subghz-phase-step" id="subghzPhaseDecoding">DECODING</span>
</div>
<div class="subghz-burst-indicator" id="subghzBurstIndicator" title="Live burst detector">
<span class="subghz-burst-dot"></span>
<span class="subghz-burst-text" id="subghzBurstText">NO BURST</span>
</div>
<button class="subghz-console-toggle" id="subghzConsoleToggleBtn">&#9660;</button>
</div>
<div class="subghz-console-body" id="subghzConsoleBody">
<div class="subghz-console-log" id="subghzConsoleLog"></div>
</div>
</div>
<!-- Action Hub (idle state — 2x2 Flipper-style cards) -->
<div class="subghz-action-hub" id="subghzActionHub">
<div class="subghz-hub-header">
<div class="subghz-hub-header-title">HackRF One</div>
<div class="subghz-hub-header-sub">SubGHz Transceiver &mdash; 1 MHz - 6 GHz</div>
</div>
<div class="subghz-hub-grid">
<div class="subghz-hub-card subghz-hub-card--green" onclick="SubGhz.hubAction('rx')">
<div class="subghz-hub-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><circle cx="12" cy="12" r="3"/><path d="M12 1v4m0 14v4M1 12h4m14 0h4"/><path d="M5.6 5.6l2.85 2.85m7.1 7.1l2.85 2.85M5.6 18.4l2.85-2.85m7.1-7.1l2.85-2.85"/></svg>
</div>
<div class="subghz-hub-title">Read RAW</div>
<div class="subghz-hub-desc">Capture raw IQ via hackrf_transfer</div>
</div>
<div class="subghz-hub-card subghz-hub-card--red" onclick="SubGhz.hubAction('txselect')">
<div class="subghz-hub-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M5 19h14"/><path d="M12 5v11"/><path d="M8 9l4-4 4 4"/></svg>
</div>
<div class="subghz-hub-title">Transmit</div>
<div class="subghz-hub-desc">Replay a saved capture</div>
</div>
<div class="subghz-hub-card subghz-hub-card--orange" onclick="SubGhz.hubAction('sweep')">
<div class="subghz-hub-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M3 20h18"/><path d="M3 17l3-7 3 4 3-9 3 6 3-3 3 9"/></svg>
</div>
<div class="subghz-hub-title">Freq Analyzer</div>
<div class="subghz-hub-desc">Wideband sweep via hackrf_sweep</div>
</div>
<div class="subghz-hub-card subghz-hub-card--purple" onclick="SubGhz.hubAction('saved')">
<div class="subghz-hub-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
</div>
<div class="subghz-hub-title">Saved</div>
<div class="subghz-hub-desc">Signal library & replay</div>
</div>
</div>
</div>
<!-- Operation Panels (one visible at a time, replaces hub) -->
<!-- RX (Raw Capture) Panel -->
<div class="subghz-op-panel" id="subghzPanelRx" style="display: none;">
<div class="subghz-op-panel-header">
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">&#9664; Back</button>
<span class="subghz-op-panel-title">Read RAW — Signal Capture</span>
<div class="subghz-op-panel-actions">
<button class="subghz-btn start" id="subghzRxStartBtnPanel" onclick="SubGhz.startRx()">Start</button>
<button class="subghz-btn stop" id="subghzRxStopBtnPanel" onclick="SubGhz.stopRx()" disabled>Stop</button>
</div>
</div>
<div class="subghz-rx-display">
<div class="subghz-rx-recording" id="subghzRxRecording" style="display: none;">
<span class="subghz-rx-rec-dot"></span>
<span>RECORDING</span>
</div>
<div class="subghz-rx-info-grid">
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">FREQUENCY</span>
<span class="subghz-rx-info-value accent-cyan" id="subghzRxFreq">--</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">LNA GAIN</span>
<span class="subghz-rx-info-value" id="subghzRxLna">--</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">VGA GAIN</span>
<span class="subghz-rx-info-value" id="subghzRxVga">--</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">SAMPLE RATE</span>
<span class="subghz-rx-info-value" id="subghzRxSampleRate">--</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">FILE SIZE</span>
<span class="subghz-rx-info-value" id="subghzRxFileSize">0 KB</span>
</div>
<div class="subghz-rx-info-item">
<span class="subghz-rx-info-label">DATA RATE</span>
<span class="subghz-rx-info-value" id="subghzRxRate">0 KB/s</span>
</div>
</div>
<div class="subghz-rx-level-wrapper">
<span class="subghz-rx-level-label">SIGNAL</span>
<span class="subghz-rx-burst-pill" id="subghzRxBurstPill">IDLE</span>
<div class="subghz-rx-level-bar">
<div class="subghz-rx-level-fill" id="subghzRxLevel" style="width: 0%;"></div>
</div>
</div>
<div class="subghz-rx-hint" id="subghzRxHint">
<span class="subghz-rx-hint-label">ANALYSIS</span>
<span class="subghz-rx-hint-text" id="subghzRxHintText">No modulation hint yet</span>
<span class="subghz-rx-hint-confidence" id="subghzRxHintConfidence">--</span>
</div>
<div class="subghz-rx-scope-wrap">
<span class="subghz-rx-scope-label">WAVEFORM</span>
<div class="subghz-rx-scope">
<canvas id="subghzRxScope"></canvas>
</div>
</div>
<div class="subghz-rx-scope-wrap">
<div class="subghz-rx-waterfall-header">
<span class="subghz-rx-scope-label">WATERFALL</span>
<div class="subghz-rx-waterfall-controls">
<div class="subghz-wf-control">
<span>FLOOR</span>
<input type="range" id="subghzWfFloor" min="0" max="200" value="20" oninput="SubGhz.setWaterfallFloor(this.value)">
<span class="subghz-wf-value" id="subghzWfFloorVal">20</span>
</div>
<div class="subghz-wf-control">
<span>RANGE</span>
<input type="range" id="subghzWfRange" min="16" max="255" value="180" oninput="SubGhz.setWaterfallRange(this.value)">
<span class="subghz-wf-value" id="subghzWfRangeVal">180</span>
</div>
<button class="subghz-wf-pause-btn" id="subghzWfPauseBtn" onclick="SubGhz.toggleWaterfall()">PAUSE</button>
</div>
</div>
<div class="subghz-rx-waterfall">
<canvas id="subghzRxWaterfall"></canvas>
</div>
</div>
</div>
</div>
<!-- Sweep Panel -->
<div class="subghz-op-panel" id="subghzPanelSweep" style="display: none;">
<div class="subghz-op-panel-header">
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">&#9664; Back</button>
<span class="subghz-op-panel-title">Frequency Analyzer</span>
<div class="subghz-op-panel-actions">
<button class="subghz-btn start" id="subghzSweepStartBtnPanel" onclick="SubGhz.startSweep()">Start</button>
<button class="subghz-btn stop" id="subghzSweepStopBtnPanel" onclick="SubGhz.stopSweep()" disabled>Stop</button>
</div>
</div>
<div class="subghz-sweep-layout">
<div class="subghz-sweep-chart-wrapper" id="subghzSweepChartWrapper">
<canvas id="subghzSweepCanvas"></canvas>
</div>
<div class="subghz-sweep-peaks-sidebar" id="subghzSweepPeaksSidebar">
<div class="subghz-sweep-peaks-title">PEAKS</div>
<div class="subghz-peak-list" id="subghzSweepPeakList"></div>
</div>
</div>
</div>
<!-- TX Panel -->
<div class="subghz-op-panel" id="subghzPanelTx" style="display: none;">
<div class="subghz-op-panel-header">
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">&#9664; Back</button>
<span class="subghz-op-panel-title">Transmit</span>
</div>
<div class="subghz-tx-display" id="subghzTxDisplay">
<div class="subghz-tx-pulse-ring">
<div class="subghz-tx-pulse-dot"></div>
</div>
<div class="subghz-tx-label" id="subghzTxStateLabel">READY</div>
<div class="subghz-tx-info-grid">
<div class="subghz-tx-info-item">
<span class="subghz-tx-info-label">FREQUENCY</span>
<span class="subghz-tx-info-value accent-red" id="subghzTxFreqDisplay">--</span>
</div>
<div class="subghz-tx-info-item">
<span class="subghz-tx-info-label">TX GAIN</span>
<span class="subghz-tx-info-value" id="subghzTxGainDisplay">--</span>
</div>
<div class="subghz-tx-info-item">
<span class="subghz-tx-info-label">ELAPSED</span>
<span class="subghz-tx-info-value" id="subghzTxElapsed">0s</span>
</div>
</div>
<div class="subghz-btn-row" style="max-width: 420px; margin: 16px auto 0;">
<button class="subghz-btn" id="subghzTxChooseCaptureBtn" onclick="SubGhz.showPanel('saved')">Choose Capture</button>
<button class="subghz-btn stop" id="subghzTxStopBtn" onclick="SubGhz.stopTx()">Stop Transmission</button>
<button class="subghz-btn start" id="subghzTxReplayLastBtn" onclick="SubGhz.replayLastTx()" style="display: none;">Replay Last</button>
</div>
</div>
</div>
<!-- Saved Panel -->
<div class="subghz-op-panel" id="subghzPanelSaved" style="display: none;">
<div class="subghz-op-panel-header">
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">&#9664; Back</button>
<span class="subghz-op-panel-title">Saved Captures</span>
<div class="subghz-op-panel-actions subghz-saved-actions">
<span class="subghz-saved-selection-count" id="subghzSavedSelectionCount" style="display: none;">0 selected</span>
<button class="subghz-btn" id="subghzSavedSelectBtn" onclick="SubGhz.toggleCaptureSelectMode()">Select</button>
<button class="subghz-btn" id="subghzSavedSelectAllBtn" onclick="SubGhz.selectAllCaptures()" style="display: none;">Select All</button>
<button class="subghz-btn stop" id="subghzSavedDeleteSelectedBtn" onclick="SubGhz.deleteSelectedCaptures()" style="display: none;" disabled>Delete Selected</button>
</div>
</div>
<div class="subghz-captures-list subghz-captures-list-main" id="subghzCapturesList" style="flex: 1; min-height: 0; max-height: none; overflow-y: auto;">
<div class="subghz-empty" id="subghzCapturesEmpty">No captures yet</div>
</div>
</div>
<!-- TX Confirmation Modal -->
<div id="subghzTxModalOverlay" class="subghz-tx-modal-overlay">
<div class="subghz-tx-modal">
<h3>Confirm Transmission</h3>
<p>You are about to transmit a radio signal on:</p>
<p class="tx-freq" id="subghzTxModalFreq">--- MHz</p>
<p class="tx-duration">Capture duration: <span id="subghzTxModalDuration">--</span></p>
<div class="subghz-tx-segment-box">
<label class="subghz-tx-segment-toggle">
<input type="checkbox" id="subghzTxSegmentEnabled" onchange="SubGhz.syncTxSegmentSelection()">
Transmit selected segment only
</label>
<div class="subghz-tx-segment-grid">
<label>Start (s)</label>
<input type="number" id="subghzTxSegmentStart" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('start')">
<label>End (s)</label>
<input type="number" id="subghzTxSegmentEnd" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('end')">
</div>
<p class="subghz-tx-segment-summary" id="subghzTxSegmentSummary">Full capture</p>
</div>
<div class="subghz-tx-burst-assist" id="subghzTxBurstAssist" style="display: none;">
<div class="subghz-tx-burst-title">Detected Bursts</div>
<div class="subghz-tx-burst-timeline" id="subghzTxBurstTimeline"></div>
<div class="subghz-tx-burst-range" id="subghzTxBurstRange">Drag on timeline to select TX segment</div>
<div class="subghz-tx-burst-list" id="subghzTxBurstList"></div>
</div>
<p>Ensure you have proper authorization to transmit on this frequency.</p>
<div class="subghz-tx-modal-actions">
<button class="subghz-tx-cancel-btn" onclick="SubGhz.cancelTx()">Cancel</button>
<button class="subghz-tx-trim-btn" id="subghzTxTrimBtn" onclick="SubGhz.trimCaptureSelection()">Trim + Save</button>
<button class="subghz-tx-confirm-btn" onclick="SubGhz.confirmTx()">Transmit</button>
</div>
</div>
</div>
</div>
<!-- WebSDR Dashboard --> <!-- WebSDR Dashboard -->
<div id="websdrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow: hidden;"> <div id="websdrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
<!-- Audio Control Bar (hidden until connected) --> <!-- Audio Control Bar (hidden until connected) -->
@@ -2538,6 +2843,7 @@
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script> <script>
// ============================================ // ============================================
@@ -2673,7 +2979,7 @@
const validModes = new Set([ const validModes = new Set([
'pager', 'sensor', 'rtlamr', 'aprs', 'listening', 'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
'spystations', 'meshtastic', 'wifi', 'bluetooth', 'spystations', 'meshtastic', 'wifi', 'bluetooth',
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'dmr', 'websdr' 'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'websdr', 'subghz'
]); ]);
function getModeFromQuery() { function getModeFromQuery() {
@@ -3006,6 +3312,41 @@
return parsed; return parsed;
} }
const SIDEBAR_COLLAPSE_KEY = 'mainSidebarCollapsed';
function setMainSidebarCollapsed(collapsed) {
const mainContent = document.querySelector('.main-content');
const collapseBtn = document.getElementById('sidebarCollapseBtn');
if (!mainContent) return;
mainContent.classList.toggle('sidebar-collapsed', collapsed);
if (collapseBtn) {
collapseBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
}
localStorage.setItem(SIDEBAR_COLLAPSE_KEY, collapsed ? 'true' : 'false');
}
function toggleMainSidebarCollapse(forceState = null) {
const mainContent = document.querySelector('.main-content');
if (!mainContent || window.innerWidth < 1024) return;
const collapsed = mainContent.classList.contains('sidebar-collapsed');
const nextState = forceState === null ? !collapsed : !!forceState;
setMainSidebarCollapsed(nextState);
}
function applySidebarCollapsePreference() {
const mainContent = document.querySelector('.main-content');
if (!mainContent) return;
if (window.innerWidth < 1024) {
mainContent.classList.remove('sidebar-collapsed');
return;
}
const savedCollapsed = localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === 'true';
setMainSidebarCollapsed(savedCollapsed);
}
window.addEventListener('resize', applySidebarCollapsePreference);
// Make sections collapsible // Make sections collapsible
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.section h3').forEach(h3 => { document.querySelectorAll('.section h3').forEach(h3 => {
@@ -3014,14 +3355,17 @@
}); });
}); });
// Collapse all sections by default (except Signal Source and SDR Device) // Collapse sidebar menu sections by default, but skip headerless utility blocks.
document.querySelectorAll('.section').forEach((section, index) => { document.querySelectorAll('.sidebar .section').forEach((section) => {
// Keep first two sections expanded (Signal Source, SDR Device), collapse rest if (section.querySelector('h3')) {
if (index > 1) {
section.classList.add('collapsed'); section.classList.add('collapsed');
} else {
section.classList.remove('collapsed');
} }
}); });
applySidebarCollapsePreference();
// Load bias-T setting from localStorage // Load bias-T setting from localStorage
loadBiasTSetting(); loadBiasTSetting();
@@ -3095,7 +3439,8 @@
'tscm': 'security', 'tscm': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr', 'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
'meshtastic': 'sdr', 'meshtastic': 'sdr',
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space' 'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space',
'subghz': 'sdr'
}; };
// Remove has-active from all dropdowns // Remove has-active from all dropdowns
@@ -3141,6 +3486,11 @@
if (isTscmRunning) stopTscmSweep(); if (isTscmRunning) stopTscmSweep();
} }
// Clean up SubGHz SSE connection when leaving the mode
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
SubGhz.destroy();
}
currentMode = mode; currentMode = mode;
if (updateUrl) { if (updateUrl) {
updateModeUrl(mode); updateModeUrl(mode);
@@ -3190,6 +3540,7 @@
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic'); document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr'); document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr'); document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
const pagerStats = document.getElementById('pagerStats'); const pagerStats = document.getElementById('pagerStats');
const sensorStats = document.getElementById('sensorStats'); const sensorStats = document.getElementById('sensorStats');
const satelliteStats = document.getElementById('satelliteStats'); const satelliteStats = document.getElementById('satelliteStats');
@@ -3227,7 +3578,8 @@
'spystations': 'SPY STATIONS', 'spystations': 'SPY STATIONS',
'meshtastic': 'MESHTASTIC', 'meshtastic': 'MESHTASTIC',
'dmr': 'DIGITAL VOICE', 'dmr': 'DIGITAL VOICE',
'websdr': 'WEBSDR' 'websdr': 'WEBSDR',
'subghz': 'SUBGHZ'
}; };
const activeModeIndicator = document.getElementById('activeModeIndicator'); const activeModeIndicator = document.getElementById('activeModeIndicator');
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase()); if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
@@ -3244,6 +3596,7 @@
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals'); const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
const dmrVisuals = document.getElementById('dmrVisuals'); const dmrVisuals = document.getElementById('dmrVisuals');
const websdrVisuals = document.getElementById('websdrVisuals'); const websdrVisuals = document.getElementById('websdrVisuals');
const subghzVisuals = document.getElementById('subghzVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none'; if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none'; if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none'; if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -3257,6 +3610,7 @@
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none'; if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none'; if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none';
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none'; if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
// Hide sidebar by default for Meshtastic mode, show for others // Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content'); const mainContent = document.querySelector('.main-content');
@@ -3292,7 +3646,8 @@
'spystations': 'Spy Stations', 'spystations': 'Spy Stations',
'meshtastic': 'Meshtastic Mesh Monitor', 'meshtastic': 'Meshtastic Mesh Monitor',
'dmr': 'Digital Voice Decoder', 'dmr': 'Digital Voice Decoder',
'websdr': 'HF/Shortwave WebSDR' 'websdr': 'HF/Shortwave WebSDR',
'subghz': 'SubGHz Transceiver'
}; };
const outputTitle = document.getElementById('outputTitle'); const outputTitle = document.getElementById('outputTitle');
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor'; if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
@@ -3310,7 +3665,7 @@
const reconBtn = document.getElementById('reconBtn'); const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel'); const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') { if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') {
if (reconPanel) reconPanel.style.display = 'none'; if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none'; if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none'; if (intelBtn) intelBtn.style.display = 'none';
@@ -3348,8 +3703,8 @@
// Hide output console for modes with their own visualizations // Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output'); const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar'); const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr') ? 'none' : 'block'; if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr') ? 'none' : 'flex'; if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it) // Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') { if (mode !== 'meshtastic') {
@@ -3409,6 +3764,8 @@
if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100); if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100);
} else if (mode === 'websdr') { } else if (mode === 'websdr') {
if (typeof initWebSDR === 'function') initWebSDR(); if (typeof initWebSDR === 'function') initWebSDR();
} else if (mode === 'subghz') {
SubGhz.init();
} }
} }
@@ -4617,6 +4974,11 @@
} }
function fetchSdrStatus() { function fetchSdrStatus() {
// Avoid probing SDR inventory while HackRF SubGHz mode is active.
// Device discovery runs hackrf_info and can disrupt active HackRF streams.
if (typeof currentMode !== 'undefined' && currentMode === 'subghz') {
return;
}
fetch('/devices/status') fetch('/devices/status')
.then(r => r.json()) .then(r => r.json())
.then(devices => { .then(devices => {
@@ -9077,6 +9439,7 @@
const region = document.getElementById('aprsStripRegion').value; const region = document.getElementById('aprsStripRegion').value;
const device = getSelectedDevice(); const device = getSelectedDevice();
const gain = document.getElementById('aprsStripGain').value; const gain = document.getElementById('aprsStripGain').value;
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
// Check if using agent mode // Check if using agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
@@ -9086,7 +9449,8 @@
const requestBody = { const requestBody = {
region, region,
device: parseInt(device), device: parseInt(device),
gain: parseInt(gain) gain: parseInt(gain),
sdr_type: sdrType
}; };
// Add custom frequency if selected // Add custom frequency if selected
@@ -9431,6 +9795,41 @@
updateAprsStationList(packet); updateAprsStationList(packet);
} }
function getAprsMarkerCategory(packet) {
const symbolCode = (packet.symbol && packet.symbol.length > 1) ? packet.symbol[1] : '';
const speed = parseFloat(packet.speed || 0);
const vehicleSymbols = new Set(['>', 'k', 'u', 'v', '[', '<', 's', 'b', 'j']);
if ((Number.isFinite(speed) && speed > 2) || vehicleSymbols.has(symbolCode)) {
return 'vehicle';
}
return 'tower';
}
function getAprsMarkerSvg(category) {
if (category === 'vehicle') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 14l2-5a2 2 0 0 1 2-1h10a2 2 0 0 1 2 1l2 5v4h-2a2 2 0 0 1-4 0H9a2 2 0 0 1-4 0H3v-4z"/><circle cx="7" cy="18" r="1.7"/><circle cx="17" cy="18" r="1.7"/></svg>';
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l3 7h-2l1 3h-2l1 8h-2l1-8h-2l1-3H9l3-7z"/><path d="M5 21h14" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>';
}
function buildAprsMarkerIcon(packet) {
const category = getAprsMarkerCategory(packet);
const callsign = packet.callsign || 'UNKNOWN';
const html = `
<div class="aprs-map-marker ${category}">
<span class="aprs-map-marker-icon">${getAprsMarkerSvg(category)}</span>
<span class="aprs-map-marker-label">${callsign}</span>
</div>
`;
return L.divIcon({
className: 'aprs-map-marker-wrap',
html,
iconSize: [110, 24],
iconAnchor: [55, 12]
});
}
function updateAprsMarker(packet) { function updateAprsMarker(packet) {
const callsign = packet.callsign; const callsign = packet.callsign;
@@ -9444,6 +9843,7 @@
if (aprsMarkers[callsign]) { if (aprsMarkers[callsign]) {
// Update existing marker position and popup // Update existing marker position and popup
aprsMarkers[callsign].setLatLng([packet.lat, packet.lon]); aprsMarkers[callsign].setLatLng([packet.lat, packet.lon]);
aprsMarkers[callsign].setIcon(buildAprsMarkerIcon(packet));
aprsMarkers[callsign].setPopupContent(` aprsMarkers[callsign].setPopupContent(`
<div style="font-family: monospace;"> <div style="font-family: monospace;">
<strong>${callsign}</strong><br> <strong>${callsign}</strong><br>
@@ -9461,14 +9861,7 @@
document.getElementById('aprsStationCount').textContent = aprsStationCount; document.getElementById('aprsStationCount').textContent = aprsStationCount;
document.getElementById('aprsStripStations').textContent = aprsStationCount; document.getElementById('aprsStripStations').textContent = aprsStationCount;
const icon = L.divIcon({ const marker = L.marker([packet.lat, packet.lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
className: 'aprs-marker',
html: `<div style="background: var(--accent-cyan); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; white-space: nowrap;">${callsign}</div>`,
iconSize: [80, 20],
iconAnchor: [40, 10]
});
const marker = L.marker([packet.lat, packet.lon], { icon: icon }).addTo(aprsMap);
marker.bindPopup(` marker.bindPopup(`
<div style="font-family: monospace;"> <div style="font-family: monospace;">
@@ -10230,9 +10623,6 @@
// Satellite management // Satellite management
let trackedSatellites = [ let trackedSatellites = [
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true }, { id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'NOAA-15', name: 'NOAA 15', norad: '25338', builtin: true, checked: true },
{ id: 'NOAA-18', name: 'NOAA 18', norad: '28654', builtin: true, checked: true },
{ id: 'NOAA-19', name: 'NOAA 19', norad: '33591', builtin: true, checked: true },
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true } { id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
]; ];
@@ -15146,7 +15536,6 @@
style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 300px; overflow-y: auto;"> style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 300px; overflow-y: auto;">
<button class="preset-btn" onclick="fetchCelestrakCategory('stations')">Space Stations</button> <button class="preset-btn" onclick="fetchCelestrakCategory('stations')">Space Stations</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('weather')">Weather</button> <button class="preset-btn" onclick="fetchCelestrakCategory('weather')">Weather</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('noaa')">NOAA</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('goes')">GOES</button> <button class="preset-btn" onclick="fetchCelestrakCategory('goes')">GOES</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('amateur')">Amateur</button> <button class="preset-btn" onclick="fetchCelestrakCategory('amateur')">Amateur</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('cubesat')">CubeSats</button> <button class="preset-btn" onclick="fetchCelestrakCategory('cubesat')">CubeSats</button>
+12 -14
View File
@@ -1,20 +1,18 @@
<!-- BLUETOOTH MODE --> <!-- BLUETOOTH MODE -->
<div id="bluetoothMode" class="mode-content"> <div id="bluetoothMode" class="mode-content">
<!-- Capability Status -->
<div id="btCapabilityStatus" class="section" style="display: none;">
<!-- Populated by JavaScript with capability warnings -->
</div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" class="section" style="display: none; padding: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="section"> <div class="section">
<h3>Scanner Configuration</h3> <h3>Scanner Configuration</h3>
<!-- Populated by JavaScript with capability warnings -->
<div id="btCapabilityStatus" style="display: none; margin-bottom: 8px;"></div>
<!-- Show All Agents option (visible when agents are available) -->
<div id="btShowAllAgentsContainer" style="display: none; margin-bottom: 8px;">
<label class="inline-checkbox" style="font-size: 10px;">
<input type="checkbox" id="btShowAllAgents" onchange="if(typeof BluetoothMode !== 'undefined') BluetoothMode.toggleShowAllAgents(this.checked)">
Show devices from all agents
</label>
</div>
<div class="form-group"> <div class="form-group">
<label>Adapter</label> <label>Adapter</label>
<select id="btAdapterSelect"> <select id="btAdapterSelect">
@@ -61,7 +59,7 @@
Stop Scanning Stop Scanning
</button> </button>
<div class="section" style="margin-top: 10px;"> <div class="section">
<h3>Export</h3> <h3>Export</h3>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="btExport('csv')" style="flex: 1;"> <button class="preset-btn" onclick="btExport('csv')" style="flex: 1;">
+30 -56
View File
@@ -2,6 +2,9 @@
<div id="dmrMode" class="mode-content"> <div id="dmrMode" class="mode-content">
<div class="section"> <div class="section">
<h3>Digital Voice</h3> <h3>Digital Voice</h3>
<div class="alpha-mode-notice">
ALPHA: Digital Voice decoding is still in active development. Expect occasional decode instability and false protocol locks.
</div>
<!-- Dependency Warning --> <!-- 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;"> <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;">
@@ -19,24 +22,15 @@
<div class="form-group"> <div class="form-group">
<label>Protocol</label> <label>Protocol</label>
<select id="dmrProtocol"> <select id="dmrProtocol">
<option value="auto" selected>Auto Detect</option> <option value="auto" selected>Auto Detect (DMR/P25/D-STAR)</option>
<option value="dmr">DMR</option> <option value="dmr">DMR</option>
<option value="p25">P25 Phase 1</option> <option value="p25">P25</option>
<option value="p25p2">P25 Phase 2</option>
<option value="nxdn">NXDN</option> <option value="nxdn">NXDN</option>
<option value="dstar">D-STAR</option> <option value="dstar">D-STAR</option>
<option value="provoice">ProVoice</option> <option value="provoice">ProVoice</option>
</select> </select>
</div>
<div class="form-group">
<label>Demodulation</label>
<select id="dmrDemod">
<option value="nfm" selected>NFM (recommended)</option>
<option value="fm">FM (wide)</option>
</select>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;"> <span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
Narrow FM often improves digital voice decode on 12.5 kHz channels. For NXDN and ProVoice, use manual protocol selection for best lock reliability
</span> </span>
</div> </div>
@@ -51,18 +45,6 @@
title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset."> title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset.">
</div> </div>
<div class="form-group">
<label>Fine Tune (Hz)</label>
<input type="number" id="dmrFineTune" value="0" min="-5000" max="5000" step="100" style="width: 100%;"
title="Offset the tuned frequency by a small amount without changing PPM.">
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
Adjust in 100 Hz steps; small offsets can dramatically improve P25 decode.
</span>
</div>
<button class="preset-btn" id="dmrFineTuneSweepBtn" onclick="sweepDmrFineTune()" style="width: 100%; margin-top: 6px;">
Sweep Fine Tune
</button>
<div class="form-group" style="margin-top: 4px;"> <div class="form-group" style="margin-top: 4px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;"> <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);"> <input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);">
@@ -94,14 +76,6 @@
</div> </div>
</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 --> <!-- Current Call -->
<div class="section" style="margin-top: 12px;"> <div class="section" style="margin-top: 12px;">
<h3>Current Call</h3> <h3>Current Call</h3>
@@ -111,30 +85,30 @@
</div> </div>
<!-- Status --> <!-- Status -->
<div class="section" style="margin-top: 12px;"> <div class="section" style="margin-top: 12px;">
<h3>Status</h3> <h3>Status</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;"> <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;"> <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 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> <span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div> </div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;"> <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 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> <span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
</div> </div>
<div style="display: flex; justify-content: space-between; align-items: center;"> <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 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> <span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
</div>
<div style="margin-top: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Quality</span>
<span id="dmrQualityText" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="height: 6px; background: rgba(255,255,255,0.08); border-radius: 6px; overflow: hidden;">
<div id="dmrQualityBar" style="height: 100%; width: 0%; background: var(--text-muted); transition: width 0.2s ease;"></div>
</div>
</div>
</div> </div>
</div> </div>
</div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startDmrBtn" onclick="startDmr()">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none;">
Stop Decoder
</button>
</div>
</div> </div>
+175
View File
@@ -0,0 +1,175 @@
<!-- SUBGHZ TRANSCEIVER MODE -->
<div id="subghzMode" class="mode-content">
<div class="section">
<h3>SubGHz Transceiver</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
HackRF One SubGHz transceiver. Capture raw signals, replay saved bursts,
and scan wideband activity with frequency analysis.
</p>
</div>
<!-- Device -->
<div class="section">
<h3>HackRF Device</h3>
<div class="subghz-device-status" id="subghzDeviceStatus">
<div class="subghz-device-row">
<span class="subghz-device-dot" id="subghzDeviceDot"></span>
<span class="subghz-device-label" id="subghzDeviceLabel">Checking...</span>
</div>
<div class="subghz-device-tools" id="subghzDeviceTools">
<span class="subghz-tool-badge" id="subghzToolHackrf" title="hackrf_transfer">HackRF</span>
<span class="subghz-tool-badge" id="subghzToolSweep" title="hackrf_sweep">Sweep</span>
</div>
</div>
<div class="form-group" style="margin-top: 8px;">
<label>Device Serial <span style="color: var(--text-dim); font-weight: normal;">(optional)</span></label>
<input type="text" id="subghzDeviceSerial" placeholder="auto-detect" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
</div>
</div>
<!-- Status -->
<div class="subghz-status-row" id="subghzStatusRow">
<div class="subghz-status-dot" id="subghzStatusDot"></div>
<span class="subghz-status-text" id="subghzStatusText">Idle</span>
<span class="subghz-status-timer" id="subghzStatusTimer"></span>
</div>
<!-- Frequency -->
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="subghzFrequency" value="433.92" step="0.001" min="1" max="6000">
</div>
<div class="subghz-preset-btns">
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(315)">315M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(433.92)">433.92M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(868)">868M</button>
<button class="subghz-preset-btn" onclick="SubGhz.setFreq(915)">915M</button>
</div>
</div>
<!-- Gain -->
<div class="section">
<h3>Gain</h3>
<div class="form-group">
<label>LNA Gain (0-40 dB)</label>
<input type="range" id="subghzLnaGain" min="0" max="40" value="24" step="8" oninput="document.getElementById('subghzLnaVal').textContent=this.value">
<span id="subghzLnaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">24</span>
</div>
<div class="form-group">
<label>VGA Gain (0-62 dB)</label>
<input type="range" id="subghzVgaGain" min="0" max="62" value="20" step="2" oninput="document.getElementById('subghzVgaVal').textContent=this.value">
<span id="subghzVgaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Sample Rate</label>
<select id="subghzSampleRate" class="mode-select">
<option value="2000000" selected>2 MHz</option>
<option value="4000000">4 MHz</option>
<option value="8000000">8 MHz</option>
<option value="10000000">10 MHz</option>
<option value="20000000">20 MHz</option>
</select>
</div>
</div>
<!-- Tabs: Receive RAW / Sweep -->
<div class="section">
<div class="subghz-tabs">
<button class="subghz-tab active" data-tab="rx" onclick="SubGhz.switchTab('rx')">Read RAW</button>
<button class="subghz-tab" data-tab="sweep" onclick="SubGhz.switchTab('sweep')">Sweep</button>
</div>
<!-- RX Tab -->
<div class="subghz-tab-content active" id="subghzTabRx">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Capture raw IQ data to file. Saved captures can be replayed or analyzed.
</p>
<div class="subghz-trigger-box">
<label class="subghz-trigger-toggle">
<input type="checkbox" id="subghzTriggerEnabled" onchange="SubGhz.syncTriggerControls()">
Smart Trigger Capture
</label>
<div class="subghz-trigger-grid">
<label>Pre-roll (ms)</label>
<input type="number" id="subghzTriggerPreMs" min="50" max="5000" step="50" value="350">
<label>Post-roll (ms)</label>
<input type="number" id="subghzTriggerPostMs" min="100" max="10000" step="50" value="700">
</div>
<p class="subghz-trigger-help">Auto-stops after burst + post-roll and trims capture window.</p>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzRxStartBtn" onclick="SubGhz.startRx()">Start Capture</button>
<button class="subghz-btn stop" id="subghzRxStopBtn" onclick="SubGhz.stopRx()" disabled>Stop Capture</button>
</div>
</div>
<!-- Sweep Tab -->
<div class="subghz-tab-content" id="subghzTabSweep">
<p style="font-size: 11px; color: var(--text-dim); margin-bottom: 10px;">
Wideband spectrum analyzer using hackrf_sweep.
</p>
<div class="form-group">
<label>Frequency Range (MHz)</label>
<div class="subghz-sweep-range">
<input type="number" id="subghzSweepStart" value="300" min="1" max="6000" step="1">
<span>to</span>
<input type="number" id="subghzSweepEnd" value="928" min="1" max="6000" step="1">
</div>
</div>
<div class="subghz-btn-row">
<button class="subghz-btn start" id="subghzSweepStartBtn" onclick="SubGhz.startSweep()">Start Sweep</button>
<button class="subghz-btn stop" id="subghzSweepStopBtn" onclick="SubGhz.stopSweep()" disabled>Stop Sweep</button>
</div>
<div style="margin-top: 10px;">
<label style="font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px;">Detected Peaks</label>
<div class="subghz-peak-list" id="subghzPeakList"></div>
</div>
</div>
</div>
<!-- TX Settings (collapsible) -->
<div class="section">
<h3 style="cursor: pointer;" onclick="document.getElementById('subghzTxSection').classList.toggle('active')">
Transmit Settings <span style="font-size: 10px; color: var(--text-dim);">&#9660;</span>
</h3>
<div id="subghzTxSection" style="display: none;">
<div class="subghz-tx-warning">
WARNING: Transmitting radio signals may be illegal without proper authorization.
Only transmit on frequencies you are licensed for and within ISM band limits.
TX is restricted to ISM bands: 300-348, 387-464, 779-928 MHz.
</div>
<div class="form-group">
<label>TX VGA Gain (0-47 dB)</label>
<input type="range" id="subghzTxGain" min="0" max="47" value="20" step="1" oninput="document.getElementById('subghzTxGainVal').textContent=this.value">
<span id="subghzTxGainVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
</div>
<div class="form-group">
<label>Max Duration (seconds)</label>
<input type="number" id="subghzTxMaxDuration" value="10" min="1" max="30" step="1">
</div>
</div>
</div>
<!-- Saved Signals Library -->
<div class="section">
<h3>Saved Signals</h3>
<div class="subghz-captures-list" id="subghzSidebarCaptures" style="max-height: 220px; overflow-y: auto;">
<div class="subghz-empty" id="subghzSidebarCapturesEmpty">No saved captures yet</div>
</div>
</div>
</div>
<script>
// Toggle TX section visibility
document.addEventListener('DOMContentLoaded', function() {
const h3 = document.querySelector('#subghzTxSection')?.previousElementSibling;
if (h3) {
h3.addEventListener('click', function() {
const section = document.getElementById('subghzTxSection');
if (section) section.style.display = section.style.display === 'none' ? 'block' : 'none';
});
}
});
</script>
+14 -13
View File
@@ -2,7 +2,7 @@
<div id="tscmMode" class="mode-content"> <div id="tscmMode" class="mode-content">
<!-- Configuration --> <!-- Configuration -->
<div class="section"> <div class="section">
<h3 style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3> <h3>TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
<div class="form-group"> <div class="form-group">
<label>Sweep Type</label> <label>Sweep Type</label>
@@ -65,14 +65,6 @@
</div> </div>
</div> </div>
<!-- Actions -->
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()" style="margin-top: 12px;">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none; margin-top: 12px;">
Stop Sweep
</button>
<!-- Futuristic Scanner Progress --> <!-- Futuristic Scanner Progress -->
<div id="tscmProgress" class="tscm-scanner-progress" style="display: none; margin-top: 12px;"> <div id="tscmProgress" class="tscm-scanner-progress" style="display: none; margin-top: 12px;">
<div class="scanner-ring"> <div class="scanner-ring">
@@ -115,8 +107,8 @@
</div> </div>
<!-- Advanced --> <!-- Advanced -->
<div class="section" style="margin-top: 12px;"> <div class="section">
<h3 style="margin-bottom: 12px;">Advanced</h3> <h3>Advanced</h3>
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px; color: var(--text-secondary);">Baseline Recording</label> <label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px; color: var(--text-secondary);">Baseline Recording</label>
@@ -156,8 +148,8 @@
</div> </div>
<!-- Tools --> <!-- Tools -->
<div class="section" style="margin-top: 12px;"> <div class="section">
<h3 style="margin-bottom: 10px;">Tools</h3> <h3>Tools</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;">
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;"> <button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
Capabilities Capabilities
@@ -182,4 +174,13 @@
<!-- Device Warnings --> <!-- Device Warnings -->
<div id="tscmDeviceWarnings" style="display: none; margin-top: 8px; padding: 8px; background: rgba(255,153,51,0.1); border: 1px solid rgba(255,153,51,0.3); border-radius: 4px;"></div> <div id="tscmDeviceWarnings" style="display: none; margin-top: 8px; padding: 8px; background: rgba(255,153,51,0.1); border: 1px solid rgba(255,153,51,0.3); border-radius: 4px;"></div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()">
Start Sweep
</button>
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none;">
Stop Sweep
</button>
</div>
</div> </div>
@@ -2,6 +2,9 @@
<div id="weatherSatMode" class="mode-content"> <div id="weatherSatMode" class="mode-content">
<div class="section"> <div class="section">
<h3>Weather Satellite Decoder</h3> <h3>Weather Satellite Decoder</h3>
<div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;"> <p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites. Receive and decode weather images from NOAA and Meteor satellites.
Uses SatDump for live SDR capture and image processing. Uses SatDump for live SDR capture and image processing.
@@ -13,10 +16,11 @@
<div class="form-group"> <div class="form-group">
<label>Select Satellite</label> <label>Select Satellite</label>
<select id="weatherSatSelect" class="mode-select"> <select id="weatherSatSelect" class="mode-select">
<option value="NOAA-15">NOAA-15 (137.620 MHz APT)</option> <option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
<option value="NOAA-18" selected>NOAA-18 (137.9125 MHz APT)</option> <option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
<option value="NOAA-19">NOAA-19 (137.100 MHz APT)</option> <option value="NOAA-15" disabled>NOAA-15 (137.620 MHz APT) [DEFUNCT]</option>
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</option> <option value="NOAA-18" disabled>NOAA-18 (137.9125 MHz APT) [DEFUNCT]</option>
<option value="NOAA-19" disabled>NOAA-19 (137.100 MHz APT) [DEFUNCT]</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -187,10 +191,11 @@
<div class="form-group"> <div class="form-group">
<label>Satellite</label> <label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select"> <select id="wxsatTestSatSelect" class="mode-select">
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
<option value="NOAA-15">NOAA-15 (APT)</option> <option value="NOAA-15">NOAA-15 (APT)</option>
<option value="NOAA-18" selected>NOAA-18 (APT)</option> <option value="NOAA-18">NOAA-18 (APT)</option>
<option value="NOAA-19">NOAA-19 (APT)</option> <option value="NOAA-19">NOAA-19 (APT)</option>
<option value="METEOR-M2-3">Meteor-M2-3 (LRPT)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
+26 -23
View File
@@ -1,7 +1,8 @@
<!-- WiFi MODE --> <!-- WiFi MODE -->
<div id="wifiMode" class="mode-content"> <div id="wifiMode" class="mode-content">
<!-- Scan Mode Tabs --> <!-- Scan Mode Tabs -->
<div class="section" style="padding: 8px;"> <div class="section">
<h3>Signal Source</h3>
<div class="wifi-scan-mode-tabs" style="display: flex; gap: 4px;"> <div class="wifi-scan-mode-tabs" style="display: flex; gap: 4px;">
<button id="wifiScanModeQuick" class="wifi-mode-tab active" style="flex: 1; padding: 8px; font-size: 11px; background: var(--accent-green); color: #000; border: none; border-radius: 4px; cursor: pointer;"> <button id="wifiScanModeQuick" class="wifi-mode-tab active" style="flex: 1; padding: 8px; font-size: 11px; background: var(--accent-green); color: #000; border: none; border-radius: 4px; cursor: pointer;">
Quick Scan Quick Scan
@@ -168,29 +169,8 @@
</div> </div>
</div> </div>
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
<!-- Export Section --> <!-- Export Section -->
<div class="section" style="margin-top: 10px;"> <div class="section">
<h3>Export</h3> <h3>Export</h3>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="WiFiMode.exportData('csv')" style="flex: 1;"> <button class="preset-btn" onclick="WiFiMode.exportData('csv')" style="flex: 1;">
@@ -201,4 +181,27 @@
</button> </button>
</div> </div>
</div> </div>
<div class="mode-actions-bottom">
<!-- v2 Scan Buttons -->
<div style="display: flex; gap: 8px;">
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
Quick Scan
</button>
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
Deep Scan
</button>
</div>
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
Stop Scanning
</button>
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
Start Scanning (Legacy)
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning (Legacy)
</button>
</div>
</div> </div>
+2 -2
View File
@@ -71,8 +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('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('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('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>') }} {{ 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>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</div> </div>
</div> </div>
@@ -191,8 +191,8 @@
{{ 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('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('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('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>') }} {{ 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>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
</nav> </nav>
{# JavaScript stub for pages that don't have switchMode defined #} {# JavaScript stub for pages that don't have switchMode defined #}
-1
View File
@@ -323,7 +323,6 @@
<option value="acars">ACARS</option> <option value="acars">ACARS</option>
<option value="aprs">APRS</option> <option value="aprs">APRS</option>
<option value="rtlamr">RTLAMR</option> <option value="rtlamr">RTLAMR</option>
<option value="dmr">DMR</option>
<option value="tscm">TSCM</option> <option value="tscm">TSCM</option>
<option value="sstv">SSTV</option> <option value="sstv">SSTV</option>
<option value="sstv_general">SSTV General</option> <option value="sstv_general">SSTV General</option>
+2 -10
View File
@@ -107,12 +107,8 @@
<label>TARGET:</label> <label>TARGET:</label>
<select id="satSelect" onchange="onSatelliteChange()"> <select id="satSelect" onchange="onSatelliteChange()">
<option value="25544">ISS (ZARYA)</option> <option value="25544">ISS (ZARYA)</option>
<option value="25338">NOAA 15</option>
<option value="28654">NOAA 18</option>
<option value="33591">NOAA 19</option>
<option value="40069">METEOR-M2</option> <option value="40069">METEOR-M2</option>
<option value="43013">NOAA 20</option> <option value="57166">METEOR-M2-3</option>
<option value="54234">METEOR-M2-3</option>
</select> </select>
</div> </div>
@@ -275,12 +271,8 @@
const satellites = { const satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' }, 25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
25338: { name: 'NOAA 15', color: '#00ff00' },
28654: { name: 'NOAA 18', color: '#ff6600' },
33591: { name: 'NOAA 19', color: '#ff3366' },
40069: { name: 'METEOR-M2', color: '#9370DB' }, 40069: { name: 'METEOR-M2', color: '#9370DB' },
43013: { name: 'NOAA 20', color: '#00ffaa' }, 57166: { name: 'METEOR-M2-3', color: '#ff00ff' }
54234: { name: 'METEOR-M2-3', color: '#ff00ff' }
}; };
function onSatelliteChange() { function onSatelliteChange() {
+74 -24
View File
@@ -1,7 +1,9 @@
"""Tests for the DMR / Digital Voice decoding module.""" """Tests for the DMR / Digital Voice decoding module."""
import queue
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import pytest import pytest
import routes.dmr as dmr_module
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
@@ -132,10 +134,9 @@ def test_dsd_fme_flags_differ_from_classic():
def test_dsd_fme_protocol_flags_known_values(): def test_dsd_fme_protocol_flags_known_values():
"""dsd-fme flags use its own flag names (NOT classic DSD mappings).""" """dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-ft'] # XDMA assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-fa'] # Broad auto
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!) assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!)
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-f1'] # NOT -fp (ProVoice in fme) assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-ft'] # P25 P1/P2 coverage
assert _DSD_FME_PROTOCOL_FLAGS['p25p2'] == ['-f2'] # Phase 2
assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn'] assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn']
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
@@ -154,10 +155,9 @@ def test_dsd_protocol_flags_known_values():
def test_dsd_fme_modulation_hints(): def test_dsd_fme_modulation_hints():
"""C4FM modulation hints should be set for C4FM protocols.""" """C4FM modulation hints should be set for C4FM protocols."""
assert _DSD_FME_MODULATION['dmr'] == ['-mc'] assert _DSD_FME_MODULATION['dmr'] == ['-mc']
assert _DSD_FME_MODULATION['p25'] == ['-mc']
assert _DSD_FME_MODULATION['p25p2'] == ['-mq']
assert _DSD_FME_MODULATION['nxdn'] == ['-mc'] assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
# D-Star and ProVoice should not have forced modulation # P25, D-Star and ProVoice should not have forced modulation
assert 'p25' not in _DSD_FME_MODULATION
assert 'dstar' not in _DSD_FME_MODULATION assert 'dstar' not in _DSD_FME_MODULATION
assert 'provoice' not in _DSD_FME_MODULATION assert 'provoice' not in _DSD_FME_MODULATION
@@ -174,6 +174,40 @@ def auth_client(client):
return client return client
@pytest.fixture(autouse=True)
def reset_dmr_globals():
"""Reset DMR globals before/after each test to avoid cross-test bleed."""
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
yield
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
def test_dmr_tools(auth_client): def test_dmr_tools(auth_client):
"""Tools endpoint should return availability info.""" """Tools endpoint should return availability info."""
resp = auth_client.get('/dmr/tools') resp = auth_client.get('/dmr/tools')
@@ -237,25 +271,41 @@ def test_dmr_stream_mimetype(auth_client):
"""Stream should return event-stream content type.""" """Stream should return event-stream content type."""
resp = auth_client.get('/dmr/stream') resp = auth_client.get('/dmr/stream')
assert resp.content_type.startswith('text/event-stream') assert resp.content_type.startswith('text/event-stream')
def test_parse_frame_error_duid():
"""Should parse DUID errors as frame_error."""
result = parse_dsd_output('P25p2 LCH 0 DUID ERR 11')
assert result is not None
assert result['type'] == 'frame_error'
assert result['kind'] == 'duid'
def test_parse_frame_error_rs(): def test_dmr_start_exception_cleans_up_resources(auth_client):
"""Should parse Reed-Solomon errors as frame_error.""" """If startup fails after rtl_fm launch, process/device state should be reset."""
result = parse_dsd_output('P25p2 SACCH R-S ERR Sc') rtl_proc = MagicMock()
assert result is not None rtl_proc.poll.return_value = None
assert result['type'] == 'frame_error' rtl_proc.wait.return_value = 0
assert result['kind'] == 'rs' rtl_proc.stdout = MagicMock()
rtl_proc.stderr = MagicMock()
builder = MagicMock()
builder.build_fm_demod_command.return_value = ['rtl_fm', '-f', '462.5625M']
def test_parse_frame_ok_p25p2(): with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
"""Should parse P25p2 4V frames as OK.""" patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'), \
result = parse_dsd_output('P25p2 LCH 1 4V 1') patch('routes.dmr.find_ffmpeg', return_value=None), \
assert result is not None patch('routes.dmr.SDRFactory.create_default_device', return_value=MagicMock()), \
assert result['type'] == 'frame_ok' patch('routes.dmr.SDRFactory.get_builder', return_value=builder), \
assert result['kind'] == 'p25p2' patch('routes.dmr.app_module.claim_sdr_device', return_value=None), \
patch('routes.dmr.app_module.release_sdr_device') as release_mock, \
patch('routes.dmr.register_process') as register_mock, \
patch('routes.dmr.unregister_process') as unregister_mock, \
patch('routes.dmr.subprocess.Popen', side_effect=[rtl_proc, RuntimeError('dsd launch failed')]):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'auto',
'device': 0,
})
assert resp.status_code == 500
assert 'dsd launch failed' in resp.get_json()['message']
register_mock.assert_called_once_with(rtl_proc)
rtl_proc.terminate.assert_called_once()
unregister_mock.assert_called_once_with(rtl_proc)
release_mock.assert_called_once_with(0)
assert dmr_module.dmr_running is False
assert dmr_module.dmr_rtl_process is None
assert dmr_module.dmr_dsd_process is None
+10
View File
@@ -58,6 +58,16 @@ class TestHealthEndpoint:
assert 'wifi' in processes assert 'wifi' in processes
assert 'bluetooth' in processes assert 'bluetooth' in processes
def test_health_reports_dmr_route_process(self, client):
"""Health should reflect DMR route module state (not stale app globals)."""
mock_proc = MagicMock()
mock_proc.poll.return_value = None
with patch('routes.dmr.dmr_running', True), \
patch('routes.dmr.dmr_dsd_process', mock_proc):
response = client.get('/health')
data = json.loads(response.data)
assert data['processes']['dmr'] is True
class TestDevicesEndpoint: class TestDevicesEndpoint:
"""Tests for devices endpoint.""" """Tests for devices endpoint."""
+38
View File
@@ -0,0 +1,38 @@
"""Tests for rtl_fm modulation token mapping."""
from routes.listening_post import _rtl_fm_demod_mode as listening_post_rtl_mode
from utils.sdr.base import SDRDevice, SDRType
from utils.sdr.rtlsdr import RTLSDRCommandBuilder, _rtl_fm_demod_mode as builder_rtl_mode
def _dummy_rtlsdr_device() -> SDRDevice:
return SDRDevice(
sdr_type=SDRType.RTL_SDR,
index=0,
name='RTL-SDR',
serial='00000001',
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES,
)
def test_rtl_fm_modulation_maps_wfm_to_wbfm() -> None:
assert listening_post_rtl_mode('wfm') == 'wbfm'
assert builder_rtl_mode('wfm') == 'wbfm'
def test_rtl_fm_modulation_keeps_other_modes() -> None:
assert listening_post_rtl_mode('fm') == 'fm'
assert builder_rtl_mode('am') == 'am'
def test_rtlsdr_builder_uses_wbfm_token_for_wfm() -> None:
builder = RTLSDRCommandBuilder()
cmd = builder.build_fm_demod_command(
device=_dummy_rtlsdr_device(),
frequency_mhz=98.1,
modulation='wfm',
)
mode_index = cmd.index('-M')
assert cmd[mode_index + 1] == 'wbfm'
+608
View File
@@ -0,0 +1,608 @@
"""Tests for SubGhzManager utility module."""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzManager, SubGhzCapture
@pytest.fixture
def tmp_data_dir(tmp_path):
"""Create a temporary data directory for SubGhz captures."""
data_dir = tmp_path / 'subghz'
data_dir.mkdir()
(data_dir / 'captures').mkdir()
return data_dir
@pytest.fixture
def manager(tmp_data_dir):
"""Create a SubGhzManager with temp directory."""
return SubGhzManager(data_dir=tmp_data_dir)
class TestSubGhzManagerInit:
def test_creates_data_dirs(self, tmp_path):
data_dir = tmp_path / 'new_subghz'
mgr = SubGhzManager(data_dir=data_dir)
assert (data_dir / 'captures').is_dir()
def test_active_mode_idle(self, manager):
assert manager.active_mode == 'idle'
def test_get_status_idle(self, manager):
status = manager.get_status()
assert status['mode'] == 'idle'
class TestToolDetection:
def test_check_hackrf_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'):
assert manager.check_hackrf() is True
def test_check_hackrf_not_found(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None # reset cache
assert manager.check_hackrf() is False
def test_check_rtl433_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/rtl_433'):
assert manager.check_rtl433() is True
def test_check_sweep_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'):
assert manager.check_sweep() is True
class TestReceive:
def test_start_receive_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_start_receive_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stderr = MagicMock()
mock_proc.stderr.readline = MagicMock(return_value=b'')
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
result = manager.start_receive(
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'rx'
def test_start_receive_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.start_receive(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_stop_receive_not_running(self, manager):
result = manager.stop_receive()
assert result['status'] == 'not_running'
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
# Create a fake IQ file
iq_file = tmp_data_dir / 'captures' / 'test.iq'
iq_file.write_bytes(b'\x00' * 1024)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
manager._rx_file = iq_file
manager._rx_frequency_hz = 433920000
manager._rx_sample_rate = 2000000
manager._rx_lna_gain = 32
manager._rx_vga_gain = 20
manager._rx_start_time = 1000.0
manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}]
with patch('utils.subghz.safe_terminate'), \
patch('time.time', return_value=1005.0):
result = manager.stop_receive()
assert result['status'] == 'stopped'
assert 'capture' in result
assert result['capture']['frequency_hz'] == 433920000
# Verify JSON sidecar was written
meta_path = iq_file.with_suffix('.json')
assert meta_path.exists()
meta = json.loads(meta_path.read_text())
assert meta['frequency_hz'] == 433920000
assert isinstance(meta.get('bursts'), list)
assert meta['bursts'][0]['peak_level'] == 42
class TestTxSafety:
def test_validate_tx_frequency_ism_433(self):
result = SubGhzManager.validate_tx_frequency(433920000)
assert result is None # Valid
def test_validate_tx_frequency_ism_315(self):
result = SubGhzManager.validate_tx_frequency(315000000)
assert result is None
def test_validate_tx_frequency_ism_915(self):
result = SubGhzManager.validate_tx_frequency(915000000)
assert result is None
def test_validate_tx_frequency_out_of_band(self):
result = SubGhzManager.validate_tx_frequency(100000000) # 100 MHz
assert result is not None
assert 'outside allowed TX bands' in result
def test_validate_tx_frequency_between_bands(self):
result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz
assert result is not None
def test_transmit_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
result = manager.transmit(capture_id='abc123')
assert result['status'] == 'error'
def test_transmit_capture_not_found(self, manager):
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='nonexistent')
assert result['status'] == 'error'
assert 'not found' in result['message']
def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir):
# Create a capture with out-of-band frequency
meta = {
'id': 'test123',
'filename': 'test.iq',
'frequency_hz': 100000000, # 100 MHz - out of ISM
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
}
meta_path = tmp_data_dir / 'captures' / 'test.json'
meta_path.write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100)
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True):
manager._hackrf_available = None
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'outside allowed TX bands' in result['message']
def test_transmit_already_running(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
result = manager.transmit(capture_id='test123')
assert result['status'] == 'error'
assert 'Already running' in result['message']
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
meta = {
'id': 'seg001',
'filename': 'seg.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 1.0,
'size_bytes': 2000,
}
(tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta))
(tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_timer = MagicMock()
mock_timer.start = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \
patch.object(manager, 'check_hackrf_device', return_value=True), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'), \
patch('threading.Timer', return_value=mock_timer), \
patch('threading.Thread') as mock_thread_cls:
mock_thread = MagicMock()
mock_thread.start = MagicMock()
mock_thread_cls.return_value = mock_thread
manager._hackrf_available = None
result = manager.transmit(
capture_id='seg001',
start_seconds=0.2,
duration_seconds=0.3,
)
assert result['status'] == 'transmitting'
assert result['segment'] is not None
assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01)
assert manager._tx_temp_file is not None
assert manager._tx_temp_file.exists()
class TestCaptureLibrary:
def test_list_captures_empty(self, manager):
captures = manager.list_captures()
assert captures == []
def test_list_captures_with_data(self, manager, tmp_data_dir):
meta = {
'id': 'cap001',
'filename': 'test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 5.0,
'size_bytes': 1024,
'label': 'test capture',
}
(tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta))
captures = manager.list_captures()
assert len(captures) == 1
assert captures[0].capture_id == 'cap001'
assert captures[0].label == 'test capture'
def test_get_capture(self, manager, tmp_data_dir):
meta = {
'id': 'cap002',
'filename': 'test2.iq',
'frequency_hz': 315000000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}
(tmp_data_dir / 'captures' / 'test2.json').write_text(json.dumps(meta))
cap = manager.get_capture('cap002')
assert cap is not None
assert cap.frequency_hz == 315000000
def test_get_capture_not_found(self, manager):
cap = manager.get_capture('nonexistent')
assert cap is None
def test_delete_capture(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'delete_me.iq'
meta_path = captures_dir / 'delete_me.json'
iq_path.write_bytes(b'\x00' * 100)
meta_path.write_text(json.dumps({
'id': 'del001',
'filename': 'delete_me.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
assert manager.delete_capture('del001') is True
assert not iq_path.exists()
assert not meta_path.exists()
def test_delete_capture_not_found(self, manager):
assert manager.delete_capture('nonexistent') is False
def test_update_label(self, manager, tmp_data_dir):
meta = {
'id': 'lbl001',
'filename': 'label_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'label': '',
}
meta_path = tmp_data_dir / 'captures' / 'label_test.json'
meta_path.write_text(json.dumps(meta))
assert manager.update_capture_label('lbl001', 'Garage Remote') is True
updated = json.loads(meta_path.read_text())
assert updated['label'] == 'Garage Remote'
assert updated['label_source'] == 'manual'
def test_update_label_not_found(self, manager):
assert manager.update_capture_label('nonexistent', 'test') is False
def test_get_capture_path(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'path_test.iq'
iq_path.write_bytes(b'\x00' * 100)
(captures_dir / 'path_test.json').write_text(json.dumps({
'id': 'pth001',
'filename': 'path_test.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
}))
path = manager.get_capture_path('pth001')
assert path is not None
assert path.name == 'path_test.iq'
def test_get_capture_path_not_found(self, manager):
assert manager.get_capture_path('nonexistent') is None
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'trim_src.iq'
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
(captures_dir / 'trim_src.json').write_text(json.dumps({
'id': 'trim001',
'filename': 'trim_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'label': 'Weather Burst',
'bursts': [
{
'start_seconds': 0.55,
'duration_seconds': 0.2,
'peak_level': 67,
'fingerprint': 'abc123',
'modulation_hint': 'OOK/ASK',
'modulation_confidence': 0.9,
}
],
}))
result = manager.trim_capture(
capture_id='trim001',
start_seconds=0.5,
duration_seconds=0.4,
)
assert result['status'] == 'ok'
assert result['capture']['id'] != 'trim001'
assert result['capture']['size_bytes'] == 800
assert result['capture']['label'].endswith('(Trim)')
trimmed_iq = captures_dir / result['capture']['filename']
assert trimmed_iq.exists()
trimmed_meta = trimmed_iq.with_suffix('.json')
assert trimmed_meta.exists()
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / 'captures'
iq_path = captures_dir / 'auto_src.iq'
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
(captures_dir / 'auto_src.json').write_text(json.dumps({
'id': 'trim002',
'filename': 'auto_src.iq',
'frequency_hz': 433920000,
'sample_rate': 1000,
'lna_gain': 24,
'vga_gain': 20,
'timestamp': '2026-01-01T00:00:00Z',
'duration_seconds': 2.0,
'size_bytes': 4000,
'bursts': [
{'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12},
{'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88},
],
}))
result = manager.trim_capture(capture_id='trim002')
assert result['status'] == 'ok'
assert result['segment']['auto_selected'] is True
assert result['capture']['duration_seconds'] > 0.25
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
cap_a = {
'id': 'grp001',
'filename': 'a.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:00:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
cap_b = {
'id': 'grp002',
'filename': 'b.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
'timestamp': '2026-01-01T00:01:00Z',
'dominant_fingerprint': 'deadbeefcafebabe',
}
(tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a))
(tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b))
captures = manager.list_captures()
assert len(captures) == 2
assert all(c.fingerprint_group.startswith('SIG-') for c in captures)
assert all(c.fingerprint_group_size == 2 for c in captures)
class TestSweep:
def test_start_sweep_no_tool(self, manager):
with patch('shutil.which', return_value=None):
manager._sweep_available = None
result = manager.start_sweep()
assert result['status'] == 'error'
def test_start_sweep_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stdout = MagicMock()
with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'), \
patch('subprocess.Popen', return_value=mock_proc), \
patch('utils.subghz.register_process'):
manager._sweep_available = None
result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928)
assert result['status'] == 'started'
# Signal daemon threads to stop so they don't outlive the test
manager._sweep_running = False
def test_stop_sweep_not_running(self, manager):
result = manager.stop_sweep()
assert result['status'] == 'not_running'
class TestDecode:
def test_start_decode_no_hackrf(self, manager):
with patch('shutil.which', return_value=None):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'hackrf_transfer' in result['message']
def test_start_decode_no_rtl433(self, manager):
def which_side_effect(name):
if name == 'hackrf_transfer':
return '/usr/bin/hackrf_transfer'
return None
with patch('shutil.which', side_effect=which_side_effect):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result['status'] == 'error'
assert 'rtl_433' in result['message']
def test_start_decode_success(self, manager):
mock_hackrf_proc = MagicMock()
mock_hackrf_proc.poll.return_value = None
mock_hackrf_proc.stdout = MagicMock()
mock_hackrf_proc.stderr = MagicMock()
mock_hackrf_proc.stderr.readline = MagicMock(return_value=b'')
mock_rtl433_proc = MagicMock()
mock_rtl433_proc.poll.return_value = None
mock_rtl433_proc.stdout = MagicMock()
mock_rtl433_proc.stderr = MagicMock()
mock_rtl433_proc.stderr.readline = MagicMock(return_value=b'')
call_count = [0]
def popen_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
return mock_hackrf_proc
return mock_rtl433_proc
with patch('shutil.which', return_value='/usr/bin/tool'), \
patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \
patch('utils.subghz.register_process'):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(
frequency_hz=433920000,
sample_rate=2000000,
)
assert result['status'] == 'started'
assert result['frequency_hz'] == 433920000
assert manager.active_mode == 'decode'
# Two processes: hackrf_transfer + rtl_433
assert mock_popen.call_count == 2
# Verify hackrf_transfer command
hackrf_cmd = mock_popen.call_args_list[0][0][0]
assert hackrf_cmd[0] == 'hackrf_transfer'
assert '-r' in hackrf_cmd
# Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert rtl433_cmd[0] == 'rtl_433'
assert '-r' in rtl433_cmd
assert 'cs8:-' in rtl433_cmd
# Both processes tracked
assert manager._decode_hackrf_process is mock_hackrf_proc
assert manager._decode_process is mock_rtl433_proc
# Signal daemon threads to stop so they don't outlive the test
manager._decode_stop = True
def test_stop_decode_not_running(self, manager):
result = manager.stop_decode()
assert result['status'] == 'not_running'
def test_stop_decode_terminates_both(self, manager):
mock_hackrf = MagicMock()
mock_hackrf.poll.return_value = None
mock_rtl433 = MagicMock()
mock_rtl433.poll.return_value = None
manager._decode_hackrf_process = mock_hackrf
manager._decode_process = mock_rtl433
manager._decode_frequency_hz = 433920000
with patch('utils.subghz.safe_terminate') as mock_term, \
patch('utils.subghz.unregister_process'):
result = manager.stop_decode()
assert result['status'] == 'stopped'
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert mock_term.call_count == 2
class TestStopAll:
def test_stop_all_clears_processes(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
with patch('utils.subghz.safe_terminate'):
manager.stop_all()
assert manager._rx_process is None
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert manager._tx_process is None
assert manager._sweep_process is None
class TestSubGhzCapture:
def test_to_dict(self):
cap = SubGhzCapture(
capture_id='abc123',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
duration_seconds=5.0,
size_bytes=1024,
label='Test',
)
d = cap.to_dict()
assert d['id'] == 'abc123'
assert d['frequency_hz'] == 433920000
assert d['label'] == 'Test'
+433
View File
@@ -0,0 +1,433 @@
"""Tests for SubGHz transceiver routes."""
from __future__ import annotations
import json
from unittest.mock import patch, MagicMock
import pytest
from utils.subghz import SubGhzCapture
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
class TestSubGhzRoutes:
"""Tests for /subghz/ endpoints."""
def test_get_status(self, client, auth_client):
"""GET /subghz/status returns manager status."""
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_status.return_value = {
'mode': 'idle',
'hackrf_available': True,
'rtl433_available': True,
'sweep_available': True,
}
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/status')
assert response.status_code == 200
data = response.get_json()
assert data['mode'] == 'idle'
assert data['hackrf_available'] is True
def test_get_presets(self, client, auth_client):
"""GET /subghz/presets returns frequency presets."""
response = auth_client.get('/subghz/presets')
assert response.status_code == 200
data = response.get_json()
assert 'presets' in data
assert '433.92 MHz' in data['presets']
assert 'sample_rates' in data
# ------ RECEIVE ------
def test_start_receive_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {
'status': 'started',
'frequency_hz': 433920000,
'sample_rate': 2000000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'sample_rate': 2000000,
'lna_gain': 32,
'vga_gain': 20,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_receive_missing_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={})
assert response.status_code == 400
data = response.get_json()
assert data['status'] == 'error'
def test_start_receive_invalid_frequency(self, client, auth_client):
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 'not_a_number',
})
assert response.status_code == 400
def test_stop_receive(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_receive.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/stop')
assert response.status_code == 200
def test_start_receive_trigger_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_receive.return_value = {'status': 'started'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/receive/start', json={
'frequency_hz': 433920000,
'trigger_enabled': True,
'trigger_pre_ms': 400,
'trigger_post_ms': 900,
})
assert response.status_code == 200
kwargs = mock_mgr.start_receive.call_args.kwargs
assert kwargs['trigger_enabled'] is True
assert kwargs['trigger_pre_ms'] == 400
assert kwargs['trigger_post_ms'] == 900
# ------ DECODE ------
def test_start_decode_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
mock_mgr.start_decode.assert_called_once()
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'weather'
def test_start_decode_profile_all(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_decode.return_value = {
'status': 'started',
'frequency_hz': 433920000,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/start', json={
'frequency_hz': 433920000,
'decode_profile': 'all',
})
assert response.status_code == 200
kwargs = mock_mgr.start_decode.call_args.kwargs
assert kwargs['decode_profile'] == 'all'
def test_start_decode_missing_freq(self, client, auth_client):
response = auth_client.post('/subghz/decode/start', json={})
assert response.status_code == 400
def test_stop_decode(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_decode.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/decode/stop')
assert response.status_code == 200
# ------ TRANSMIT ------
def test_transmit_missing_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={})
assert response.status_code == 400
data = response.get_json()
assert 'capture_id is required' in data['message']
def test_transmit_invalid_capture_id(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': '../../../etc/passwd',
})
assert response.status_code == 400
data = response.get_json()
assert 'Invalid' in data['message']
def test_transmit_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'transmitting'
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] is None
assert kwargs['duration_seconds'] is None
def test_transmit_segment_params(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.transmit.return_value = {
'status': 'transmitting',
'capture_id': 'abc123',
'frequency_hz': 433920000,
'max_duration': 10,
'segment': {'start_seconds': 0.1, 'duration_seconds': 0.4},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'tx_gain': 20,
'max_duration': 10,
'start_seconds': 0.1,
'duration_seconds': 0.4,
})
assert response.status_code == 200
kwargs = mock_mgr.transmit.call_args.kwargs
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.4
def test_transmit_invalid_segment_param(self, client, auth_client):
response = auth_client.post('/subghz/transmit', json={
'capture_id': 'abc123',
'start_seconds': 'not-a-number',
})
assert response.status_code == 400
def test_stop_transmit(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_transmit.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/transmit/stop')
assert response.status_code == 200
# ------ SWEEP ------
def test_start_sweep_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.start_sweep.return_value = {
'status': 'started',
'freq_start_mhz': 300,
'freq_end_mhz': 928,
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 300,
'freq_end_mhz': 928,
})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
def test_start_sweep_invalid_range(self, client, auth_client):
response = auth_client.post('/subghz/sweep/start', json={
'freq_start_mhz': 928,
'freq_end_mhz': 300, # start > end
})
assert response.status_code == 400
def test_stop_sweep(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.stop_sweep.return_value = {'status': 'stopped'}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/sweep/stop')
assert response.status_code == 200
# ------ CAPTURES ------
def test_list_captures_empty(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.list_captures.return_value = []
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 0
assert data['captures'] == []
def test_list_captures_with_data(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap1',
filename='test.iq',
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.list_captures.return_value = [cap]
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures')
assert response.status_code == 200
data = response.get_json()
assert data['count'] == 1
assert data['captures'][0]['id'] == 'cap1'
def test_get_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
cap = SubGhzCapture(
capture_id='cap2',
filename='test2.iq',
frequency_hz=315000000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp='2026-01-01T00:00:00Z',
)
mock_mgr.get_capture.return_value = cap
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/cap2')
assert response.status_code == 200
data = response.get_json()
assert data['capture']['frequency_hz'] == 315000000
def test_get_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.get_capture.return_value = None
mock_get.return_value = mock_mgr
response = auth_client.get('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_get_capture_invalid_id(self, client, auth_client):
response = auth_client.get('/subghz/captures/bad-id!')
assert response.status_code == 400
def test_delete_capture(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/cap1')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'deleted'
def test_trim_capture_success(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.trim_capture.return_value = {
'status': 'ok',
'capture': {
'id': 'trim_new',
'filename': 'trimmed.iq',
'frequency_hz': 433920000,
'sample_rate': 2000000,
},
}
mock_get.return_value = mock_mgr
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 0.1,
'duration_seconds': 0.3,
})
assert response.status_code == 200
kwargs = mock_mgr.trim_capture.call_args.kwargs
assert kwargs['capture_id'] == 'cap1'
assert kwargs['start_seconds'] == 0.1
assert kwargs['duration_seconds'] == 0.3
def test_trim_capture_invalid_param(self, client, auth_client):
response = auth_client.post('/subghz/captures/cap1/trim', json={
'start_seconds': 'bad',
})
assert response.status_code == 400
def test_delete_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.delete_capture.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.delete('/subghz/captures/nonexistent')
assert response.status_code == 404
def test_update_capture_label(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = True
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'Garage Remote',
})
assert response.status_code == 200
data = response.get_json()
assert data['label'] == 'Garage Remote'
def test_update_capture_label_too_long(self, client, auth_client):
response = auth_client.patch('/subghz/captures/cap1', json={
'label': 'x' * 200,
})
assert response.status_code == 400
def test_update_capture_not_found(self, client, auth_client):
with patch('routes.subghz.get_subghz_manager') as mock_get:
mock_mgr = MagicMock()
mock_mgr.update_capture_label.return_value = False
mock_get.return_value = mock_mgr
response = auth_client.patch('/subghz/captures/nonexistent', json={
'label': 'test',
})
assert response.status_code == 404
# ------ SSE STREAM ------
def test_stream_endpoint(self, client, auth_client):
"""GET /subghz/stream returns SSE response."""
with patch('routes.subghz.sse_stream', return_value=iter([])):
response = auth_client.get('/subghz/stream')
assert response.status_code == 200
assert response.content_type.startswith('text/event-stream')
+44
View File
@@ -256,6 +256,50 @@ MAX_DSC_MESSAGE_AGE_SECONDS = 3600 # 1 hour
DSC_TERMINATE_TIMEOUT = 3 DSC_TERMINATE_TIMEOUT = 3
# =============================================================================
# SUBGHZ TRANSCEIVER (HackRF)
# =============================================================================
# Allowed ISM TX frequency bands (MHz) - transmit only within these ranges
SUBGHZ_TX_ALLOWED_BANDS = [
(300.0, 348.0), # 315 MHz ISM band
(387.0, 464.0), # 433 MHz ISM band
(779.0, 928.0), # 868/915 MHz ISM band
]
# HackRF frequency limits (MHz)
SUBGHZ_FREQ_MIN_MHZ = 1.0
SUBGHZ_FREQ_MAX_MHZ = 6000.0
# HackRF gain ranges
SUBGHZ_LNA_GAIN_MIN = 0
SUBGHZ_LNA_GAIN_MAX = 40
SUBGHZ_VGA_GAIN_MIN = 0
SUBGHZ_VGA_GAIN_MAX = 62
SUBGHZ_TX_VGA_GAIN_MIN = 0
SUBGHZ_TX_VGA_GAIN_MAX = 47
# Default sample rates available (Hz)
SUBGHZ_SAMPLE_RATES = [2000000, 4000000, 8000000, 10000000, 20000000]
# Maximum TX duration watchdog (seconds)
SUBGHZ_TX_MAX_DURATION = 30
# Sweep defaults
SUBGHZ_SWEEP_BIN_WIDTH = 100000 # 100 kHz bins
# SubGHz process termination timeout
SUBGHZ_TERMINATE_TIMEOUT = 3
# Common SubGHz preset frequencies (MHz)
SUBGHZ_PRESETS = {
'315 MHz': 315.0,
'433.92 MHz': 433.92,
'868 MHz': 868.0,
'915 MHz': 915.0,
}
# ============================================================================= # =============================================================================
# DEAUTH ATTACK DETECTION # DEAUTH ATTACK DETECTION
# ============================================================================= # =============================================================================
+32
View File
@@ -394,6 +394,38 @@ TOOL_DEPENDENCIES = {
} }
} }
}, },
'subghz': {
'name': 'SubGHz Transceiver',
'tools': {
'hackrf_transfer': {
'required': True,
'description': 'HackRF IQ capture and replay',
'install': {
'apt': 'sudo apt install hackrf',
'brew': 'brew install hackrf',
'manual': 'https://github.com/greatscottgadgets/hackrf'
}
},
'hackrf_sweep': {
'required': False,
'description': 'HackRF wideband spectrum sweep',
'install': {
'apt': 'sudo apt install hackrf',
'brew': 'brew install hackrf',
'manual': 'https://github.com/greatscottgadgets/hackrf'
}
},
'rtl_433': {
'required': False,
'description': 'Protocol decoder for SubGHz signals',
'install': {
'apt': 'sudo apt install rtl-433',
'brew': 'brew install rtl_433',
'manual': 'https://github.com/merbanan/rtl_433'
}
}
}
},
'tscm': { 'tscm': {
'name': 'TSCM Counter-Surveillance', 'name': 'TSCM Counter-Surveillance',
'tools': { 'tools': {
+31
View File
@@ -10,12 +10,28 @@ import logging
import re import re
import shutil import shutil
import subprocess import subprocess
import time
from typing import Optional from typing import Optional
from .base import SDRCapabilities, SDRDevice, SDRType from .base import SDRCapabilities, SDRDevice, SDRType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache HackRF detection results so polling endpoints don't repeatedly run
# hackrf_info while the device is actively streaming in SubGHz mode.
_hackrf_cache: list[SDRDevice] = []
_hackrf_cache_ts: float = 0.0
_HACKRF_CACHE_TTL_SECONDS = 3.0
def _hackrf_probe_blocked() -> bool:
"""Return True when probing HackRF would interfere with an active stream."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
except Exception:
return False
def _check_tool(name: str) -> bool: def _check_tool(name: str) -> bool:
"""Check if a tool is available in PATH.""" """Check if a tool is available in PATH."""
@@ -301,9 +317,22 @@ def detect_hackrf_devices() -> list[SDRDevice]:
Fallback for when SoapySDR is not available. Fallback for when SoapySDR is not available.
""" """
global _hackrf_cache, _hackrf_cache_ts
now = time.time()
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
if _hackrf_probe_blocked():
return list(_hackrf_cache)
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
return list(_hackrf_cache)
devices: list[SDRDevice] = [] devices: list[SDRDevice] = []
if not _check_tool('hackrf_info'): if not _check_tool('hackrf_info'):
_hackrf_cache = devices
_hackrf_cache_ts = now
return devices return devices
try: try:
@@ -345,6 +374,8 @@ def detect_hackrf_devices() -> list[SDRDevice]:
except Exception as e: except Exception as e:
logger.debug(f"HackRF detection error: {e}") logger.debug(f"HackRF detection error: {e}")
_hackrf_cache = list(devices)
_hackrf_cache_ts = now
return devices return devices
+8 -1
View File
@@ -17,6 +17,12 @@ from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.sdr.rtlsdr') logger = logging.getLogger('intercept.sdr.rtlsdr')
def _rtl_fm_demod_mode(modulation: str) -> str:
"""Map app/UI modulation names to rtl_fm demod tokens."""
mod = str(modulation or '').lower().strip()
return 'wbfm' if mod == 'wfm' else mod
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]: def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
"""Detect the correct bias-t flag for the installed dump1090 variant. """Detect the correct bias-t flag for the installed dump1090 variant.
@@ -88,11 +94,12 @@ class RTLSDRCommandBuilder(CommandBuilder):
Used for pager decoding. Supports local devices and rtl_tcp connections. Used for pager decoding. Supports local devices and rtl_tcp connections.
""" """
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm' rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
demod_mode = _rtl_fm_demod_mode(modulation)
cmd = [ cmd = [
rtl_fm_path, rtl_fm_path,
'-d', self._get_device_arg(device), '-d', self._get_device_arg(device),
'-f', f'{frequency_mhz}M', '-f', f'{frequency_mhz}M',
'-M', modulation, '-M', demod_mode,
'-s', str(sample_rate), '-s', str(sample_rate),
] ]
+4
View File
@@ -311,6 +311,10 @@ class VISDetector:
if len(self._data_bits) != 8: if len(self._data_bits) != 8:
return None return None
# VIS uses even parity across 8 data bits + parity bit.
if (sum(self._data_bits) + self._parity_bit) % 2 != 0:
return None
# Decode VIS code (LSB first) # Decode VIS code (LSB first)
vis_code = 0 vis_code = 0
for i, bit in enumerate(self._data_bits): for i, bit in enumerate(self._data_bits):
+2809
View File
File diff suppressed because it is too large Load Diff
+19 -9
View File
@@ -3,10 +3,11 @@
Provides automated capture and decoding of weather satellite images using SatDump. Provides automated capture and decoding of weather satellite images using SatDump.
Supported satellites: Supported satellites:
- NOAA-15: 137.620 MHz (APT) - NOAA-15: 137.620 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
- NOAA-18: 137.9125 MHz (APT) - NOAA-18: 137.9125 MHz (APT) [DEFUNCT - decommissioned Jun 2025]
- NOAA-19: 137.100 MHz (APT) - NOAA-19: 137.100 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
- Meteor-M2-3: 137.900 MHz (LRPT) - Meteor-M2-3: 137.900 MHz (LRPT)
- Meteor-M2-4: 137.900 MHz (LRPT)
Uses SatDump CLI for live SDR capture and decoding, with fallback to Uses SatDump CLI for live SDR capture and decoding, with fallback to
rtl_fm capture for manual decoding when SatDump is unavailable. rtl_fm capture for manual decoding when SatDump is unavailable.
@@ -42,8 +43,8 @@ WEATHER_SATELLITES = {
'mode': 'APT', 'mode': 'APT',
'pipeline': 'noaa_apt', 'pipeline': 'noaa_apt',
'tle_key': 'NOAA-15', 'tle_key': 'NOAA-15',
'description': 'NOAA-15 APT (analog weather imagery)', 'description': 'NOAA-15 APT (decommissioned Aug 2025)',
'active': True, 'active': False,
}, },
'NOAA-18': { 'NOAA-18': {
'name': 'NOAA 18', 'name': 'NOAA 18',
@@ -51,8 +52,8 @@ WEATHER_SATELLITES = {
'mode': 'APT', 'mode': 'APT',
'pipeline': 'noaa_apt', 'pipeline': 'noaa_apt',
'tle_key': 'NOAA-18', 'tle_key': 'NOAA-18',
'description': 'NOAA-18 APT (analog weather imagery)', 'description': 'NOAA-18 APT (decommissioned Jun 2025)',
'active': True, 'active': False,
}, },
'NOAA-19': { 'NOAA-19': {
'name': 'NOAA 19', 'name': 'NOAA 19',
@@ -60,8 +61,8 @@ WEATHER_SATELLITES = {
'mode': 'APT', 'mode': 'APT',
'pipeline': 'noaa_apt', 'pipeline': 'noaa_apt',
'tle_key': 'NOAA-19', 'tle_key': 'NOAA-19',
'description': 'NOAA-19 APT (analog weather imagery)', 'description': 'NOAA-19 APT (decommissioned Aug 2025)',
'active': True, 'active': False,
}, },
'METEOR-M2-3': { 'METEOR-M2-3': {
'name': 'Meteor-M2-3', 'name': 'Meteor-M2-3',
@@ -72,6 +73,15 @@ WEATHER_SATELLITES = {
'description': 'Meteor-M2-3 LRPT (digital color imagery)', 'description': 'Meteor-M2-3 LRPT (digital color imagery)',
'active': True, 'active': True,
}, },
'METEOR-M2-4': {
'name': 'Meteor-M2-4',
'frequency': 137.900,
'mode': 'LRPT',
'pipeline': 'meteor_m2-x_lrpt',
'tle_key': 'METEOR-M2-4',
'description': 'Meteor-M2-4 LRPT (digital color imagery)',
'active': True,
},
} }
# Default sample rate for weather satellite reception # Default sample rate for weather satellite reception