Update SDR device claims

This commit is contained in:
Smittix
2026-02-10 18:07:52 +00:00
parent b4c47ed28b
commit 257de37dfe
9 changed files with 585 additions and 420 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'})
+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
+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 => {