mirror of
https://github.com/smittix/intercept.git
synced 2026-07-13 03:58:10 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 257de37dfe | |||
| b4c47ed28b | |||
| cbcb8b02fa | |||
| 0a02325c0c | |||
| 9bfbd6231d | |||
| 191344f41b | |||
| dcb2488f47 |
@@ -236,12 +236,12 @@ cleanup_manager.register(deauth_alerts)
|
|||||||
# SDR DEVICE REGISTRY
|
# SDR DEVICE REGISTRY
|
||||||
# ============================================
|
# ============================================
|
||||||
# Tracks which mode is using which SDR device to prevent conflicts
|
# Tracks which mode is using which SDR device to prevent conflicts
|
||||||
# Key: device_index (int), Value: 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
|
||||||
@@ -251,46 +251,61 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
|||||||
Args:
|
Args:
|
||||||
device_index: The SDR device index to claim
|
device_index: The SDR device index to claim
|
||||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||||
|
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Error message if device is in use, None if successfully claimed
|
Error message if device is in use, None if successfully claimed
|
||||||
"""
|
"""
|
||||||
|
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
|
||||||
|
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
if device_index in sdr_device_registry:
|
device_entry = sdr_device_registry.get(device_index, {})
|
||||||
in_use_by = sdr_device_registry[device_index]
|
if sdr_type_key in device_entry:
|
||||||
|
in_use_by = device_entry[sdr_type_key]
|
||||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||||
|
|
||||||
# Probe the USB device to catch external processes holding the handle
|
# Probe the USB device to catch external processes holding the handle
|
||||||
try:
|
# Only relevant for RTL-SDR devices
|
||||||
from utils.sdr.detection import probe_rtlsdr_device
|
if sdr_type_key == 'rtlsdr':
|
||||||
usb_error = probe_rtlsdr_device(device_index)
|
try:
|
||||||
if usb_error:
|
from utils.sdr.detection import probe_rtlsdr_device
|
||||||
return usb_error
|
usb_error = probe_rtlsdr_device(device_index)
|
||||||
except Exception:
|
if usb_error:
|
||||||
pass # If probe fails, let the caller proceed normally
|
return usb_error
|
||||||
|
except Exception:
|
||||||
|
pass # If probe fails, let the caller proceed normally
|
||||||
|
|
||||||
sdr_device_registry[device_index] = mode_name
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def release_sdr_device(device_index: int) -> None:
|
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
|
||||||
"""Release an SDR device from the registry.
|
"""Release an SDR device from the registry.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_index: The SDR device index to release
|
device_index: The SDR device index to release
|
||||||
|
sdr_type: SDR hardware type (e.g., 'rtlsdr', 'hackrf')
|
||||||
"""
|
"""
|
||||||
|
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
sdr_device_registry.pop(device_index, None)
|
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()}
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -396,8 +411,11 @@ def get_devices_status() -> Response:
|
|||||||
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()
|
||||||
|
device_registry = registry.get(device.index, {})
|
||||||
|
d['in_use'] = sdr_type_key in device_registry
|
||||||
|
d['used_by'] = device_registry.get(sdr_type_key)
|
||||||
result.append(d)
|
result.append(d)
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|||||||
+38
-13
@@ -679,6 +679,7 @@ class ModeManager:
|
|||||||
running_modes_detail[mode] = {
|
running_modes_detail[mode] = {
|
||||||
'started_at': info.get('started_at'),
|
'started_at': info.get('started_at'),
|
||||||
'device': params.get('device', params.get('device_index', 0)),
|
'device': params.get('device', params.get('device_index', 0)),
|
||||||
|
'sdr_type': str(params.get('sdr_type', 'rtlsdr')).lower(),
|
||||||
}
|
}
|
||||||
|
|
||||||
status = {
|
status = {
|
||||||
@@ -698,20 +699,22 @@ 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.
|
||||||
"""
|
"""
|
||||||
|
sdr_type_key = str(sdr_type or 'rtlsdr').lower()
|
||||||
for mode, info in self.running_modes.items():
|
for mode, info in self.running_modes.items():
|
||||||
if mode in self.SDR_MODES:
|
if mode in self.SDR_MODES:
|
||||||
mode_device = info.get('params', {}).get('device', 0)
|
mode_device = info.get('params', {}).get('device', 0)
|
||||||
|
mode_sdr_type = str(info.get('params', {}).get('sdr_type', 'rtlsdr')).lower()
|
||||||
# Normalize to int for comparison
|
# Normalize to int for comparison
|
||||||
try:
|
try:
|
||||||
mode_device = int(mode_device)
|
mode_device = int(mode_device)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
mode_device = 0
|
mode_device = 0
|
||||||
if mode_device == device:
|
if mode_device == device and mode_sdr_type == sdr_type_key:
|
||||||
return mode
|
return mode
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -731,7 +734,8 @@ class ModeManager:
|
|||||||
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()
|
||||||
|
in_use_by = self.get_sdr_in_use(device, sdr_type)
|
||||||
if in_use_by:
|
if in_use_by:
|
||||||
return {
|
return {
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1100,6 +1104,11 @@ class ModeManager:
|
|||||||
# Mode-specific cleanup
|
# Mode-specific cleanup
|
||||||
if mode == 'adsb':
|
if mode == 'adsb':
|
||||||
self.adsb_aircraft.clear()
|
self.adsb_aircraft.clear()
|
||||||
|
if 'adsb_mlat' in self.output_threads:
|
||||||
|
thread = self.output_threads['adsb_mlat']
|
||||||
|
if thread and thread.is_alive():
|
||||||
|
thread.join(timeout=1)
|
||||||
|
del self.output_threads['adsb_mlat']
|
||||||
elif mode == 'wifi':
|
elif mode == 'wifi':
|
||||||
self.wifi_networks.clear()
|
self.wifi_networks.clear()
|
||||||
self.wifi_clients.clear()
|
self.wifi_clients.clear()
|
||||||
@@ -1315,10 +1324,16 @@ class ModeManager:
|
|||||||
sdr_type_str = params.get('sdr_type', 'rtlsdr')
|
sdr_type_str = params.get('sdr_type', 'rtlsdr')
|
||||||
remote_sbs_host = params.get('remote_sbs_host')
|
remote_sbs_host = params.get('remote_sbs_host')
|
||||||
remote_sbs_port = params.get('remote_sbs_port', 30003)
|
remote_sbs_port = params.get('remote_sbs_port', 30003)
|
||||||
|
mlat_sbs_host = params.get('mlat_sbs_host')
|
||||||
|
mlat_sbs_port = params.get('mlat_sbs_port', 30105)
|
||||||
|
|
||||||
# If remote SBS host provided, just connect to it
|
# If remote SBS host provided, just connect to it
|
||||||
if remote_sbs_host:
|
if remote_sbs_host:
|
||||||
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:
|
||||||
@@ -1328,7 +1343,11 @@ class ModeManager:
|
|||||||
sock.close()
|
sock.close()
|
||||||
if result == 0:
|
if result == 0:
|
||||||
logger.info("dump1090 already running, connecting to SBS port")
|
logger.info("dump1090 already running, connecting to SBS port")
|
||||||
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
|
||||||
|
|
||||||
@@ -1385,7 +1404,11 @@ class ModeManager:
|
|||||||
return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'}
|
return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'}
|
||||||
|
|
||||||
# Connect to SBS port
|
# Connect to SBS port
|
||||||
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,15 +1437,15 @@ 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',
|
||||||
@@ -1431,7 +1454,7 @@ class ModeManager:
|
|||||||
'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)
|
||||||
@@ -1443,7 +1466,7 @@ class ModeManager:
|
|||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(5.0)
|
sock.settimeout(5.0)
|
||||||
sock.connect((host, port))
|
sock.connect((host, port))
|
||||||
logger.info(f"Connected to SBS at {host}:{port}")
|
logger.info(f"Connected to SBS at {host}:{port} ({source_tag})")
|
||||||
retry_count = 0
|
retry_count = 0
|
||||||
|
|
||||||
buffer = ""
|
buffer = ""
|
||||||
@@ -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,7 +1498,7 @@ 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
|
||||||
@@ -1509,6 +1532,8 @@ class ModeManager:
|
|||||||
if parts[14] and parts[15]:
|
if parts[14] and parts[15]:
|
||||||
aircraft['lat'] = float(parts[14])
|
aircraft['lat'] = float(parts[14])
|
||||||
aircraft['lon'] = float(parts[15])
|
aircraft['lon'] = float(parts[15])
|
||||||
|
if source_tag:
|
||||||
|
aircraft['position_source'] = source_tag
|
||||||
|
|
||||||
elif msg_type == '4' and len(parts) > 16:
|
elif msg_type == '4' and len(parts) > 16:
|
||||||
if parts[12]:
|
if parts[12]:
|
||||||
|
|||||||
+83
-25
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+34
-18
@@ -51,7 +51,9 @@ scanner_lock = threading.Lock()
|
|||||||
scanner_paused = False
|
scanner_paused = False
|
||||||
scanner_current_freq = 0.0
|
scanner_current_freq = 0.0
|
||||||
scanner_active_device: Optional[int] = None
|
scanner_active_device: Optional[int] = None
|
||||||
|
scanner_active_sdr_type: Optional[str] = None
|
||||||
listening_active_device: Optional[int] = None
|
listening_active_device: Optional[int] = None
|
||||||
|
listening_active_sdr_type: Optional[str] = None
|
||||||
scanner_power_process: Optional[subprocess.Popen] = None
|
scanner_power_process: Optional[subprocess.Popen] = None
|
||||||
scanner_config = {
|
scanner_config = {
|
||||||
'start_freq': 88.0,
|
'start_freq': 88.0,
|
||||||
@@ -937,6 +939,7 @@ def check_tools() -> Response:
|
|||||||
def start_scanner() -> Response:
|
def start_scanner() -> Response:
|
||||||
"""Start the frequency scanner."""
|
"""Start the frequency scanner."""
|
||||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
|
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
|
||||||
|
global scanner_active_sdr_type, listening_active_sdr_type
|
||||||
|
|
||||||
with scanner_lock:
|
with scanner_lock:
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
@@ -1003,10 +1006,11 @@ def start_scanner() -> Response:
|
|||||||
}), 503
|
}), 503
|
||||||
# Release listening device if active
|
# Release listening device if active
|
||||||
if listening_active_device is not None:
|
if listening_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
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
|
||||||
# Claim device for scanner
|
# Claim device for scanner
|
||||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1014,6 +1018,7 @@ def start_scanner() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
scanner_active_device = scanner_config['device']
|
scanner_active_device = scanner_config['device']
|
||||||
|
scanner_active_sdr_type = sdr_type
|
||||||
scanner_running = True
|
scanner_running = True
|
||||||
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
||||||
scanner_thread.start()
|
scanner_thread.start()
|
||||||
@@ -1031,9 +1036,10 @@ def start_scanner() -> Response:
|
|||||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||||
}), 503
|
}), 503
|
||||||
if listening_active_device is not None:
|
if listening_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
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
|
||||||
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', sdr_type)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1041,6 +1047,7 @@ def start_scanner() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
scanner_active_device = scanner_config['device']
|
scanner_active_device = scanner_config['device']
|
||||||
|
scanner_active_sdr_type = sdr_type
|
||||||
|
|
||||||
scanner_running = True
|
scanner_running = True
|
||||||
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
||||||
@@ -1055,7 +1062,7 @@ def start_scanner() -> Response:
|
|||||||
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
||||||
def stop_scanner() -> Response:
|
def stop_scanner() -> Response:
|
||||||
"""Stop the frequency scanner."""
|
"""Stop the frequency scanner."""
|
||||||
global scanner_running, scanner_active_device, scanner_power_process
|
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()
|
||||||
@@ -1070,8 +1077,9 @@ def stop_scanner() -> Response:
|
|||||||
pass
|
pass
|
||||||
scanner_power_process = None
|
scanner_power_process = None
|
||||||
if scanner_active_device is not None:
|
if scanner_active_device is not None:
|
||||||
app_module.release_sdr_device(scanner_active_device)
|
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'})
|
||||||
|
|
||||||
@@ -1243,13 +1251,15 @@ def get_presets() -> Response:
|
|||||||
def start_audio() -> Response:
|
def start_audio() -> Response:
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
||||||
|
global scanner_active_sdr_type, listening_active_sdr_type, waterfall_active_sdr_type
|
||||||
|
|
||||||
# Stop scanner if running
|
# Stop scanner if running
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
if scanner_active_device is not None:
|
if scanner_active_device is not None:
|
||||||
app_module.release_sdr_device(scanner_active_device)
|
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,7 +1316,7 @@ def start_audio() -> Response:
|
|||||||
scanner_config['sdr_type'] = sdr_type
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
|
||||||
# Stop waterfall if it's using the same SDR (SSE path)
|
# Stop waterfall if it's using the same SDR (SSE path)
|
||||||
if waterfall_running and waterfall_active_device == device:
|
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)
|
||||||
|
|
||||||
@@ -1316,8 +1326,9 @@ def start_audio() -> Response:
|
|||||||
# to give the USB device time to be fully released.
|
# to give the USB device time to be fully released.
|
||||||
if listening_active_device is None or listening_active_device != device:
|
if listening_active_device is None or listening_active_device != device:
|
||||||
if listening_active_device is not None:
|
if listening_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
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
|
||||||
@@ -1326,10 +1337,10 @@ def start_audio() -> Response:
|
|||||||
# attempt — the WebSocket handler may not have finished
|
# attempt — the WebSocket handler may not have finished
|
||||||
# cleanup yet.
|
# cleanup yet.
|
||||||
device_status = app_module.get_sdr_device_status()
|
device_status = app_module.get_sdr_device_status()
|
||||||
if device_status.get(device) == '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:
|
||||||
@@ -1346,6 +1357,7 @@ def start_audio() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
listening_active_device = device
|
listening_active_device = device
|
||||||
|
listening_active_sdr_type = sdr_type
|
||||||
|
|
||||||
_start_audio_stream(frequency, modulation)
|
_start_audio_stream(frequency, modulation)
|
||||||
|
|
||||||
@@ -1365,11 +1377,12 @@ 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
|
||||||
|
listening_active_sdr_type = None
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -1549,6 +1562,7 @@ waterfall_running = False
|
|||||||
waterfall_lock = threading.Lock()
|
waterfall_lock = threading.Lock()
|
||||||
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||||
waterfall_active_device: Optional[int] = None
|
waterfall_active_device: Optional[int] = None
|
||||||
|
waterfall_active_sdr_type: Optional[str] = None
|
||||||
waterfall_config = {
|
waterfall_config = {
|
||||||
'start_freq': 88.0,
|
'start_freq': 88.0,
|
||||||
'end_freq': 108.0,
|
'end_freq': 108.0,
|
||||||
@@ -1725,7 +1739,7 @@ def _waterfall_loop():
|
|||||||
|
|
||||||
def _stop_waterfall_internal() -> None:
|
def _stop_waterfall_internal() -> None:
|
||||||
"""Stop the waterfall display and release resources."""
|
"""Stop the waterfall display and release resources."""
|
||||||
global waterfall_running, waterfall_process, waterfall_active_device
|
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:
|
||||||
@@ -1740,14 +1754,15 @@ def _stop_waterfall_internal() -> None:
|
|||||||
waterfall_process = None
|
waterfall_process = None
|
||||||
|
|
||||||
if waterfall_active_device is not None:
|
if waterfall_active_device is not None:
|
||||||
app_module.release_sdr_device(waterfall_active_device)
|
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()
|
||||||
|
|||||||
+23
-16
@@ -34,6 +34,7 @@ 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:
|
||||||
@@ -234,13 +235,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
app_module.current_process = None
|
app_module.current_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
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:
|
||||||
@@ -265,6 +267,13 @@ def start_decoding() -> Response:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||||
|
|
||||||
|
# Get SDR type and build command via abstraction layer
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
@@ -272,7 +281,7 @@ def start_decoding() -> Response:
|
|||||||
# Claim local device if not using remote rtl_tcp
|
# Claim local device if not using remote rtl_tcp
|
||||||
if not rtl_tcp_host:
|
if not rtl_tcp_host:
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'pager')
|
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type.value)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -280,14 +289,16 @@ def start_decoding() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
pager_active_device = device_int
|
pager_active_device = device_int
|
||||||
|
pager_active_sdr_type = sdr_type.value
|
||||||
|
|
||||||
# Validate protocols
|
# Validate protocols
|
||||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||||
protocols = data.get('protocols', valid_protocols)
|
protocols = data.get('protocols', valid_protocols)
|
||||||
if not isinstance(protocols, list):
|
if not isinstance(protocols, list):
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
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': 'error', 'message': 'Protocols must be a list'}), 400
|
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||||
protocols = [p for p in protocols if p in valid_protocols]
|
protocols = [p for p in protocols if p in valid_protocols]
|
||||||
if not protocols:
|
if not protocols:
|
||||||
@@ -312,13 +323,6 @@ def start_decoding() -> Response:
|
|||||||
elif proto == 'FLEX':
|
elif proto == 'FLEX':
|
||||||
decoders.extend(['-a', 'FLEX'])
|
decoders.extend(['-a', 'FLEX'])
|
||||||
|
|
||||||
# Get SDR type and build command via abstraction layer
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
|
||||||
sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
|
|
||||||
if rtl_tcp_host:
|
if rtl_tcp_host:
|
||||||
# Validate and create network device
|
# Validate and create network device
|
||||||
try:
|
try:
|
||||||
@@ -428,8 +432,9 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
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': 'error', 'message': f'Tool not found: {e.filename}'})
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Kill orphaned rtl_fm process if it was started
|
# Kill orphaned rtl_fm process if it was started
|
||||||
@@ -443,14 +448,15 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
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': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stop', methods=['POST'])
|
@pager_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
global pager_active_device
|
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:
|
||||||
@@ -487,8 +493,9 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
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'})
|
||||||
|
|
||||||
|
|||||||
+21
-15
@@ -27,6 +27,7 @@ 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()
|
||||||
@@ -91,8 +92,9 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
app_module.sensor_process = None
|
app_module.sensor_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
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')
|
||||||
@@ -105,7 +107,7 @@ def sensor_status() -> Response:
|
|||||||
|
|
||||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
def start_sensor() -> Response:
|
def start_sensor() -> Response:
|
||||||
global sensor_active_device
|
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,6 +124,13 @@ def start_sensor() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Get SDR type and build command via abstraction layer
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
# Check for rtl_tcp (remote SDR) connection
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
@@ -129,7 +138,7 @@ def start_sensor() -> Response:
|
|||||||
# Claim local device if not using remote rtl_tcp
|
# Claim local device if not using remote rtl_tcp
|
||||||
if not rtl_tcp_host:
|
if not rtl_tcp_host:
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'sensor')
|
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type.value)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -137,6 +146,7 @@ def start_sensor() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
sensor_active_device = device_int
|
sensor_active_device = device_int
|
||||||
|
sensor_active_sdr_type = sdr_type.value
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.sensor_queue.empty():
|
while not app_module.sensor_queue.empty():
|
||||||
@@ -145,13 +155,6 @@ def start_sensor() -> Response:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Get SDR type and build command via abstraction layer
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
|
||||||
sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
|
|
||||||
if rtl_tcp_host:
|
if rtl_tcp_host:
|
||||||
# Validate and create network device
|
# Validate and create network device
|
||||||
try:
|
try:
|
||||||
@@ -217,20 +220,22 @@ def start_sensor() -> Response:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
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': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
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': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||||
def stop_sensor() -> Response:
|
def stop_sensor() -> Response:
|
||||||
global sensor_active_device
|
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:
|
||||||
@@ -243,8 +248,9 @@ def stop_sensor() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
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'})
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
reader_thread = None
|
reader_thread = None
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = None
|
||||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||||
send_queue = queue.Queue(maxsize=120)
|
send_queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
@@ -141,8 +142,9 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
iq_process = None
|
iq_process = None
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
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,7 +187,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
end_freq = center_freq + effective_span_mhz / 2
|
end_freq = center_freq + effective_span_mhz / 2
|
||||||
|
|
||||||
# Claim the device
|
# Claim the device
|
||||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
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',
|
||||||
@@ -194,6 +196,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
claimed_device = device_index
|
claimed_device = device_index
|
||||||
|
claimed_sdr_type = sdr_type.value
|
||||||
|
|
||||||
# Build I/Q capture command
|
# Build I/Q capture command
|
||||||
try:
|
try:
|
||||||
@@ -208,8 +211,9 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
bias_t=bias_t,
|
bias_t=bias_t,
|
||||||
)
|
)
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
app_module.release_sdr_device(device_index)
|
app_module.release_sdr_device(device_index, sdr_type.value)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = None
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': str(e),
|
'message': str(e),
|
||||||
@@ -255,8 +259,9 @@ 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
|
||||||
|
claimed_sdr_type = None
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Failed to start I/Q capture: {e}',
|
'message': f'Failed to start I/Q capture: {e}',
|
||||||
@@ -350,8 +355,9 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
iq_process = None
|
iq_process = None
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
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()
|
||||||
ws.send(json.dumps({'status': 'stopped'}))
|
ws.send(json.dumps({'status': 'stopped'}))
|
||||||
|
|
||||||
@@ -366,7 +372,8 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
safe_terminate(iq_process)
|
safe_terminate(iq_process)
|
||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
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
@@ -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;
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user