From 2f5f429e832bcefe9c0a733dfdda156a6ae5a6c2 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 3 Mar 2026 18:56:38 +0000 Subject: [PATCH] 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 --- routes/listening_post.py | 6 +++--- templates/adsb_dashboard.html | 4 ++-- utils/sdr/__init__.py | 3 ++- utils/sdr/detection.py | 36 ++++++++++++++++++++++++++++++++++- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/routes/listening_post.py b/routes/listening_post.py index b59bb40..7d244ce 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -1311,9 +1311,9 @@ def start_audio() -> Response: try: frequency = float(data.get('frequency', 0)) modulation = normalize_modulation(data.get('modulation', 'wfm')) - squelch = int(data.get('squelch', 0)) - gain = int(data.get('gain', 40)) - device = int(data.get('device', 0)) + squelch = int(data.get('squelch') or 0) + gain = int(data.get('gain') or 40) + device = int(data.get('device') or 0) sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() request_token_raw = data.get('request_token') request_token = int(request_token_raw) if request_token_raw is not None else None diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index a7fa318..a79b761 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -3652,8 +3652,8 @@ sudo make install async function startAirband() { const frequency = getAirbandFrequency(); - const device = parseInt(document.getElementById('airbandDeviceSelect').value); - const squelch = parseInt(document.getElementById('airbandSquelch').value); + const device = parseInt(document.getElementById('airbandDeviceSelect').value) || 0; + const squelch = parseInt(document.getElementById('airbandSquelch').value) || 0; console.log('[AIRBAND] Starting with device:', device, 'freq:', frequency, 'squelch:', squelch); diff --git a/utils/sdr/__init__.py b/utils/sdr/__init__.py index 75c19a3..00b2648 100644 --- a/utils/sdr/__init__.py +++ b/utils/sdr/__init__.py @@ -26,7 +26,7 @@ from __future__ import annotations from typing import Optional 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 .limesdr import LimeSDRCommandBuilder from .hackrf import HackRFCommandBuilder @@ -231,4 +231,5 @@ __all__ = [ 'get_capabilities_for_type', # Device probing 'probe_rtlsdr_device', + 'invalidate_device_cache', ] diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index 43d93cf..12d7196 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -23,6 +23,16 @@ _hackrf_cache: list[SDRDevice] = [] _hackrf_cache_ts: float = 0.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: """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 -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. + 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. """ + 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] = [] skip_in_soapy: set[SDRType] = set() @@ -524,6 +548,16 @@ def detect_all_devices() -> list[SDRDevice]: for d in devices: 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 +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 +