fix: airband start crash when device selector not yet populated

When the /devices fetch hasn't completed or fails, parseInt on an empty
select returns NaN which JSON-serializes to null. The backend then calls
int(None) and raises TypeError. Fix both layers: frontend falls back to
0 on NaN, backend uses `or` defaults so null values don't bypass the
fallback.

Also adds a short TTL cache to detect_all_devices() so multiple
concurrent callers on the same page load don't each spawn blocking
subprocess probes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-03-03 18:56:38 +00:00
parent fb4482fac7
commit 2f5f429e83
4 changed files with 42 additions and 7 deletions

View File

@@ -1311,9 +1311,9 @@ def start_audio() -> Response:
try: try:
frequency = float(data.get('frequency', 0)) frequency = float(data.get('frequency', 0))
modulation = normalize_modulation(data.get('modulation', 'wfm')) modulation = normalize_modulation(data.get('modulation', 'wfm'))
squelch = int(data.get('squelch', 0)) squelch = int(data.get('squelch') or 0)
gain = int(data.get('gain', 40)) gain = int(data.get('gain') or 40)
device = int(data.get('device', 0)) device = int(data.get('device') or 0)
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
request_token_raw = data.get('request_token') request_token_raw = data.get('request_token')
request_token = int(request_token_raw) if request_token_raw is not None else None request_token = int(request_token_raw) if request_token_raw is not None else None

View File

@@ -3652,8 +3652,8 @@ sudo make install</code>
async function startAirband() { async function startAirband() {
const frequency = getAirbandFrequency(); const frequency = getAirbandFrequency();
const device = parseInt(document.getElementById('airbandDeviceSelect').value); const device = parseInt(document.getElementById('airbandDeviceSelect').value) || 0;
const squelch = parseInt(document.getElementById('airbandSquelch').value); const squelch = parseInt(document.getElementById('airbandSquelch').value) || 0;
console.log('[AIRBAND] Starting with device:', device, 'freq:', frequency, 'squelch:', squelch); console.log('[AIRBAND] Starting with device:', device, 'freq:', frequency, 'squelch:', squelch);

View File

@@ -26,7 +26,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from .detection import detect_all_devices, probe_rtlsdr_device from .detection import detect_all_devices, invalidate_device_cache, probe_rtlsdr_device
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder from .hackrf import HackRFCommandBuilder
@@ -231,4 +231,5 @@ __all__ = [
'get_capabilities_for_type', 'get_capabilities_for_type',
# Device probing # Device probing
'probe_rtlsdr_device', 'probe_rtlsdr_device',
'invalidate_device_cache',
] ]

View File

@@ -23,6 +23,16 @@ _hackrf_cache: list[SDRDevice] = []
_hackrf_cache_ts: float = 0.0 _hackrf_cache_ts: float = 0.0
_HACKRF_CACHE_TTL_SECONDS = 3.0 _HACKRF_CACHE_TTL_SECONDS = 3.0
# Cache all-device detection results. Multiple endpoints call
# detect_all_devices() on the same page load (e.g. /devices and /adsb/tools
# both trigger it from DOMContentLoaded). On a Pi the subprocess calls
# (rtl_test, SoapySDRUtil, hackrf_info) each take seconds and block the
# single gevent worker, serialising every other request behind them.
# A short TTL cache avoids duplicate subprocess storms.
_all_devices_cache: list[SDRDevice] = []
_all_devices_cache_ts: float = 0.0
_ALL_DEVICES_CACHE_TTL_SECONDS = 5.0
def _hackrf_probe_blocked() -> bool: def _hackrf_probe_blocked() -> bool:
"""Return True when probing HackRF would interfere with an active stream.""" """Return True when probing HackRF would interfere with an active stream."""
@@ -492,12 +502,26 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
return None return None
def detect_all_devices() -> list[SDRDevice]: def detect_all_devices(force: bool = False) -> list[SDRDevice]:
""" """
Detect all connected SDR devices across all supported hardware types. Detect all connected SDR devices across all supported hardware types.
Results are cached for a few seconds so that multiple callers hitting
this within the same page-load cycle (e.g. /devices + /adsb/tools) do
not each spawn a full set of blocking subprocess probes.
Args:
force: Bypass the cache and re-probe hardware.
Returns a unified list of SDRDevice objects sorted by type and index. Returns a unified list of SDRDevice objects sorted by type and index.
""" """
global _all_devices_cache, _all_devices_cache_ts
now = time.time()
if not force and _all_devices_cache_ts and (now - _all_devices_cache_ts) < _ALL_DEVICES_CACHE_TTL_SECONDS:
logger.debug("Returning cached device list (%d device(s))", len(_all_devices_cache))
return list(_all_devices_cache)
devices: list[SDRDevice] = [] devices: list[SDRDevice] = []
skip_in_soapy: set[SDRType] = set() skip_in_soapy: set[SDRType] = set()
@@ -524,6 +548,16 @@ def detect_all_devices() -> list[SDRDevice]:
for d in devices: for d in devices:
logger.debug(f" {d.sdr_type.value}:{d.index} - {d.name} (serial: {d.serial})") logger.debug(f" {d.sdr_type.value}:{d.index} - {d.name} (serial: {d.serial})")
# Update cache
_all_devices_cache = list(devices)
_all_devices_cache_ts = time.time()
return devices return devices
def invalidate_device_cache() -> None:
"""Clear the all-devices cache so the next call re-probes hardware."""
global _all_devices_cache, _all_devices_cache_ts
_all_devices_cache = []
_all_devices_cache_ts = 0.0