Compare commits

...

7 Commits

Author SHA1 Message Date
Smittix 257de37dfe Update SDR device claims 2026-02-10 18:07:52 +00:00
Smittix b4c47ed28b Add DMR quality meter and fine tune sweep 2026-02-10 15:24:49 +00:00
Smittix cbcb8b02fa Add fine tune offset for digital voice 2026-02-10 12:11:32 +00:00
Smittix 0a02325c0c Add demodulation toggle for digital voice 2026-02-10 12:02:08 +00:00
Smittix 9bfbd6231d Add P25 Phase 2 option for DMR decoder 2026-02-10 11:43:13 +00:00
Smittix 191344f41b Parse dsd-fme JSON output for DMR events 2026-02-10 11:16:17 +00:00
Smittix dcb2488f47 Fix DMR audio mux startup order 2026-02-10 10:08:39 +00:00
13 changed files with 924 additions and 447 deletions
+69 -51
View File
@@ -235,62 +235,77 @@ 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: mode_name (str) # Key: device_index (int), Value: {sdr_type: mode_name}
sdr_device_registry: dict[int, str] = {} sdr_device_registry: dict[int, dict[str, str]] = {}
sdr_device_registry_lock = threading.Lock() sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None: def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> 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
catch stale handles held by external processes (e.g. a leftover catch stale handles held by external processes (e.g. a leftover
rtl_fm from a previous crash). rtl_fm from a previous crash).
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
""" """
with sdr_device_registry_lock: sdr_type_key = str(sdr_type or 'rtlsdr').lower()
if device_index in sdr_device_registry:
in_use_by = sdr_device_registry[device_index] with sdr_device_registry_lock:
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' device_entry = sdr_device_registry.get(device_index, {})
if sdr_type_key in device_entry:
# Probe the USB device to catch external processes holding the handle in_use_by = device_entry[sdr_type_key]
try: return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
from utils.sdr.detection import probe_rtlsdr_device
usb_error = probe_rtlsdr_device(device_index) # Probe the USB device to catch external processes holding the handle
if usb_error: # Only relevant for RTL-SDR devices
return usb_error if sdr_type_key == 'rtlsdr':
except Exception: try:
pass # If probe fails, let the caller proceed normally from utils.sdr.detection import probe_rtlsdr_device
usb_error = probe_rtlsdr_device(device_index)
sdr_device_registry[device_index] = mode_name if usb_error:
return None return usb_error
except Exception:
pass # If probe fails, let the caller proceed normally
def release_sdr_device(device_index: int) -> None:
"""Release an SDR device from the registry. if device_index not in sdr_device_registry:
sdr_device_registry[device_index] = {}
sdr_device_registry[device_index][sdr_type_key] = mode_name
return None
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
"""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')
with sdr_device_registry_lock: """
sdr_device_registry.pop(device_index, None) sdr_type_key = str(sdr_type or 'rtlsdr').lower()
with sdr_device_registry_lock:
entry = sdr_device_registry.get(device_index)
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, str]: def get_sdr_device_status() -> dict[int, dict[str, str]]:
"""Get current SDR device allocations. """Get current SDR device allocations.
Returns: Returns:
Dictionary mapping device indices to mode names Dictionary mapping device indices to {sdr_type: mode_name}
""" """
with sdr_device_registry_lock: with sdr_device_registry_lock:
return dict(sdr_device_registry) return {idx: dict(modes) for idx, modes in sdr_device_registry.items()}
# ============================================ # ============================================
@@ -388,17 +403,20 @@ def get_devices() -> Response:
@app.route('/devices/status') @app.route('/devices/status')
def get_devices_status() -> Response: def get_devices_status() -> Response:
"""Get all SDR devices with usage status.""" """Get all SDR devices with usage status."""
devices = SDRFactory.detect_devices() devices = SDRFactory.detect_devices()
registry = get_sdr_device_status() registry = get_sdr_device_status()
result = [] result = []
for device in devices: for device in devices:
d = device.to_dict() d = device.to_dict()
d['in_use'] = device.index in registry sdr_type_key = device.sdr_type.value if hasattr(device.sdr_type, 'value') else str(device.sdr_type)
d['used_by'] = registry.get(device.index) sdr_type_key = str(sdr_type_key).lower()
result.append(d) 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)
return jsonify(result) return jsonify(result)
+111 -86
View File
@@ -673,13 +673,14 @@ class ModeManager:
def get_status(self) -> dict: def get_status(self) -> dict:
"""Get overall agent status.""" """Get overall agent status."""
# Build running modes with device info for multi-SDR tracking # Build running modes with device info for multi-SDR tracking
running_modes_detail = {} running_modes_detail = {}
for mode, info in self.running_modes.items(): for mode, info in self.running_modes.items():
params = info.get('params', {}) params = info.get('params', {})
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 = {
'running_modes': list(self.running_modes.keys()), 'running_modes': list(self.running_modes.keys()),
@@ -698,22 +699,24 @@ 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) -> str | None: def get_sdr_in_use(self, device: int = 0, sdr_type: str = 'rtlsdr') -> 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.
""" """
for mode, info in self.running_modes.items(): sdr_type_key = str(sdr_type or 'rtlsdr').lower()
if mode in self.SDR_MODES: for mode, info in self.running_modes.items():
mode_device = info.get('params', {}).get('device', 0) if mode in self.SDR_MODES:
# Normalize to int for comparison mode_device = info.get('params', {}).get('device', 0)
try: mode_sdr_type = str(info.get('params', {}).get('sdr_type', 'rtlsdr')).lower()
mode_device = int(mode_device) # Normalize to int for comparison
except (ValueError, TypeError): try:
mode_device = 0 mode_device = int(mode_device)
if mode_device == device: except (ValueError, TypeError):
return mode mode_device = 0
return None if mode_device == device and mode_sdr_type == sdr_type_key:
return mode
return None
def start_mode(self, mode: str, params: dict) -> dict: def start_mode(self, mode: str, params: dict) -> dict:
"""Start a mode with given parameters.""" """Start a mode with given parameters."""
@@ -725,18 +728,19 @@ class ModeManager:
return {'status': 'error', 'message': f'{mode} not available (missing tools)'} return {'status': 'error', 'message': f'{mode} not available (missing tools)'}
# Check SDR device conflicts for SDR-based modes # Check SDR device conflicts for SDR-based modes
if mode in self.SDR_MODES: if mode in self.SDR_MODES:
device = params.get('device', 0) device = params.get('device', 0)
try: try:
device = int(device) device = int(device)
except (ValueError, TypeError): except (ValueError, TypeError):
device = 0 device = 0
in_use_by = self.get_sdr_in_use(device) sdr_type = str(params.get('sdr_type', 'rtlsdr')).lower()
if in_use_by: in_use_by = self.get_sdr_in_use(device, sdr_type)
return { if in_use_by:
'status': 'error', return {
'message': f'SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' 'status': 'error',
} 'message': f'SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
}
# Initialize lock if needed # Initialize lock if needed
if mode not in self.locks: if mode not in self.locks:
@@ -1097,10 +1101,15 @@ class ModeManager:
if mode in self.data_snapshots: if mode in self.data_snapshots:
del self.data_snapshots[mode] del self.data_snapshots[mode]
# Mode-specific cleanup # Mode-specific cleanup
if mode == 'adsb': if mode == 'adsb':
self.adsb_aircraft.clear() self.adsb_aircraft.clear()
elif mode == 'wifi': 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':
self.wifi_networks.clear() self.wifi_networks.clear()
self.wifi_clients.clear() self.wifi_clients.clear()
elif mode == 'bluetooth': elif mode == 'bluetooth':
@@ -1311,14 +1320,20 @@ class ModeManager:
"""Start dump1090 ADS-B mode using Intercept's utilities.""" """Start dump1090 ADS-B mode using Intercept's utilities."""
gain = params.get('gain', '40') gain = params.get('gain', '40')
device = params.get('device', '0') device = params.get('device', '0')
bias_t = params.get('bias_t', False) bias_t = params.get('bias_t', False)
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:
return self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port) result = self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port, source_tag='adsb', thread_name='adsb')
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:
@@ -1326,9 +1341,13 @@ class ModeManager:
sock.settimeout(1.0) sock.settimeout(1.0)
result = sock.connect_ex(('localhost', 30003)) result = sock.connect_ex(('localhost', 30003))
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")
return self._start_adsb_sbs_connection('localhost', 30003) result = self._start_adsb_sbs_connection('localhost', 30003, source_tag='adsb', thread_name='adsb')
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
@@ -1380,12 +1399,16 @@ class ModeManager:
# Wait for dump1090 to start # Wait for dump1090 to start
time.sleep(2) time.sleep(2)
if proc.poll() is not None: if proc.poll() is not None:
stderr = proc.stderr.read().decode('utf-8', errors='ignore') stderr = proc.stderr.read().decode('utf-8', errors='ignore')
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
return self._start_adsb_sbs_connection('localhost', 30003) result = self._start_adsb_sbs_connection('localhost', 30003, source_tag='adsb', thread_name='adsb')
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'}
@@ -1414,27 +1437,27 @@ class ModeManager:
return path return path
return None return None
def _start_adsb_sbs_connection(self, host: str, port: int) -> dict: def _start_adsb_sbs_connection(self, host: str, port: int, *, source_tag: str = 'adsb', thread_name: str = 'adsb') -> 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), args=(host, port, source_tag),
daemon=True daemon=True
) )
thread.start() thread.start()
self.output_threads['adsb'] = thread self.output_threads[thread_name] = thread
return { return {
'status': 'started', 'status': 'started',
'mode': 'adsb', 'mode': 'adsb',
'sbs_source': f'{host}:{port}', 'sbs_source': f'{host}:{port}',
'gps_enabled': gps_manager.is_running 'gps_enabled': gps_manager.is_running
} }
def _adsb_sbs_reader(self, host: str, port: int): def _adsb_sbs_reader(self, host: str, port: int, source_tag: str = 'adsb'):
"""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)
retry_count = 0 retry_count = 0
max_retries = 5 max_retries = 5
@@ -1443,8 +1466,8 @@ 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}") logger.info(f"Connected to SBS at {host}:{port} ({source_tag})")
retry_count = 0 retry_count = 0
buffer = "" buffer = ""
sock.settimeout(1.0) sock.settimeout(1.0)
@@ -1458,7 +1481,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()) self._parse_sbs_line(line.strip(), source_tag)
except socket.timeout: except socket.timeout:
continue continue
@@ -1475,10 +1498,10 @@ class ModeManager:
logger.info("ADS-B SBS reader stopped") logger.info("ADS-B SBS reader stopped")
def _parse_sbs_line(self, line: str): def _parse_sbs_line(self, line: str, source_tag: str = 'adsb'):
"""Parse SBS format line and update aircraft dict.""" """Parse SBS format line and update aircraft dict."""
if not line: if not line:
return return
parts = line.split(',') parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG': if len(parts) < 11 or parts[0] != 'MSG':
@@ -1503,12 +1526,14 @@ class ModeManager:
if callsign: if callsign:
aircraft['callsign'] = callsign aircraft['callsign'] = callsign
elif msg_type == '3' and len(parts) > 15: elif msg_type == '3' and len(parts) > 15:
if parts[11]: if parts[11]:
aircraft['altitude'] = int(float(parts[11])) aircraft['altitude'] = int(float(parts[11]))
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]:
+83 -25
View File
@@ -35,6 +35,9 @@ 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
@@ -71,7 +74,10 @@ 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()
@@ -318,7 +324,29 @@ def check_dump1090_service():
return None return None
def parse_sbs_stream(service_addr): def _reset_adsb_state() -> None:
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
@@ -327,26 +355,23 @@ def parse_sbs_stream(service_addr):
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, connecting to {host}:{port}") logger.info(f"SBS stream parser started ({source_label}), 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))
adsb_connected = True _set_adsb_connected(service_addr, True)
_sbs_error_logged = False # Reset so we log next error _sbs_error_logged = False # Reset so we log next error
logger.info("Connected to SBS stream") logger.info(f"Connected to SBS stream ({source_label})")
buffer = "" buffer = ""
last_update = time.time() last_update = time.time()
pending_updates = set() pending_updates = set()
adsb_bytes_received = 0 local_lines_received = 0
adsb_lines_received = 0
while adsb_using_service: while adsb_using_service:
try: try:
@@ -364,13 +389,14 @@ def parse_sbs_stream(service_addr):
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 adsb_lines_received <= 3: if local_lines_received <= 3:
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}") logger.info(f"SBS line ({source_label}) {local_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 adsb_lines_received <= 5: if local_lines_received <= 5:
logger.debug(f"Skipping non-MSG line: {line[:50]}") logger.debug(f"Skipping non-MSG line: {line[:50]}")
continue continue
@@ -421,6 +447,8 @@ def parse_sbs_stream(service_addr):
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
@@ -494,18 +522,26 @@ def parse_sbs_stream(service_addr):
continue continue
sock.close() sock.close()
adsb_connected = False _set_adsb_connected(service_addr, False)
except OSError as e: except OSError as e:
adsb_connected = False _set_adsb_connected(service_addr, 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)
adsb_connected = False _set_adsb_connected(service_addr, 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."""
@@ -580,7 +616,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 global adsb_using_service, adsb_active_device, adsb_active_sdr_type
with app_module.adsb_lock: with app_module.adsb_lock:
if adsb_using_service: if adsb_using_service:
@@ -601,10 +637,22 @@ 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
@@ -617,8 +665,10 @@ 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,), daemon=True) thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr, '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,
sdr_type='remote', sdr_type='remote',
@@ -638,8 +688,10 @@ 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,), daemon=True) thread = threading.Thread(target=parse_sbs_stream, args=(existing_service, '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,
sdr_type='external', sdr_type='external',
@@ -689,7 +741,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') error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type.value)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -726,7 +778,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) app_module.release_sdr_device(device_int, sdr_type.value)
stderr_output = '' stderr_output = ''
if app_module.adsb_process.stderr: if app_module.adsb_process.stderr:
try: try:
@@ -772,9 +824,12 @@ def start_adsb():
}) })
adsb_using_service = True adsb_using_service = True
adsb_active_device = device # Track which device is being used adsb_active_device = device # Track which device index is being used
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) adsb_active_sdr_type = sdr_type.value
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,
@@ -792,14 +847,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) app_module.release_sdr_device(device_int, sdr_type.value)
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 global adsb_using_service, adsb_active_device, adsb_active_sdr_type
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
@@ -823,10 +878,12 @@ 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) app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr')
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()
@@ -868,6 +925,7 @@ 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,
) )
+8 -5
View File
@@ -44,6 +44,7 @@ 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
@@ -326,7 +327,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 global ais_running, ais_active_device, ais_active_sdr_type
with app_module.ais_lock: with app_module.ais_lock:
if ais_running: if ais_running:
@@ -373,7 +374,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') error = app_module.claim_sdr_device(device_int, 'ais', sdr_type.value)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
@@ -412,7 +413,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) app_module.release_sdr_device(device_int, sdr_type.value)
stderr_output = '' stderr_output = ''
if app_module.ais_process.stderr: if app_module.ais_process.stderr:
try: try:
@@ -426,6 +427,7 @@ 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)
@@ -439,7 +441,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) app_module.release_sdr_device(device_int, sdr_type.value)
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
@@ -466,10 +468,11 @@ 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) app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
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'})
+134 -7
View File
@@ -3,6 +3,7 @@
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
@@ -50,7 +51,8 @@ dmr_active_device: Optional[int] = None
# freeze stderr / text data output). # freeze stderr / text data output).
_active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint _active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice'] VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'p25p2', 'nxdn', 'dstar', 'provoice']
VALID_DEMODS = ['nfm', 'fm']
# Classic dsd flags # Classic dsd flags
_DSD_PROTOCOL_FLAGS = { _DSD_PROTOCOL_FLAGS = {
@@ -71,6 +73,7 @@ _DSD_FME_PROTOCOL_FLAGS = {
'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF 'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF
'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': ['-f1'], # P25 Phase 1 (-fp is ProVoice 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)
@@ -80,7 +83,8 @@ _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; Phase 2 would use -mq) 'p25': ['-mc'], # C4FM (Phase 1)
'p25p2': ['-mq'], # CQPSK (Phase 2)
'nxdn': ['-mc'], # C4FM 'nxdn': ['-mc'], # C4FM
} }
@@ -114,6 +118,78 @@ def find_ffmpeg() -> str | None:
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.
@@ -124,6 +200,46 @@ 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
@@ -134,8 +250,6 @@ 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:
@@ -398,11 +512,19 @@ 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
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 protocol == 'p25p2' and not is_fme:
return jsonify({'status': 'error', 'message': 'P25 Phase 2 requires dsd-fme.'}), 400
if abs(fine_tune) > 20000:
return jsonify({'status': 'error', 'message': 'Fine tune offset too large (max +/- 20000 Hz).'}), 400
# Clear stale queue # Clear stale queue
try: try:
@@ -420,7 +542,7 @@ def start_dmr() -> Response:
dmr_active_device = device dmr_active_device = device
freq_hz = int(frequency * 1e6) freq_hz = int((frequency * 1e6) + fine_tune)
# Build rtl_fm command (48kHz sample rate for DSD). # Build rtl_fm command (48kHz sample rate for DSD).
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream # Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream
@@ -428,7 +550,7 @@ def start_dmr() -> Response:
# internally via its own frame-sync detection. # internally via its own frame-sync detection.
rtl_cmd = [ rtl_cmd = [
rtl_fm_path, rtl_fm_path,
'-M', 'fm', '-M', demod,
'-f', str(freq_hz), '-f', str(freq_hz),
'-s', '48000', '-s', '48000',
'-g', str(gain), '-g', str(gain),
@@ -484,6 +606,9 @@ 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.
@@ -509,6 +634,7 @@ def start_dmr() -> Response:
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 unregister all
dmr_running = False
dmr_has_audio = False dmr_has_audio = False
for proc in [dmr_dsd_process, dmr_rtl_process]: for proc in [dmr_dsd_process, dmr_rtl_process]:
if proc and proc.poll() is None: if proc and proc.poll() is None:
@@ -547,7 +673,6 @@ def start_dmr() -> Response:
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start() threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
dmr_running = True
dmr_thread = threading.Thread( dmr_thread = threading.Thread(
target=stream_dsd_output, target=stream_dsd_output,
args=(dmr_rtl_process, dmr_dsd_process), args=(dmr_rtl_process, dmr_dsd_process),
@@ -564,6 +689,8 @@ def start_dmr() -> Response:
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
dmr_has_audio = False
if dmr_active_device is not None: if dmr_active_device is not None:
app_module.release_sdr_device(dmr_active_device) app_module.release_sdr_device(dmr_active_device)
dmr_active_device = None dmr_active_device = None
+99 -83
View File
@@ -46,13 +46,15 @@ audio_modulation = 'fm'
# Scanner state # Scanner state
scanner_thread: Optional[threading.Thread] = None scanner_thread: Optional[threading.Thread] = None
scanner_running = False scanner_running = False
scanner_lock = threading.Lock() 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
listening_active_device: Optional[int] = None scanner_active_sdr_type: Optional[str] = None
scanner_power_process: Optional[subprocess.Popen] = None listening_active_device: Optional[int] = None
listening_active_sdr_type: Optional[str] = None
scanner_power_process: Optional[subprocess.Popen] = None
scanner_config = { scanner_config = {
'start_freq': 88.0, 'start_freq': 88.0,
'end_freq': 108.0, 'end_freq': 108.0,
@@ -936,7 +938,8 @@ def check_tools() -> Response:
@listening_post_bp.route('/scanner/start', methods=['POST']) @listening_post_bp.route('/scanner/start', methods=['POST'])
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:
@@ -1002,21 +1005,23 @@ def start_scanner() -> Response:
'message': 'rtl_power not found. Install rtl-sdr tools.' 'message': 'rtl_power not found. Install rtl-sdr tools.'
}), 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) app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr')
listening_active_device = None listening_active_device = None
# Claim device for scanner listening_active_sdr_type = None
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') # Claim device for scanner
if error: error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type)
return jsonify({ if error:
'status': 'error', return jsonify({
'error_type': 'DEVICE_BUSY', 'status': 'error',
'message': error 'error_type': 'DEVICE_BUSY',
}), 409 'message': error
scanner_active_device = scanner_config['device'] }), 409
scanner_running = True scanner_active_device = scanner_config['device']
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True) scanner_active_sdr_type = sdr_type
scanner_thread.start() scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
scanner_thread.start()
else: else:
if sdr_type == 'rtlsdr': if sdr_type == 'rtlsdr':
if not find_rtl_fm(): if not find_rtl_fm():
@@ -1030,17 +1035,19 @@ def start_scanner() -> Response:
'status': 'error', 'status': 'error',
'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) app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr')
listening_active_device = None listening_active_device = None
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') listening_active_sdr_type = None
if error: error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type)
return jsonify({ if error:
'status': 'error', return jsonify({
'error_type': 'DEVICE_BUSY', 'status': 'error',
'message': error 'error_type': 'DEVICE_BUSY',
}), 409 'message': error
scanner_active_device = scanner_config['device'] }), 409
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)
@@ -1053,9 +1060,9 @@ 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 global scanner_running, scanner_active_device, scanner_power_process, scanner_active_sdr_type
scanner_running = False scanner_running = False
_stop_audio_stream() _stop_audio_stream()
@@ -1069,9 +1076,10 @@ def stop_scanner() -> Response:
except Exception: except Exception:
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) app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type or 'rtlsdr')
scanner_active_device = None scanner_active_device = None
scanner_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -1242,14 +1250,16 @@ def get_presets() -> Response:
@listening_post_bp.route('/audio/start', methods=['POST']) @listening_post_bp.route('/audio/start', methods=['POST'])
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) app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type or 'rtlsdr')
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)
@@ -1306,18 +1316,19 @@ 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: if waterfall_running and waterfall_active_device == device and waterfall_active_sdr_type == sdr_type:
_stop_waterfall_internal() _stop_waterfall_internal()
time.sleep(0.2) time.sleep(0.2)
# Claim device for listening audio. The WebSocket waterfall handler # Claim device for listening audio. The WebSocket waterfall handler
# may still be tearing down its IQ capture process (thread join + # may still be tearing down its IQ capture process (thread join +
# safe_terminate can take several seconds), so we retry with back-off # safe_terminate can take several seconds), so we retry with back-off
# 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) app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr')
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
@@ -1325,13 +1336,13 @@ def start_audio() -> Response:
# Force-release a stale waterfall registry entry on each # Force-release a stale waterfall registry entry on each
# 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) == 'waterfall': if device_status.get(device, {}).get(sdr_type) == 'waterfall':
app_module.release_sdr_device(device) app_module.release_sdr_device(device, sdr_type)
error = app_module.claim_sdr_device(device, 'listening') error = app_module.claim_sdr_device(device, 'listening', sdr_type)
if not error: if not error:
break break
if attempt < max_claim_attempts - 1: if attempt < max_claim_attempts - 1:
logger.debug( logger.debug(
f"Device claim attempt {attempt + 1}/{max_claim_attempts} " f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
@@ -1345,7 +1356,8 @@ def start_audio() -> Response:
'error_type': 'DEVICE_BUSY', 'error_type': 'DEVICE_BUSY',
'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)
@@ -1363,14 +1375,15 @@ 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 global listening_active_device, listening_active_sdr_type
_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) app_module.release_sdr_device(listening_active_device, listening_active_sdr_type or 'rtlsdr')
listening_active_device = None listening_active_device = None
return jsonify({'status': 'stopped'}) listening_active_sdr_type = None
return jsonify({'status': 'stopped'})
@listening_post_bp.route('/audio/status') @listening_post_bp.route('/audio/status')
@@ -1547,9 +1560,10 @@ waterfall_process: Optional[subprocess.Popen] = None
waterfall_thread: Optional[threading.Thread] = None waterfall_thread: Optional[threading.Thread] = None
waterfall_running = False 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_config = { waterfall_active_sdr_type: Optional[str] = None
waterfall_config = {
'start_freq': 88.0, 'start_freq': 88.0,
'end_freq': 108.0, 'end_freq': 108.0,
'bin_size': 10000, 'bin_size': 10000,
@@ -1723,9 +1737,9 @@ def _waterfall_loop():
logger.info("Waterfall loop stopped") logger.info("Waterfall loop stopped")
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 global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
waterfall_running = False waterfall_running = False
if waterfall_process and waterfall_process.poll() is None: if waterfall_process and waterfall_process.poll() is None:
@@ -1739,15 +1753,16 @@ def _stop_waterfall_internal() -> None:
pass pass
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) app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type or 'rtlsdr')
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 global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device, waterfall_active_sdr_type
with waterfall_lock: with waterfall_lock:
if waterfall_running: if waterfall_running:
@@ -1788,11 +1803,12 @@ def start_waterfall() -> Response:
pass pass
# Claim SDR device # Claim SDR device
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall') error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr')
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()
+68 -61
View File
@@ -32,8 +32,9 @@ from utils.dependencies import get_tool_path
pager_bp = Blueprint('pager', __name__) 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:
@@ -205,7 +206,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 global pager_active_device, pager_active_sdr_type
try: try:
os.close(master_fd) os.close(master_fd)
except OSError: except OSError:
@@ -233,14 +234,15 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
with app_module.process_lock: with app_module.process_lock:
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) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
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 global pager_active_device, pager_active_sdr_type
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -262,33 +264,42 @@ def start_decoding() -> Response:
squelch = int(squelch) squelch = int(squelch)
if not 0 <= squelch <= 1000: if not 0 <= squelch <= 1000:
raise ValueError("Squelch must be between 0 and 1000") raise ValueError("Squelch must be between 0 and 1000")
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400 return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
# Check for rtl_tcp (remote SDR) connection # Get SDR type and build command via abstraction layer
rtl_tcp_host = data.get('rtl_tcp_host') sdr_type_str = data.get('sdr_type', 'rtlsdr')
rtl_tcp_port = data.get('rtl_tcp_port', 1234) try:
sdr_type = SDRType(sdr_type_str)
# Claim local device if not using remote rtl_tcp except ValueError:
if not rtl_tcp_host: sdr_type = SDRType.RTL_SDR
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager') # Check for rtl_tcp (remote SDR) connection
if error: rtl_tcp_host = data.get('rtl_tcp_host')
return jsonify({ rtl_tcp_port = data.get('rtl_tcp_port', 1234)
'status': 'error',
'error_type': 'DEVICE_BUSY', # Claim local device if not using remote rtl_tcp
'message': error if not rtl_tcp_host:
}), 409 device_int = int(device)
pager_active_device = device_int error = app_module.claim_sdr_device(device_int, 'pager', sdr_type.value)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
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) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 pager_active_sdr_type = None
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:
protocols = valid_protocols protocols = valid_protocols
@@ -312,14 +323,7 @@ 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 if rtl_tcp_host:
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:
# Validate and create network device # Validate and create network device
try: try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
@@ -416,22 +420,23 @@ def start_decoding() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e: except FileNotFoundError as e:
# Kill orphaned rtl_fm process # Kill orphaned rtl_fm process
try: try:
rtl_process.terminate() rtl_process.terminate()
rtl_process.wait(timeout=2) rtl_process.wait(timeout=2)
except Exception: except Exception:
try: try:
rtl_process.kill() rtl_process.kill()
except Exception: except Exception:
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) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) pager_active_sdr_type = None
except Exception as e: return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e:
# Kill orphaned rtl_fm process if it was started # Kill orphaned rtl_fm process if it was started
try: try:
rtl_process.terminate() rtl_process.terminate()
@@ -441,16 +446,17 @@ def start_decoding() -> Response:
rtl_process.kill() rtl_process.kill()
except Exception: except Exception:
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) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
return jsonify({'status': 'error', 'message': str(e)}) pager_active_sdr_type = None
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 global pager_active_device, pager_active_sdr_type
with app_module.process_lock: with app_module.process_lock:
if app_module.current_process: if app_module.current_process:
@@ -485,10 +491,11 @@ def stop_decoding() -> Response:
app_module.current_process = None app_module.current_process = None
# 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) app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
pager_active_device = None pager_active_device = None
pager_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+50 -44
View File
@@ -25,8 +25,9 @@ from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__) 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:
@@ -75,7 +76,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 global sensor_active_device, sensor_active_sdr_type
# Ensure process is terminated # Ensure process is terminated
try: try:
process.terminate() process.terminate()
@@ -90,9 +91,10 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
with app_module.sensor_lock: with app_module.sensor_lock:
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) app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
@sensor_bp.route('/sensor/status') @sensor_bp.route('/sensor/status')
@@ -104,8 +106,8 @@ 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 global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
@@ -122,21 +124,29 @@ 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
# Check for rtl_tcp (remote SDR) connection # Get SDR type and build command via abstraction layer
rtl_tcp_host = data.get('rtl_tcp_host') sdr_type_str = data.get('sdr_type', 'rtlsdr')
rtl_tcp_port = data.get('rtl_tcp_port', 1234) try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# 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') error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type.value)
if error: if error:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'error_type': 'DEVICE_BUSY', 'error_type': 'DEVICE_BUSY',
'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():
@@ -145,14 +155,7 @@ def start_sensor() -> Response:
except queue.Empty: except queue.Empty:
break break
# Get SDR type and build command via abstraction layer if rtl_tcp_host:
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:
# Validate and create network device # Validate and create network device
try: try:
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
@@ -214,23 +217,25 @@ def start_sensor() -> Response:
return jsonify({'status': 'started', 'command': full_cmd}) return jsonify({'status': 'started', 'command': full_cmd})
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) app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) sensor_active_sdr_type = None
except Exception as e: return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
# Release device on failure except Exception as e:
if sensor_active_device is not None: # Release device on failure
app_module.release_sdr_device(sensor_active_device) if sensor_active_device is not None:
sensor_active_device = None app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
return jsonify({'status': 'error', 'message': str(e)}) sensor_active_device = None
sensor_active_sdr_type = None
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 global sensor_active_device, sensor_active_sdr_type
with app_module.sensor_lock: with app_module.sensor_lock:
if app_module.sensor_process: if app_module.sensor_process:
@@ -242,9 +247,10 @@ def stop_sensor() -> Response:
app_module.sensor_process = None app_module.sensor_process = None
# 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) app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
sensor_active_device = None sensor_active_device = None
sensor_active_sdr_type = None
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
+46 -39
View File
@@ -83,10 +83,11 @@ def init_waterfall_websocket(app: Flask):
# Import app module for device claiming # Import app module for device claiming
import app as app_module import app as app_module
iq_process = None iq_process = None
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)
@@ -136,13 +137,14 @@ def init_waterfall_websocket(app: Flask):
stop_event.set() stop_event.set()
if reader_thread and reader_thread.is_alive(): if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2) reader_thread.join(timeout=2)
if iq_process: if iq_process:
safe_terminate(iq_process) safe_terminate(iq_process)
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) app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr')
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():
@@ -185,15 +187,16 @@ 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') claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type.value)
if claim_err: if claim_err:
ws.send(json.dumps({ ws.send(json.dumps({
'status': 'error', 'status': 'error',
'message': claim_err, 'message': claim_err,
'error_type': 'DEVICE_BUSY', 'error_type': 'DEVICE_BUSY',
})) }))
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:
@@ -207,11 +210,12 @@ def init_waterfall_websocket(app: Flask):
ppm=ppm, ppm=ppm,
bias_t=bias_t, bias_t=bias_t,
) )
except NotImplementedError as e: except NotImplementedError as e:
app_module.release_sdr_device(device_index) app_module.release_sdr_device(device_index, sdr_type.value)
claimed_device = None claimed_device = None
ws.send(json.dumps({ claimed_sdr_type = None
'status': 'error', ws.send(json.dumps({
'status': 'error',
'message': str(e), 'message': str(e),
})) }))
continue continue
@@ -255,10 +259,11 @@ 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) app_module.release_sdr_device(device_index, sdr_type.value)
claimed_device = None claimed_device = None
ws.send(json.dumps({ claimed_sdr_type = None
'status': 'error', ws.send(json.dumps({
'status': 'error',
'message': f'Failed to start I/Q capture: {e}', 'message': f'Failed to start I/Q capture: {e}',
})) }))
continue continue
@@ -345,15 +350,16 @@ def init_waterfall_websocket(app: Flask):
if reader_thread and reader_thread.is_alive(): if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2) reader_thread.join(timeout=2)
reader_thread = None reader_thread = None
if iq_process: if iq_process:
safe_terminate(iq_process) safe_terminate(iq_process)
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) app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr')
claimed_device = None claimed_device = None
stop_event.clear() claimed_sdr_type = None
ws.send(json.dumps({'status': 'stopped'})) stop_event.clear()
ws.send(json.dumps({'status': 'stopped'}))
except Exception as e: except Exception as e:
logger.info(f"WebSocket waterfall closed: {e}") logger.info(f"WebSocket waterfall closed: {e}")
@@ -362,11 +368,12 @@ def init_waterfall_websocket(app: Flask):
stop_event.set() stop_event.set()
if reader_thread and reader_thread.is_alive(): if reader_thread and reader_thread.is_alive():
reader_thread.join(timeout=2) reader_thread.join(timeout=2)
if iq_process: if iq_process:
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) app_module.release_sdr_device(claimed_device, claimed_sdr_type or 'rtlsdr')
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
+132 -4
View File
@@ -12,6 +12,9 @@ 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 = [];
@@ -69,7 +72,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;
// 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.
@@ -83,19 +88,22 @@ 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, relaxCrc frequency, protocol, gain, ppm, fineTune, relaxCrc, demod
})); }));
} catch (e) { /* localStorage unavailable */ } } catch (e) { /* localStorage unavailable */ }
fetch('/dmr/start', { return 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, relaxCrc }) body: JSON.stringify({ frequency, protocol, gain, device, ppm, fineTune, relaxCrc, demod })
}) })
.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 = [];
@@ -144,11 +152,14 @@ function startDmr() {
function stopDmr() { function stopDmr() {
stopDmrAudio(); stopDmrAudio();
fetch('/dmr/stop', { method: 'POST' }) return 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;
@@ -194,6 +205,7 @@ 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;
@@ -236,8 +248,14 @@ 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) {
@@ -280,6 +298,111 @@ 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() {
@@ -616,12 +739,16 @@ 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 */ }
} }
@@ -770,3 +897,4 @@ window.addDmrBookmark = addDmrBookmark;
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark; window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
window.removeDmrBookmark = removeDmrBookmark; window.removeDmrBookmark = removeDmrBookmark;
window.dmrQuickTune = dmrQuickTune; window.dmrQuickTune = dmrQuickTune;
window.sweepDmrFineTune = sweepDmrFineTune;
+51 -26
View File
@@ -449,7 +449,10 @@
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;
opt.textContent = `SDR ${d.index}: ${d.name}`; const sdrType = (d.sdr_type || d.type || 'rtlsdr').toLowerCase();
const sdrLabel = sdrType.toUpperCase();
opt.dataset.sdrType = sdrType;
opt.textContent = `SDR ${d.index} (${sdrLabel}): ${d.name}`;
aisSelect.appendChild(opt); aisSelect.appendChild(opt);
}); });
} }
@@ -457,18 +460,23 @@
// Populate DSC device selector // Populate DSC device selector
const dscSelect = document.getElementById('dscDeviceSelect'); const dscSelect = document.getElementById('dscDeviceSelect');
dscSelect.innerHTML = ''; dscSelect.innerHTML = '';
if (devices.length === 0) { const dscDevices = devices.filter(d => {
dscSelect.innerHTML = '<option value="0">No devices</option>'; const sdrType = (d.sdr_type || d.type || 'rtlsdr').toLowerCase();
return sdrType === 'rtlsdr';
});
if (dscDevices.length === 0) {
dscSelect.innerHTML = '<option value="0">No RTL-SDR found</option>';
} else { } else {
devices.forEach((d, i) => { dscDevices.forEach((d, i) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.index; opt.value = d.index;
opt.textContent = `SDR ${d.index}: ${d.name}`; opt.dataset.sdrType = 'rtlsdr';
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 (devices.length > 1) { if (dscDevices.length > 1) {
dscSelect.value = devices[1].index; dscSelect.value = dscDevices[1].index;
} }
} }
}) })
@@ -546,7 +554,9 @@
} }
function startTracking() { function startTracking() {
const device = document.getElementById('aisDeviceSelect').value; const aisSelect = document.getElementById('aisDeviceSelect');
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
@@ -561,7 +571,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() }) body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled(), sdr_type: sdrType })
}) })
.then(r => r.json()) .then(r => r.json())
.then(result => { .then(result => {
@@ -586,7 +596,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() }) body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled(), sdr_type: sdrType })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -1170,7 +1180,9 @@
} }
function startDscTracking() { function startDscTracking() {
const device = document.getElementById('dscDeviceSelect').value; const dscSelect = document.getElementById('dscDeviceSelect');
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
@@ -1185,7 +1197,7 @@
fetch(endpoint, { fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain }) body: JSON.stringify({ device, gain, sdr_type: sdrType })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -1617,21 +1629,32 @@
const aisSelect = document.getElementById('aisDeviceSelect'); const aisSelect = document.getElementById('aisDeviceSelect');
const dscSelect = document.getElementById('dscDeviceSelect'); const dscSelect = document.getElementById('dscDeviceSelect');
[aisSelect, dscSelect].forEach(select => { const aisDevices = devices || [];
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) {
if (devices.length === 0) { select.innerHTML = `<option value=\"0\">${emptyLabel}</option>`;
select.innerHTML = '<option value="0">No SDR found</option>'; return;
} 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);
});
} }
}); 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');
fillSelect(dscSelect, dscDevices, 'No RTL-SDR found');
} }
// Override startTracking for agent support // Override startTracking for agent support
@@ -1645,13 +1668,15 @@
return; return;
} }
const device = document.getElementById('aisDeviceSelect').value; const aisSelect = document.getElementById('aisDeviceSelect');
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() }) body: JSON.stringify({ device, gain, bias_t: getBiasTEnabled(), sdr_type: sdrType })
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
+49 -16
View File
@@ -21,13 +21,25 @@
<select id="dmrProtocol"> <select id="dmrProtocol">
<option value="auto" selected>Auto Detect</option> <option value="auto" selected>Auto Detect</option>
<option value="dmr">DMR</option> <option value="dmr">DMR</option>
<option value="p25">P25</option> <option value="p25">P25 Phase 1</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>
<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;">
Narrow FM often improves digital voice decode on 12.5 kHz channels.
</span>
</div>
<div class="form-group"> <div class="form-group">
<label>Gain</label> <label>Gain</label>
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;"> <input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
@@ -39,6 +51,18 @@
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);">
@@ -87,21 +111,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> </div>
+24
View File
@@ -135,6 +135,7 @@ def test_dsd_fme_protocol_flags_known_values():
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-ft'] # XDMA assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-ft'] # XDMA
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'] == ['-f1'] # NOT -fp (ProVoice in fme)
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,6 +155,7 @@ 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['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 # D-Star and ProVoice should not have forced modulation
assert 'dstar' not in _DSD_FME_MODULATION assert 'dstar' not in _DSD_FME_MODULATION
@@ -235,3 +237,25 @@ 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():
"""Should parse Reed-Solomon errors as frame_error."""
result = parse_dsd_output('P25p2 SACCH R-S ERR Sc')
assert result is not None
assert result['type'] == 'frame_error'
assert result['kind'] == 'rs'
def test_parse_frame_ok_p25p2():
"""Should parse P25p2 4V frames as OK."""
result = parse_dsd_output('P25p2 LCH 1 4V 1')
assert result is not None
assert result['type'] == 'frame_ok'
assert result['kind'] == 'p25p2'