mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Merge upstream/main and resolve acars, vdl2, dashboard conflicts
Resolved conflicts: - routes/acars.py: keep /messages and /clear endpoints for history reload - routes/vdl2.py: keep /messages and /clear endpoints for history reload - templates/adsb_dashboard.html: keep removal of hardcoded device-1 defaults for ACARS/VDL2 selectors (users pick their own device) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
Dockerfile
13
Dockerfile
@@ -200,6 +200,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/hackrf \
|
||||
# Install radiosonde_auto_rx (weather balloon decoder)
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
|
||||
&& cd radiosonde_auto_rx/auto_rx \
|
||||
&& pip install --no-cache-dir -r requirements.txt \
|
||||
&& bash build.sh \
|
||||
&& mkdir -p /opt/radiosonde_auto_rx/auto_rx \
|
||||
&& cp -r . /opt/radiosonde_auto_rx/auto_rx/ \
|
||||
&& chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/radiosonde_auto_rx \
|
||||
# Build rtlamr (utility meter decoder - requires Go)
|
||||
&& cd /tmp \
|
||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||
@@ -246,7 +257,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
# Create data directory for persistence
|
||||
RUN mkdir -p /app/data /app/data/weather_sat
|
||||
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 5050
|
||||
|
||||
65
app.py
65
app.py
@@ -198,6 +198,11 @@ tscm_lock = threading.Lock()
|
||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
subghz_lock = threading.Lock()
|
||||
|
||||
# Radiosonde weather balloon tracking
|
||||
radiosonde_process = None
|
||||
radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
radiosonde_lock = threading.Lock()
|
||||
|
||||
# CW/Morse code decoder
|
||||
morse_process = None
|
||||
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
@@ -257,12 +262,12 @@ cleanup_manager.register(deauth_alerts)
|
||||
# SDR DEVICE REGISTRY
|
||||
# ============================================
|
||||
# Tracks which mode is using which SDR device to prevent conflicts
|
||||
# Key: device_index (int), Value: mode_name (str)
|
||||
sdr_device_registry: dict[int, str] = {}
|
||||
# Key: "sdr_type:device_index" (str), Value: mode_name (str)
|
||||
sdr_device_registry: dict[str, str] = {}
|
||||
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.
|
||||
|
||||
Checks the in-app registry first, then probes the USB device to
|
||||
@@ -272,43 +277,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
Args:
|
||||
device_index: The SDR device index to claim
|
||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
||||
|
||||
Returns:
|
||||
Error message if device is in use, None if successfully claimed
|
||||
"""
|
||||
key = f"{sdr_type}:{device_index}"
|
||||
with sdr_device_registry_lock:
|
||||
if device_index in sdr_device_registry:
|
||||
in_use_by = sdr_device_registry[device_index]
|
||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||
if key in sdr_device_registry:
|
||||
in_use_by = sdr_device_registry[key]
|
||||
return f'SDR device {sdr_type}:{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
|
||||
try:
|
||||
from utils.sdr.detection import probe_rtlsdr_device
|
||||
usb_error = probe_rtlsdr_device(device_index)
|
||||
if usb_error:
|
||||
return usb_error
|
||||
except Exception:
|
||||
pass # If probe fails, let the caller proceed normally
|
||||
if sdr_type == 'rtlsdr':
|
||||
try:
|
||||
from utils.sdr.detection import probe_rtlsdr_device
|
||||
usb_error = probe_rtlsdr_device(device_index)
|
||||
if usb_error:
|
||||
return usb_error
|
||||
except Exception:
|
||||
pass # If probe fails, let the caller proceed normally
|
||||
|
||||
sdr_device_registry[device_index] = mode_name
|
||||
sdr_device_registry[key] = mode_name
|
||||
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.
|
||||
|
||||
Args:
|
||||
device_index: The SDR device index to release
|
||||
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
||||
"""
|
||||
key = f"{sdr_type}:{device_index}"
|
||||
with sdr_device_registry_lock:
|
||||
sdr_device_registry.pop(device_index, None)
|
||||
sdr_device_registry.pop(key, None)
|
||||
|
||||
|
||||
def get_sdr_device_status() -> dict[int, str]:
|
||||
def get_sdr_device_status() -> dict[str, str]:
|
||||
"""Get current SDR device allocations.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device indices to mode names
|
||||
Dictionary mapping 'sdr_type:device_index' keys to mode names
|
||||
"""
|
||||
with sdr_device_registry_lock:
|
||||
return dict(sdr_device_registry)
|
||||
@@ -429,8 +439,9 @@ def get_devices_status() -> Response:
|
||||
result = []
|
||||
for device in devices:
|
||||
d = device.to_dict()
|
||||
d['in_use'] = device.index in registry
|
||||
d['used_by'] = registry.get(device.index)
|
||||
key = f"{device.sdr_type.value}:{device.index}"
|
||||
d['in_use'] = key in registry
|
||||
d['used_by'] = registry.get(key)
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result)
|
||||
@@ -760,6 +771,7 @@ def health_check() -> Response:
|
||||
'wifi': wifi_active,
|
||||
'bluetooth': bt_active,
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False),
|
||||
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
|
||||
'subghz': _get_subghz_active(),
|
||||
},
|
||||
@@ -778,12 +790,13 @@ def health_check() -> Response:
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global vdl2_process, morse_process
|
||||
global vdl2_process, morse_process, radiosonde_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||
|
||||
# Import adsb and ais modules to reset their state
|
||||
# Import modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
from routes import ais as ais_module
|
||||
from routes import radiosonde as radiosonde_module
|
||||
from utils.bluetooth import reset_bluetooth_scanner
|
||||
|
||||
killed = []
|
||||
@@ -793,7 +806,8 @@ def kill_all() -> Response:
|
||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||
'hcitool', 'bluetoothctl', 'satdump',
|
||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||
'hackrf_transfer', 'hackrf_sweep'
|
||||
'hackrf_transfer', 'hackrf_sweep',
|
||||
'auto_rx'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -823,6 +837,11 @@ def kill_all() -> Response:
|
||||
ais_process = None
|
||||
ais_module.ais_running = False
|
||||
|
||||
# Reset Radiosonde state
|
||||
with radiosonde_lock:
|
||||
radiosonde_process = None
|
||||
radiosonde_module.radiosonde_running = False
|
||||
|
||||
# Reset ACARS state
|
||||
with acars_lock:
|
||||
acars_process = None
|
||||
|
||||
@@ -355,6 +355,12 @@ SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10)
|
||||
SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
|
||||
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0)
|
||||
|
||||
# Radiosonde settings
|
||||
RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0)
|
||||
RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0)
|
||||
RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0)
|
||||
RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673)
|
||||
|
||||
# Update checking
|
||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||
|
||||
@@ -19,6 +19,7 @@ def register_blueprints(app):
|
||||
from .morse import morse_bp
|
||||
from .offline import offline_bp
|
||||
from .pager import pager_bp
|
||||
from .radiosonde import radiosonde_bp
|
||||
from .recordings import recordings_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .satellite import satellite_bp
|
||||
@@ -76,6 +77,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
||||
app.register_blueprint(morse_bp) # CW/Morse code decoder
|
||||
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
||||
app.register_blueprint(system_bp) # System health monitoring
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
|
||||
@@ -48,6 +48,7 @@ acars_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
acars_active_device: int | None = None
|
||||
acars_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def find_acarsdec():
|
||||
@@ -164,7 +165,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
logger.error(f"ACARS stream error: {e}")
|
||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global acars_active_device
|
||||
global acars_active_device, acars_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
@@ -180,8 +181,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
app_module.acars_process = None
|
||||
# Release SDR device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
|
||||
|
||||
@acars_bp.route('/tools')
|
||||
@@ -213,7 +215,7 @@ def acars_status() -> Response:
|
||||
@acars_bp.route('/start', methods=['POST'])
|
||||
def start_acars() -> Response:
|
||||
"""Start ACARS decoder."""
|
||||
global acars_message_count, acars_last_message_time, acars_active_device
|
||||
global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
@@ -240,9 +242,12 @@ def start_acars() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'acars')
|
||||
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -251,6 +256,7 @@ def start_acars() -> Response:
|
||||
}), 409
|
||||
|
||||
acars_active_device = device_int
|
||||
acars_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||
@@ -268,8 +274,6 @@ def start_acars() -> Response:
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -356,8 +360,9 @@ def start_acars() -> Response:
|
||||
if process.poll() is not None:
|
||||
# Process died - release device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
@@ -388,8 +393,9 @@ def start_acars() -> Response:
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -397,7 +403,7 @@ def start_acars() -> Response:
|
||||
@acars_bp.route('/stop', methods=['POST'])
|
||||
def stop_acars() -> Response:
|
||||
"""Stop ACARS decoder."""
|
||||
global acars_active_device
|
||||
global acars_active_device, acars_active_sdr_type
|
||||
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
@@ -418,8 +424,9 @@ def stop_acars() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -445,6 +452,7 @@ def stream_acars() -> Response:
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@acars_bp.route('/messages')
|
||||
def get_acars_messages() -> Response:
|
||||
"""Get recent ACARS messages from correlator (for history reload)."""
|
||||
|
||||
@@ -72,6 +72,7 @@ adsb_last_message_time = None
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
adsb_active_device = None # Track which device index is being used
|
||||
adsb_active_sdr_type: str | None = None
|
||||
_sbs_error_logged = False # Suppress repeated connection error logs
|
||||
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
@@ -674,7 +675,7 @@ def adsb_session():
|
||||
@adsb_bp.route('/start', methods=['POST'])
|
||||
def start_adsb():
|
||||
"""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:
|
||||
if adsb_using_service:
|
||||
@@ -757,6 +758,7 @@ def start_adsb():
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
sdr_type_str = sdr_type.value
|
||||
|
||||
# For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
@@ -787,7 +789,7 @@ def start_adsb():
|
||||
|
||||
# Check if device is available before starting local dump1090
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'adsb')
|
||||
error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -795,6 +797,10 @@ def start_adsb():
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
# Track claimed device immediately so stop_adsb() can always release it
|
||||
adsb_active_device = device
|
||||
adsb_active_sdr_type = sdr_type_str
|
||||
|
||||
# Create device object and build command via abstraction layer
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
@@ -821,11 +827,24 @@ def start_adsb():
|
||||
)
|
||||
write_dump1090_pid(app_module.adsb_process.pid)
|
||||
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
# Poll for dump1090 readiness instead of blind sleep
|
||||
dump1090_ready = False
|
||||
poll_interval = 0.1
|
||||
elapsed = 0.0
|
||||
while elapsed < DUMP1090_START_WAIT:
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
break # Process exited early — handle below
|
||||
if check_dump1090_service():
|
||||
dump1090_ready = True
|
||||
break
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
# Process exited - release device and get error message
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
adsb_active_device = None
|
||||
adsb_active_sdr_type = None
|
||||
stderr_output = ''
|
||||
if app_module.adsb_process.stderr:
|
||||
try:
|
||||
@@ -871,7 +890,6 @@ def start_adsb():
|
||||
})
|
||||
|
||||
adsb_using_service = True
|
||||
adsb_active_device = device # Track which device is being used
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
@@ -891,14 +909,16 @@ def start_adsb():
|
||||
})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
adsb_active_device = None
|
||||
adsb_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
|
||||
data = request.get_json(silent=True) or {}
|
||||
stop_source = data.get('source')
|
||||
stopped_by = request.remote_addr
|
||||
@@ -923,10 +943,11 @@ def stop_adsb():
|
||||
|
||||
# Release device from registry
|
||||
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_active_device = None
|
||||
adsb_active_sdr_type = None
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
|
||||
@@ -44,6 +44,7 @@ ais_connected = False
|
||||
ais_messages_received = 0
|
||||
ais_last_message_time = None
|
||||
ais_active_device = None
|
||||
ais_active_sdr_type: str | None = None
|
||||
_ais_error_logged = True
|
||||
|
||||
# Common installation paths for AIS-catcher
|
||||
@@ -350,7 +351,7 @@ def ais_status():
|
||||
@ais_bp.route('/start', methods=['POST'])
|
||||
def start_ais():
|
||||
"""Start AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
global ais_running, ais_active_device, ais_active_sdr_type
|
||||
|
||||
with app_module.ais_lock:
|
||||
if ais_running:
|
||||
@@ -397,7 +398,7 @@ def start_ais():
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'ais')
|
||||
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -436,7 +437,7 @@ def start_ais():
|
||||
|
||||
if app_module.ais_process.poll() is not None:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
stderr_output = ''
|
||||
if app_module.ais_process.stderr:
|
||||
try:
|
||||
@@ -450,6 +451,7 @@ def start_ais():
|
||||
|
||||
ais_running = True
|
||||
ais_active_device = device
|
||||
ais_active_sdr_type = sdr_type_str
|
||||
|
||||
# Start TCP parser thread
|
||||
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||
@@ -463,7 +465,7 @@ def start_ais():
|
||||
})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -471,7 +473,7 @@ def start_ais():
|
||||
@ais_bp.route('/stop', methods=['POST'])
|
||||
def stop_ais():
|
||||
"""Stop AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
global ais_running, ais_active_device, ais_active_sdr_type
|
||||
|
||||
with app_module.ais_lock:
|
||||
if app_module.ais_process:
|
||||
@@ -490,10 +492,11 @@ def stop_ais():
|
||||
|
||||
# Release device from registry
|
||||
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_active_device = None
|
||||
ais_active_sdr_type = None
|
||||
|
||||
app_module.ais_vessels.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
259
routes/aprs.py
259
routes/aprs.py
@@ -5,8 +5,10 @@ from __future__ import annotations
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -35,6 +37,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
|
||||
# Track which SDR device is being used
|
||||
aprs_active_device: int | None = None
|
||||
aprs_active_sdr_type: str | None = None
|
||||
|
||||
# APRS frequencies by region (MHz)
|
||||
APRS_FREQUENCIES = {
|
||||
@@ -103,6 +106,9 @@ ADEVICE stdin null
|
||||
CHANNEL 0
|
||||
MYCALL N0CALL
|
||||
MODEM 1200
|
||||
FIX_BITS 1
|
||||
AGWPORT 0
|
||||
KISSPORT 0
|
||||
"""
|
||||
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
|
||||
f.write(config)
|
||||
@@ -1437,19 +1443,19 @@ def should_send_meter_update(level: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||
def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||
"""Stream decoded APRS packets and audio level meter to queue.
|
||||
|
||||
This function reads from the decoder's stdout (text mode, line-buffered).
|
||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
||||
rtl_fm's stderr is captured via PIPE with a monitor thread.
|
||||
Reads from a PTY master fd to get line-buffered output from the decoder,
|
||||
avoiding the 15-minute pipe buffering delay. Uses select() + os.read()
|
||||
to poll the PTY (same pattern as pager.py).
|
||||
|
||||
Outputs two types of messages to the queue:
|
||||
- type='aprs': Decoded APRS packets
|
||||
- type='meter': Audio level meter readings (rate-limited)
|
||||
"""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global _last_meter_time, _last_meter_level, aprs_active_device
|
||||
global _last_meter_time, _last_meter_level, aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
# Capture the device claimed by THIS session so the finally block only
|
||||
# releases our own device, not one claimed by a subsequent start.
|
||||
@@ -1462,93 +1468,114 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
try:
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Read line-by-line in binary mode. Empty bytes b'' signals EOF.
|
||||
# Decode with errors='replace' so corrupted radio bytes (e.g. 0xf7)
|
||||
# never crash the stream.
|
||||
for raw in iter(decoder_process.stdout.readline, b''):
|
||||
line = raw.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
# Read from PTY using select() for non-blocking reads.
|
||||
# PTY forces the decoder to line-buffer, so output arrives immediately
|
||||
# instead of waiting for a full 4-8KB pipe buffer to fill.
|
||||
buffer = ""
|
||||
while True:
|
||||
try:
|
||||
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
# Check for audio level line first (for signal meter)
|
||||
audio_level = parse_audio_level(line)
|
||||
if audio_level is not None:
|
||||
if should_send_meter_update(audio_level):
|
||||
meter_msg = {
|
||||
'type': 'meter',
|
||||
'level': audio_level,
|
||||
'ts': datetime.utcnow().isoformat() + 'Z'
|
||||
}
|
||||
app_module.aprs_queue.put(meter_msg)
|
||||
continue # Audio level lines are not packets
|
||||
if ready:
|
||||
try:
|
||||
data = os.read(master_fd, 1024)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode('utf-8', errors='replace')
|
||||
except OSError:
|
||||
break
|
||||
|
||||
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
||||
line = normalize_aprs_output_line(line)
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||
if '>' not in line or ':' not in line:
|
||||
continue
|
||||
# Check for audio level line first (for signal meter)
|
||||
audio_level = parse_audio_level(line)
|
||||
if audio_level is not None:
|
||||
if should_send_meter_update(audio_level):
|
||||
meter_msg = {
|
||||
'type': 'meter',
|
||||
'level': audio_level,
|
||||
'ts': datetime.utcnow().isoformat() + 'Z'
|
||||
}
|
||||
app_module.aprs_queue.put(meter_msg)
|
||||
continue # Audio level lines are not packets
|
||||
|
||||
packet = parse_aprs_packet(line)
|
||||
if packet:
|
||||
aprs_packet_count += 1
|
||||
aprs_last_packet_time = time.time()
|
||||
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
||||
line = normalize_aprs_output_line(line)
|
||||
|
||||
# Track unique stations
|
||||
callsign = packet.get('callsign')
|
||||
if callsign and callsign not in aprs_stations:
|
||||
aprs_station_count += 1
|
||||
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||
if '>' not in line or ':' not in line:
|
||||
continue
|
||||
|
||||
# Update station data, preserving last known coordinates when
|
||||
# packets do not contain position fields.
|
||||
if callsign:
|
||||
existing = aprs_stations.get(callsign, {})
|
||||
packet_lat = packet.get('lat')
|
||||
packet_lon = packet.get('lon')
|
||||
aprs_stations[callsign] = {
|
||||
'callsign': callsign,
|
||||
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
|
||||
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
|
||||
'symbol': packet.get('symbol') or existing.get('symbol'),
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Geofence check
|
||||
_aprs_lat = packet_lat
|
||||
_aprs_lon = packet_lon
|
||||
if _aprs_lat is not None and _aprs_lon is not None:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||
{'callsign': callsign}
|
||||
):
|
||||
process_event('aprs', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
aprs_stations,
|
||||
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||
)
|
||||
del aprs_stations[oldest]
|
||||
packet = parse_aprs_packet(line)
|
||||
if packet:
|
||||
aprs_packet_count += 1
|
||||
aprs_last_packet_time = time.time()
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
# Track unique stations
|
||||
callsign = packet.get('callsign')
|
||||
if callsign and callsign not in aprs_stations:
|
||||
aprs_station_count += 1
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
# Update station data, preserving last known coordinates when
|
||||
# packets do not contain position fields.
|
||||
if callsign:
|
||||
existing = aprs_stations.get(callsign, {})
|
||||
packet_lat = packet.get('lat')
|
||||
packet_lon = packet.get('lon')
|
||||
aprs_stations[callsign] = {
|
||||
'callsign': callsign,
|
||||
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
|
||||
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
|
||||
'symbol': packet.get('symbol') or existing.get('symbol'),
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Geofence check
|
||||
_aprs_lat = packet_lat
|
||||
_aprs_lon = packet_lon
|
||||
if _aprs_lat is not None and _aprs_lon is not None:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||
{'callsign': callsign}
|
||||
):
|
||||
process_event('aprs', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
aprs_stations,
|
||||
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||
)
|
||||
del aprs_stations[oldest]
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
@@ -1562,8 +1589,9 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
pass
|
||||
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
||||
if my_device is not None and aprs_active_device == my_device:
|
||||
app_module.release_sdr_device(my_device)
|
||||
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
@@ -1632,7 +1660,7 @@ def aprs_data() -> Response:
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global aprs_active_device
|
||||
global aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
with app_module.aprs_lock:
|
||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||
@@ -1681,7 +1709,7 @@ def start_aprs() -> Response:
|
||||
}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs')
|
||||
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -1689,6 +1717,7 @@ def start_aprs() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
aprs_active_device = device
|
||||
aprs_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequency for region
|
||||
region = data.get('region', 'north_america')
|
||||
@@ -1730,8 +1759,9 @@ def start_aprs() -> Response:
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build decoder command
|
||||
@@ -1785,19 +1815,25 @@ def start_aprs() -> Response:
|
||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
||||
rtl_stderr_thread.start()
|
||||
|
||||
# Create a pseudo-terminal for decoder output. PTY forces the
|
||||
# decoder to line-buffer its stdout, avoiding the 15-minute delay
|
||||
# caused by full pipe buffering (~4-8KB) on small APRS packets.
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
# Start decoder with stdin wired to rtl_fm's stdout.
|
||||
# Use binary mode to avoid UnicodeDecodeError on raw/corrupted bytes
|
||||
# from the radio decoder (e.g. 0xf7). Lines are decoded manually
|
||||
# in stream_aprs_output with errors='replace'.
|
||||
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||
# stdout/stderr go to the PTY slave so output is line-buffered.
|
||||
decoder_process = subprocess.Popen(
|
||||
decoder_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=PIPE,
|
||||
stderr=STDOUT,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Close slave fd in parent — decoder owns it now.
|
||||
os.close(slave_fd)
|
||||
|
||||
# Close rtl_fm's stdout in parent so decoder owns it exclusively.
|
||||
# This ensures proper EOF propagation when rtl_fm terminates.
|
||||
rtl_process.stdout.close()
|
||||
@@ -1818,40 +1854,57 @@ def start_aprs() -> Response:
|
||||
if stderr_output:
|
||||
error_msg += f': {stderr_output[:200]}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
decoder_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
if decoder_process.poll() is not None:
|
||||
# Decoder exited early - capture any output
|
||||
raw_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else b''
|
||||
error_output = raw_output.decode('utf-8', errors='replace') if raw_output else ''
|
||||
# Decoder exited early - capture any output from PTY
|
||||
error_output = ''
|
||||
try:
|
||||
ready, _, _ = select.select([master_fd], [], [], 0.5)
|
||||
if ready:
|
||||
raw = os.read(master_fd, 500)
|
||||
error_output = raw.decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = f'{decoder_name} failed to start'
|
||||
if error_output:
|
||||
error_msg += f': {error_output}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
# Store references for status checks and cleanup
|
||||
app_module.aprs_process = decoder_process
|
||||
app_module.aprs_rtl_process = rtl_process
|
||||
app_module.aprs_master_fd = master_fd
|
||||
|
||||
# Start background thread to read decoder output and push to queue
|
||||
thread = threading.Thread(
|
||||
target=stream_aprs_output,
|
||||
args=(rtl_process, decoder_process),
|
||||
args=(master_fd, rtl_process, decoder_process),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
@@ -1868,15 +1921,16 @@ def start_aprs() -> Response:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
global aprs_active_device
|
||||
global aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
with app_module.aprs_lock:
|
||||
processes_to_stop = []
|
||||
@@ -1902,14 +1956,23 @@ def stop_aprs() -> Response:
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping APRS process: {e}")
|
||||
|
||||
# Close PTY master fd
|
||||
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
|
||||
try:
|
||||
os.close(app_module.aprs_master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
app_module.aprs_master_fd = None
|
||||
|
||||
app_module.aprs_process = None
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ dsc_running = False
|
||||
|
||||
# Track which device is being used
|
||||
dsc_active_device: int | None = None
|
||||
dsc_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def _get_dsc_decoder_path() -> str | None:
|
||||
@@ -171,7 +172,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
'error': str(e)
|
||||
})
|
||||
finally:
|
||||
global dsc_active_device
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
@@ -197,8 +198,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
app_module.dsc_rtl_process = None
|
||||
# Release SDR device
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
|
||||
|
||||
def _store_critical_alert(msg: dict) -> None:
|
||||
@@ -331,10 +333,13 @@ def start_decoding() -> Response:
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Check if device is available using centralized registry
|
||||
global dsc_active_device
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -343,6 +348,7 @@ def start_decoding() -> Response:
|
||||
}), 409
|
||||
|
||||
dsc_active_device = device_int
|
||||
dsc_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.dsc_queue.empty():
|
||||
@@ -440,8 +446,9 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Tool not found: {e.filename}'
|
||||
@@ -458,8 +465,9 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
logger.error(f"Failed to start DSC decoder: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -470,7 +478,7 @@ def start_decoding() -> Response:
|
||||
@dsc_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
"""Stop DSC decoder."""
|
||||
global dsc_running, dsc_active_device
|
||||
global dsc_running, dsc_active_device, dsc_active_sdr_type
|
||||
|
||||
with app_module.dsc_lock:
|
||||
if not app_module.dsc_process:
|
||||
@@ -509,8 +517,9 @@ def stop_decoding() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ scanner_lock = threading.Lock()
|
||||
scanner_paused = False
|
||||
scanner_current_freq = 0.0
|
||||
scanner_active_device: Optional[int] = None
|
||||
scanner_active_sdr_type: str = 'rtlsdr'
|
||||
receiver_active_device: Optional[int] = None
|
||||
receiver_active_sdr_type: str = 'rtlsdr'
|
||||
scanner_power_process: Optional[subprocess.Popen] = None
|
||||
scanner_config = {
|
||||
'start_freq': 88.0,
|
||||
@@ -996,7 +998,7 @@ def check_tools() -> Response:
|
||||
@receiver_bp.route('/scanner/start', methods=['POST'])
|
||||
def start_scanner() -> Response:
|
||||
"""Start the frequency scanner."""
|
||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, receiver_active_device
|
||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type
|
||||
|
||||
with scanner_lock:
|
||||
if scanner_running:
|
||||
@@ -1063,10 +1065,11 @@ def start_scanner() -> Response:
|
||||
}), 503
|
||||
# Release listening device if active
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
# Claim device for scanner
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -1074,6 +1077,7 @@ def start_scanner() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
scanner_active_device = scanner_config['device']
|
||||
scanner_active_sdr_type = scanner_config['sdr_type']
|
||||
scanner_running = True
|
||||
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
||||
scanner_thread.start()
|
||||
@@ -1091,9 +1095,10 @@ def start_scanner() -> Response:
|
||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||
}), 503
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -1101,6 +1106,7 @@ def start_scanner() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
scanner_active_device = scanner_config['device']
|
||||
scanner_active_sdr_type = scanner_config['sdr_type']
|
||||
|
||||
scanner_running = True
|
||||
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
||||
@@ -1115,7 +1121,7 @@ def start_scanner() -> Response:
|
||||
@receiver_bp.route('/scanner/stop', methods=['POST'])
|
||||
def stop_scanner() -> Response:
|
||||
"""Stop the frequency scanner."""
|
||||
global scanner_running, scanner_active_device, scanner_power_process
|
||||
global scanner_running, scanner_active_device, scanner_active_sdr_type, scanner_power_process
|
||||
|
||||
scanner_running = False
|
||||
_stop_audio_stream()
|
||||
@@ -1130,8 +1136,9 @@ def stop_scanner() -> Response:
|
||||
pass
|
||||
scanner_power_process = 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)
|
||||
scanner_active_device = None
|
||||
scanner_active_sdr_type = 'rtlsdr'
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -1296,7 +1303,7 @@ def get_presets() -> Response:
|
||||
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||
def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
|
||||
global scanner_running, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type, scanner_power_process, scanner_thread
|
||||
global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token
|
||||
|
||||
data = request.json or {}
|
||||
@@ -1356,8 +1363,9 @@ def start_audio() -> Response:
|
||||
if scanner_running:
|
||||
scanner_running = False
|
||||
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)
|
||||
scanner_active_device = None
|
||||
scanner_active_sdr_type = 'rtlsdr'
|
||||
scanner_thread_ref = scanner_thread
|
||||
scanner_proc_ref = scanner_power_process
|
||||
scanner_power_process = None
|
||||
@@ -1419,8 +1427,9 @@ def start_audio() -> Response:
|
||||
audio_source = 'waterfall'
|
||||
# Shared monitor uses the waterfall's existing SDR claim.
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
@@ -1443,13 +1452,14 @@ def start_audio() -> Response:
|
||||
# to give the USB device time to be fully released.
|
||||
if receiver_active_device is None or receiver_active_device != device:
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
|
||||
error = None
|
||||
max_claim_attempts = 6
|
||||
for attempt in range(max_claim_attempts):
|
||||
error = app_module.claim_sdr_device(device, 'receiver')
|
||||
error = app_module.claim_sdr_device(device, 'receiver', sdr_type)
|
||||
if not error:
|
||||
break
|
||||
if attempt < max_claim_attempts - 1:
|
||||
@@ -1466,6 +1476,7 @@ def start_audio() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
receiver_active_device = device
|
||||
receiver_active_sdr_type = sdr_type
|
||||
|
||||
_start_audio_stream(
|
||||
frequency,
|
||||
@@ -1489,8 +1500,9 @@ def start_audio() -> Response:
|
||||
|
||||
# Avoid leaving a stale device claim after startup failure.
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
|
||||
start_error = ''
|
||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||
@@ -1515,11 +1527,12 @@ def start_audio() -> Response:
|
||||
@receiver_bp.route('/audio/stop', methods=['POST'])
|
||||
def stop_audio() -> Response:
|
||||
"""Stop audio."""
|
||||
global receiver_active_device
|
||||
global receiver_active_device, receiver_active_sdr_type
|
||||
_stop_audio_stream()
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -1825,6 +1838,7 @@ waterfall_running = False
|
||||
waterfall_lock = threading.Lock()
|
||||
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
waterfall_active_device: Optional[int] = None
|
||||
waterfall_active_sdr_type: str = 'rtlsdr'
|
||||
waterfall_config = {
|
||||
'start_freq': 88.0,
|
||||
'end_freq': 108.0,
|
||||
@@ -2033,7 +2047,7 @@ def _waterfall_loop():
|
||||
|
||||
def _stop_waterfall_internal() -> None:
|
||||
"""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
|
||||
if waterfall_process and waterfall_process.poll() is None:
|
||||
@@ -2048,14 +2062,15 @@ def _stop_waterfall_internal() -> None:
|
||||
waterfall_process = 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)
|
||||
waterfall_active_device = None
|
||||
waterfall_active_sdr_type = 'rtlsdr'
|
||||
|
||||
|
||||
@receiver_bp.route('/waterfall/start', methods=['POST'])
|
||||
def start_waterfall() -> Response:
|
||||
"""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:
|
||||
if waterfall_running:
|
||||
@@ -2101,11 +2116,12 @@ def start_waterfall() -> Response:
|
||||
pass
|
||||
|
||||
# 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:
|
||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||
|
||||
waterfall_active_device = waterfall_config['device']
|
||||
waterfall_active_sdr_type = 'rtlsdr'
|
||||
waterfall_running = True
|
||||
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
|
||||
waterfall_thread.start()
|
||||
|
||||
@@ -51,6 +51,7 @@ class _FilteredQueue:
|
||||
|
||||
# Track which device is being used
|
||||
morse_active_device: int | None = None
|
||||
morse_active_sdr_type: str | None = None
|
||||
|
||||
# Runtime lifecycle state.
|
||||
MORSE_IDLE = 'idle'
|
||||
@@ -231,7 +232,7 @@ def _snapshot_live_resources() -> list[str]:
|
||||
|
||||
@morse_bp.route('/morse/start', methods=['POST'])
|
||||
def start_morse() -> Response:
|
||||
global morse_active_device, morse_decoder_worker, morse_stderr_worker
|
||||
global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker
|
||||
global morse_stop_event, morse_control_queue, morse_runtime_config
|
||||
global morse_last_error, morse_session_id
|
||||
|
||||
@@ -261,6 +262,8 @@ def start_morse() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
with app_module.morse_lock:
|
||||
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
|
||||
return jsonify({
|
||||
@@ -270,7 +273,7 @@ def start_morse() -> Response:
|
||||
}), 409
|
||||
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'morse')
|
||||
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -279,6 +282,7 @@ def start_morse() -> Response:
|
||||
}), 409
|
||||
|
||||
morse_active_device = device_int
|
||||
morse_active_sdr_type = sdr_type_str
|
||||
morse_last_error = ''
|
||||
morse_session_id += 1
|
||||
|
||||
@@ -288,7 +292,6 @@ def start_morse() -> Response:
|
||||
sample_rate = 22050
|
||||
bias_t = _bool_value(data.get('bias_t', False), False)
|
||||
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -408,7 +411,7 @@ def start_morse() -> Response:
|
||||
for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1):
|
||||
if candidate_device_index != active_device_index:
|
||||
prev_device = active_device_index
|
||||
claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse')
|
||||
claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse', sdr_type_str)
|
||||
if claim_error:
|
||||
msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}'
|
||||
attempt_errors.append(msg)
|
||||
@@ -417,7 +420,7 @@ def start_morse() -> Response:
|
||||
continue
|
||||
|
||||
if prev_device is not None:
|
||||
app_module.release_sdr_device(prev_device)
|
||||
app_module.release_sdr_device(prev_device, morse_active_sdr_type or 'rtlsdr')
|
||||
active_device_index = candidate_device_index
|
||||
with app_module.morse_lock:
|
||||
morse_active_device = active_device_index
|
||||
@@ -634,8 +637,9 @@ def start_morse() -> Response:
|
||||
logger.error('Morse startup failed: %s', msg)
|
||||
with app_module.morse_lock:
|
||||
if morse_active_device is not None:
|
||||
app_module.release_sdr_device(morse_active_device)
|
||||
app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
|
||||
morse_active_device = None
|
||||
morse_active_sdr_type = None
|
||||
morse_last_error = msg
|
||||
_set_state(MORSE_ERROR, msg)
|
||||
_set_state(MORSE_IDLE, 'Idle')
|
||||
@@ -675,8 +679,9 @@ def start_morse() -> Response:
|
||||
)
|
||||
with app_module.morse_lock:
|
||||
if morse_active_device is not None:
|
||||
app_module.release_sdr_device(morse_active_device)
|
||||
app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
|
||||
morse_active_device = None
|
||||
morse_active_sdr_type = None
|
||||
morse_last_error = f'Tool not found: {e.filename}'
|
||||
_set_state(MORSE_ERROR, morse_last_error)
|
||||
_set_state(MORSE_IDLE, 'Idle')
|
||||
@@ -692,8 +697,9 @@ def start_morse() -> Response:
|
||||
)
|
||||
with app_module.morse_lock:
|
||||
if morse_active_device is not None:
|
||||
app_module.release_sdr_device(morse_active_device)
|
||||
app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
|
||||
morse_active_device = None
|
||||
morse_active_sdr_type = None
|
||||
morse_last_error = str(e)
|
||||
_set_state(MORSE_ERROR, morse_last_error)
|
||||
_set_state(MORSE_IDLE, 'Idle')
|
||||
@@ -702,7 +708,7 @@ def start_morse() -> Response:
|
||||
|
||||
@morse_bp.route('/morse/stop', methods=['POST'])
|
||||
def stop_morse() -> Response:
|
||||
global morse_active_device, morse_decoder_worker, morse_stderr_worker
|
||||
global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker
|
||||
global morse_stop_event, morse_control_queue
|
||||
|
||||
stop_started = time.perf_counter()
|
||||
@@ -717,6 +723,7 @@ def stop_morse() -> Response:
|
||||
stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None)
|
||||
control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None)
|
||||
active_device = morse_active_device
|
||||
active_sdr_type = morse_active_sdr_type
|
||||
|
||||
if (
|
||||
not rtl_proc
|
||||
@@ -768,7 +775,7 @@ def stop_morse() -> Response:
|
||||
_mark(f'stderr thread joined={stderr_joined}')
|
||||
|
||||
if active_device is not None:
|
||||
app_module.release_sdr_device(active_device)
|
||||
app_module.release_sdr_device(active_device, active_sdr_type or 'rtlsdr')
|
||||
_mark(f'SDR device {active_device} released')
|
||||
|
||||
stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1)
|
||||
@@ -782,6 +789,7 @@ def stop_morse() -> Response:
|
||||
|
||||
with app_module.morse_lock:
|
||||
morse_active_device = None
|
||||
morse_active_sdr_type = None
|
||||
_set_state(MORSE_IDLE, 'Stopped', extra={
|
||||
'stop_ms': stop_ms,
|
||||
'cleanup_steps': cleanup_steps,
|
||||
|
||||
159
routes/pager.py
159
routes/pager.py
@@ -24,7 +24,7 @@ from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
@@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
pager_active_device: int | None = None
|
||||
pager_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
@@ -96,7 +97,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
"""Log a message to file if logging is enabled."""
|
||||
if not app_module.logging_enabled:
|
||||
return
|
||||
@@ -104,39 +105,39 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||
waveform: list[int] = []
|
||||
for sample in window:
|
||||
# Convert int16 PCM to int8 range for lightweight transport.
|
||||
packed = int(round(sample / 256))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
return waveform
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event plus a compact waveform sample onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||
waveform: list[int] = []
|
||||
for sample in window:
|
||||
# Convert int16 PCM to int8 range for lightweight transport.
|
||||
packed = int(round(sample / 256))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
return waveform
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event plus a compact waveform sample onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
@@ -160,16 +161,16 @@ def audio_relay_thread(
|
||||
if n_samples == 0:
|
||||
continue
|
||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Audio relay error: {e}")
|
||||
finally:
|
||||
@@ -220,7 +221,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
@@ -249,13 +250,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
app_module.current_process = None
|
||||
# Release SDR device
|
||||
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_sdr_type = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
@@ -284,10 +286,13 @@ def start_decoding() -> Response:
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Get SDR type early so we can pass it to claim/release
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'pager')
|
||||
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -295,14 +300,16 @@ def start_decoding() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
pager_active_device = device_int
|
||||
pager_active_sdr_type = sdr_type_str
|
||||
|
||||
# Validate protocols
|
||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||
protocols = data.get('protocols', valid_protocols)
|
||||
if not isinstance(protocols, list):
|
||||
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_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||
protocols = [p for p in protocols if p in valid_protocols]
|
||||
if not protocols:
|
||||
@@ -327,8 +334,7 @@ def start_decoding() -> Response:
|
||||
elif proto == 'FLEX':
|
||||
decoders.extend(['-a', 'FLEX'])
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -443,8 +449,9 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
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_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
@@ -458,14 +465,15 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
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_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
@@ -502,8 +510,9 @@ def stop_decoding() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
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_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -553,22 +562,22 @@ def toggle_logging() -> Response:
|
||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
655
routes/radiosonde.py
Normal file
655
routes/radiosonde.py
Normal file
@@ -0,0 +1,655 @@
|
||||
"""Radiosonde weather balloon tracking routes.
|
||||
|
||||
Uses radiosonde_auto_rx to automatically scan for and decode radiosonde
|
||||
telemetry (position, altitude, temperature, humidity, pressure) on the
|
||||
400-406 MHz band. Telemetry arrives as JSON over UDP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.constants import (
|
||||
MAX_RADIOSONDE_AGE_SECONDS,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
RADIOSONDE_TERMINATE_TIMEOUT,
|
||||
RADIOSONDE_UDP_PORT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
|
||||
logger = get_logger('intercept.radiosonde')
|
||||
|
||||
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
||||
|
||||
# Track radiosonde state
|
||||
radiosonde_running = False
|
||||
radiosonde_active_device: int | None = None
|
||||
radiosonde_active_sdr_type: str | None = None
|
||||
|
||||
# Active balloon data: serial -> telemetry dict
|
||||
radiosonde_balloons: dict[str, dict[str, Any]] = {}
|
||||
_balloons_lock = threading.Lock()
|
||||
|
||||
# UDP listener socket reference (so /stop can close it)
|
||||
_udp_socket: socket.socket | None = None
|
||||
|
||||
# Common installation paths for radiosonde_auto_rx
|
||||
AUTO_RX_PATHS = [
|
||||
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
|
||||
'/usr/local/bin/radiosonde_auto_rx',
|
||||
'/opt/auto_rx/auto_rx.py',
|
||||
]
|
||||
|
||||
|
||||
def find_auto_rx() -> str | None:
|
||||
"""Find radiosonde_auto_rx script/binary."""
|
||||
# Check PATH first
|
||||
path = shutil.which('radiosonde_auto_rx')
|
||||
if path:
|
||||
return path
|
||||
# Check common locations
|
||||
for p in AUTO_RX_PATHS:
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
# Check for Python script (not executable but runnable)
|
||||
for p in AUTO_RX_PATHS:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def generate_station_cfg(
|
||||
freq_min: float = 400.0,
|
||||
freq_max: float = 406.0,
|
||||
gain: float = 40.0,
|
||||
device_index: int = 0,
|
||||
ppm: int = 0,
|
||||
bias_t: bool = False,
|
||||
udp_port: int = RADIOSONDE_UDP_PORT,
|
||||
) -> str:
|
||||
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
|
||||
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
|
||||
log_dir = os.path.join(cfg_dir, 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
cfg_path = os.path.join(cfg_dir, 'station.cfg')
|
||||
|
||||
# Full station.cfg based on radiosonde_auto_rx v1.8+ example config.
|
||||
# All sections and keys included to avoid missing-key crashes.
|
||||
cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx
|
||||
|
||||
[sdr]
|
||||
sdr_type = RTLSDR
|
||||
sdr_quantity = 1
|
||||
sdr_hostname = localhost
|
||||
sdr_port = 5555
|
||||
|
||||
[sdr_1]
|
||||
device_idx = {device_index}
|
||||
ppm = {ppm}
|
||||
gain = {gain}
|
||||
bias = {str(bias_t)}
|
||||
|
||||
[search_params]
|
||||
min_freq = {freq_min}
|
||||
max_freq = {freq_max}
|
||||
rx_timeout = 180
|
||||
only_scan = []
|
||||
never_scan = []
|
||||
always_scan = []
|
||||
always_decode = []
|
||||
|
||||
[location]
|
||||
station_lat = 0.0
|
||||
station_lon = 0.0
|
||||
station_alt = 0.0
|
||||
gpsd_enabled = False
|
||||
gpsd_host = localhost
|
||||
gpsd_port = 2947
|
||||
|
||||
[habitat]
|
||||
uploader_callsign = INTERCEPT
|
||||
upload_listener_position = False
|
||||
uploader_antenna = unknown
|
||||
|
||||
[sondehub]
|
||||
sondehub_enabled = False
|
||||
sondehub_upload_rate = 15
|
||||
sondehub_contact_email = none@none.com
|
||||
|
||||
[aprs]
|
||||
aprs_enabled = False
|
||||
aprs_user = N0CALL
|
||||
aprs_pass = 00000
|
||||
upload_rate = 30
|
||||
aprs_server = radiosondy.info
|
||||
aprs_port = 14580
|
||||
station_beacon_enabled = False
|
||||
station_beacon_rate = 30
|
||||
station_beacon_comment = radiosonde_auto_rx
|
||||
station_beacon_icon = /`
|
||||
aprs_object_id = <id>
|
||||
aprs_use_custom_object_id = False
|
||||
aprs_custom_comment = <type> <freq>
|
||||
|
||||
[oziplotter]
|
||||
ozi_enabled = False
|
||||
ozi_update_rate = 5
|
||||
ozi_host = 127.0.0.1
|
||||
ozi_port = 8942
|
||||
payload_summary_enabled = True
|
||||
payload_summary_host = 127.0.0.1
|
||||
payload_summary_port = {udp_port}
|
||||
|
||||
[email]
|
||||
email_enabled = False
|
||||
launch_notifications = True
|
||||
landing_notifications = True
|
||||
encrypted_sonde_notifications = True
|
||||
landing_range_threshold = 30
|
||||
landing_altitude_threshold = 1000
|
||||
error_notifications = False
|
||||
smtp_server = localhost
|
||||
smtp_port = 25
|
||||
smtp_authentication = None
|
||||
smtp_login = None
|
||||
smtp_password = None
|
||||
from = sonde@localhost
|
||||
to = none@none.com
|
||||
subject = Sonde launch detected
|
||||
|
||||
[rotator]
|
||||
rotator_enabled = False
|
||||
update_rate = 30
|
||||
rotation_threshold = 5.0
|
||||
rotator_hostname = 127.0.0.1
|
||||
rotator_port = 4533
|
||||
rotator_homing_enabled = False
|
||||
rotator_homing_delay = 10
|
||||
rotator_home_azimuth = 0.0
|
||||
rotator_home_elevation = 0.0
|
||||
azimuth_only = False
|
||||
|
||||
[logging]
|
||||
per_sonde_log = True
|
||||
save_system_log = False
|
||||
enable_debug_logging = False
|
||||
save_cal_data = False
|
||||
|
||||
[web]
|
||||
web_host = 127.0.0.1
|
||||
web_port = 0
|
||||
archive_age = 120
|
||||
web_control = False
|
||||
web_password = none
|
||||
kml_refresh_rate = 10
|
||||
|
||||
[debugging]
|
||||
save_detection_audio = False
|
||||
save_decode_audio = False
|
||||
save_decode_iq = False
|
||||
save_raw_hex = False
|
||||
|
||||
[advanced]
|
||||
search_step = 800
|
||||
snr_threshold = 10
|
||||
max_peaks = 10
|
||||
min_distance = 1000
|
||||
scan_dwell_time = 20
|
||||
detect_dwell_time = 5
|
||||
scan_delay = 10
|
||||
quantization = 10000
|
||||
decoder_spacing_limit = 15000
|
||||
temporary_block_time = 120
|
||||
max_async_scan_workers = 4
|
||||
synchronous_upload = True
|
||||
payload_id_valid = 3
|
||||
sdr_fm_path = rtl_fm
|
||||
sdr_power_path = rtl_power
|
||||
ss_iq_path = ./ss_iq
|
||||
ss_power_path = ./ss_power
|
||||
|
||||
[filtering]
|
||||
max_altitude = 50000
|
||||
max_radius_km = 1000
|
||||
min_radius_km = 0
|
||||
radius_temporary_block = False
|
||||
sonde_time_threshold = 3
|
||||
"""
|
||||
|
||||
with open(cfg_path, 'w') as f:
|
||||
f.write(cfg)
|
||||
|
||||
logger.info(f"Generated station.cfg at {cfg_path}")
|
||||
return cfg_path
|
||||
|
||||
|
||||
def parse_radiosonde_udp(udp_port: int) -> None:
|
||||
"""Thread function: listen for radiosonde_auto_rx UDP JSON telemetry."""
|
||||
global radiosonde_running, _udp_socket
|
||||
|
||||
logger.info(f"Radiosonde UDP listener started on port {udp_port}")
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(('0.0.0.0', udp_port))
|
||||
sock.settimeout(2.0)
|
||||
_udp_socket = sock
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to bind UDP port {udp_port}: {e}")
|
||||
return
|
||||
|
||||
while radiosonde_running:
|
||||
try:
|
||||
data, _addr = sock.recvfrom(4096)
|
||||
except socket.timeout:
|
||||
# Clean up stale balloons
|
||||
_cleanup_stale_balloons()
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
|
||||
try:
|
||||
msg = json.loads(data.decode('utf-8', errors='ignore'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
balloon = _process_telemetry(msg)
|
||||
if balloon:
|
||||
serial = balloon.get('id', '')
|
||||
if serial:
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons[serial] = balloon
|
||||
try:
|
||||
app_module.radiosonde_queue.put_nowait({
|
||||
'type': 'balloon',
|
||||
**balloon,
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
_udp_socket = None
|
||||
logger.info("Radiosonde UDP listener stopped")
|
||||
|
||||
|
||||
def _process_telemetry(msg: dict) -> dict | None:
|
||||
"""Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet."""
|
||||
# auto_rx broadcasts packets with a 'type' field
|
||||
# Telemetry packets have type 'payload_summary' or individual sonde data
|
||||
serial = msg.get('id') or msg.get('serial')
|
||||
if not serial:
|
||||
return None
|
||||
|
||||
balloon: dict[str, Any] = {'id': str(serial)}
|
||||
|
||||
# Sonde type (RS41, RS92, DFM, M10, etc.)
|
||||
if 'type' in msg:
|
||||
balloon['sonde_type'] = msg['type']
|
||||
if 'subtype' in msg:
|
||||
balloon['sonde_type'] = msg['subtype']
|
||||
|
||||
# Timestamp
|
||||
if 'datetime' in msg:
|
||||
balloon['datetime'] = msg['datetime']
|
||||
|
||||
# Position
|
||||
for key in ('lat', 'latitude'):
|
||||
if key in msg:
|
||||
try:
|
||||
balloon['lat'] = float(msg[key])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
for key in ('lon', 'longitude'):
|
||||
if key in msg:
|
||||
try:
|
||||
balloon['lon'] = float(msg[key])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
|
||||
# Altitude (metres)
|
||||
if 'alt' in msg:
|
||||
try:
|
||||
balloon['alt'] = float(msg['alt'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Meteorological data
|
||||
for field in ('temp', 'humidity', 'pressure'):
|
||||
if field in msg:
|
||||
try:
|
||||
balloon[field] = float(msg[field])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Velocity
|
||||
if 'vel_h' in msg:
|
||||
try:
|
||||
balloon['vel_h'] = float(msg['vel_h'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if 'vel_v' in msg:
|
||||
try:
|
||||
balloon['vel_v'] = float(msg['vel_v'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if 'heading' in msg:
|
||||
try:
|
||||
balloon['heading'] = float(msg['heading'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# GPS satellites
|
||||
if 'sats' in msg:
|
||||
try:
|
||||
balloon['sats'] = int(msg['sats'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Battery voltage
|
||||
if 'batt' in msg:
|
||||
try:
|
||||
balloon['batt'] = float(msg['batt'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Frequency
|
||||
if 'freq' in msg:
|
||||
try:
|
||||
balloon['freq'] = float(msg['freq'])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
balloon['last_seen'] = time.time()
|
||||
return balloon
|
||||
|
||||
|
||||
def _cleanup_stale_balloons() -> None:
|
||||
"""Remove balloons not seen within the retention window."""
|
||||
now = time.time()
|
||||
with _balloons_lock:
|
||||
stale = [
|
||||
k for k, v in radiosonde_balloons.items()
|
||||
if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS
|
||||
]
|
||||
for k in stale:
|
||||
del radiosonde_balloons[k]
|
||||
|
||||
|
||||
@radiosonde_bp.route('/tools')
|
||||
def check_tools():
|
||||
"""Check for radiosonde decoding tools and hardware."""
|
||||
auto_rx_path = find_auto_rx()
|
||||
devices = SDRFactory.detect_devices()
|
||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
|
||||
return jsonify({
|
||||
'auto_rx': auto_rx_path is not None,
|
||||
'auto_rx_path': auto_rx_path,
|
||||
'has_rtlsdr': has_rtlsdr,
|
||||
'device_count': len(devices),
|
||||
})
|
||||
|
||||
|
||||
@radiosonde_bp.route('/status')
|
||||
def radiosonde_status():
|
||||
"""Get radiosonde tracking status."""
|
||||
process_running = False
|
||||
if app_module.radiosonde_process:
|
||||
process_running = app_module.radiosonde_process.poll() is None
|
||||
|
||||
with _balloons_lock:
|
||||
balloon_count = len(radiosonde_balloons)
|
||||
balloons_snapshot = dict(radiosonde_balloons)
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': radiosonde_running,
|
||||
'active_device': radiosonde_active_device,
|
||||
'balloon_count': balloon_count,
|
||||
'balloons': balloons_snapshot,
|
||||
'queue_size': app_module.radiosonde_queue.qsize(),
|
||||
'auto_rx_path': find_auto_rx(),
|
||||
'process_running': process_running,
|
||||
})
|
||||
|
||||
|
||||
@radiosonde_bp.route('/start', methods=['POST'])
|
||||
def start_radiosonde():
|
||||
"""Start radiosonde tracking."""
|
||||
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type
|
||||
|
||||
with app_module.radiosonde_lock:
|
||||
if radiosonde_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'message': 'Radiosonde tracking already active',
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
gain = float(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
freq_min = data.get('freq_min', 400.0)
|
||||
freq_max = data.get('freq_max', 406.0)
|
||||
try:
|
||||
freq_min = float(freq_min)
|
||||
freq_max = float(freq_max)
|
||||
if not (380.0 <= freq_min <= 410.0) or not (380.0 <= freq_max <= 410.0):
|
||||
raise ValueError("Frequency out of range")
|
||||
if freq_min >= freq_max:
|
||||
raise ValueError("Min frequency must be less than max")
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid frequency range: {e}'}), 400
|
||||
|
||||
bias_t = data.get('bias_t', False)
|
||||
ppm = int(data.get('ppm', 0))
|
||||
|
||||
# Find auto_rx
|
||||
auto_rx_path = find_auto_rx()
|
||||
if not auto_rx_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx',
|
||||
}), 400
|
||||
|
||||
# Get SDR type
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Kill any existing process
|
||||
if app_module.radiosonde_process:
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 15)
|
||||
app_module.radiosonde_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 9)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.radiosonde_process = None
|
||||
logger.info("Killed existing radiosonde process")
|
||||
|
||||
# Claim SDR device
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
|
||||
# Generate config
|
||||
cfg_path = generate_station_cfg(
|
||||
freq_min=freq_min,
|
||||
freq_max=freq_max,
|
||||
gain=gain,
|
||||
device_index=device_int,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
|
||||
# Build command - auto_rx -c expects a file path, not a directory
|
||||
cfg_abs = os.path.abspath(cfg_path)
|
||||
if auto_rx_path.endswith('.py'):
|
||||
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
|
||||
else:
|
||||
cmd = [auto_rx_path, '-c', cfg_abs]
|
||||
|
||||
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
|
||||
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
||||
|
||||
try:
|
||||
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
||||
app_module.radiosonde_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
cwd=auto_rx_dir,
|
||||
)
|
||||
|
||||
# Wait briefly for process to start
|
||||
time.sleep(2.0)
|
||||
|
||||
if app_module.radiosonde_process.poll() is not None:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
stderr_output = ''
|
||||
if app_module.radiosonde_process.stderr:
|
||||
try:
|
||||
stderr_output = app_module.radiosonde_process.stderr.read().decode(
|
||||
'utf-8', errors='ignore'
|
||||
).strip()
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = 'radiosonde_auto_rx failed to start. Check SDR device connection.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
radiosonde_running = True
|
||||
radiosonde_active_device = device_int
|
||||
radiosonde_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear stale data
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons.clear()
|
||||
|
||||
# Start UDP listener thread
|
||||
udp_thread = threading.Thread(
|
||||
target=parse_radiosonde_udp,
|
||||
args=(RADIOSONDE_UDP_PORT,),
|
||||
daemon=True,
|
||||
)
|
||||
udp_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': 'Radiosonde tracking started',
|
||||
'device': device,
|
||||
})
|
||||
except Exception as e:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
logger.error(f"Failed to start radiosonde_auto_rx: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@radiosonde_bp.route('/stop', methods=['POST'])
|
||||
def stop_radiosonde():
|
||||
"""Stop radiosonde tracking."""
|
||||
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket
|
||||
|
||||
with app_module.radiosonde_lock:
|
||||
if app_module.radiosonde_process:
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 15)
|
||||
app_module.radiosonde_process.wait(timeout=RADIOSONDE_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 9)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.radiosonde_process = None
|
||||
logger.info("Radiosonde process stopped")
|
||||
|
||||
# Close UDP socket to unblock listener thread
|
||||
if _udp_socket:
|
||||
try:
|
||||
_udp_socket.close()
|
||||
except OSError:
|
||||
pass
|
||||
_udp_socket = None
|
||||
|
||||
# Release SDR device
|
||||
if radiosonde_active_device is not None:
|
||||
app_module.release_sdr_device(
|
||||
radiosonde_active_device,
|
||||
radiosonde_active_sdr_type or 'rtlsdr',
|
||||
)
|
||||
|
||||
radiosonde_running = False
|
||||
radiosonde_active_device = None
|
||||
radiosonde_active_sdr_type = None
|
||||
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons.clear()
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@radiosonde_bp.route('/stream')
|
||||
def stream_radiosonde():
|
||||
"""SSE stream for radiosonde telemetry."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.radiosonde_queue,
|
||||
channel_key='radiosonde',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@radiosonde_bp.route('/balloons')
|
||||
def get_balloons():
|
||||
"""Get current balloon data."""
|
||||
with _balloons_lock:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'count': len(radiosonde_balloons),
|
||||
'balloons': dict(radiosonde_balloons),
|
||||
})
|
||||
522
routes/sensor.py
522
routes/sensor.py
@@ -1,5 +1,5 @@
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -10,25 +10,26 @@ import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
sensor_active_sdr_type: str | None = None
|
||||
|
||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||
_MAX_RSSI_HISTORY = 60
|
||||
@@ -65,36 +66,36 @@ def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 2
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
if rssi is not None or snr is not None:
|
||||
try:
|
||||
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||
@@ -113,204 +114,211 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
})
|
||||
except (TypeError, ValueError, queue.Full):
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global sensor_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
sensor_active_device = device_int
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
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:
|
||||
# Validate and create network device
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Get SDR type early so we can pass it to claim/release
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
sensor_active_device = device_int
|
||||
sensor_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
@@ -330,12 +338,12 @@ def stream_sensor() -> Response:
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return jsonify({'status': 'success', 'devices': result})
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return jsonify({'status': 'success', 'devices': result})
|
||||
|
||||
270
routes/system.py
270
routes/system.py
@@ -1,7 +1,8 @@
|
||||
"""System Health monitoring blueprint.
|
||||
|
||||
Provides real-time system metrics (CPU, memory, disk, temperatures),
|
||||
active process status, and SDR device enumeration via SSE streaming.
|
||||
Provides real-time system metrics (CPU, memory, disk, temperatures,
|
||||
network, battery, fans), active process status, SDR device enumeration,
|
||||
location, and weather data via SSE streaming and REST endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,11 +12,13 @@ import os
|
||||
import platform
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||
from utils.logging import sensor_logger as logger
|
||||
@@ -29,6 +32,11 @@ except ImportError:
|
||||
psutil = None # type: ignore[assignment]
|
||||
_HAS_PSUTIL = False
|
||||
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
_requests = None # type: ignore[assignment]
|
||||
|
||||
system_bp = Blueprint('system', __name__, url_prefix='/system')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -40,6 +48,11 @@ _collector_started = False
|
||||
_collector_lock = threading.Lock()
|
||||
_app_start_time: float | None = None
|
||||
|
||||
# Weather cache
|
||||
_weather_cache: dict[str, Any] = {}
|
||||
_weather_cache_time: float = 0.0
|
||||
_WEATHER_CACHE_TTL = 600 # 10 minutes
|
||||
|
||||
|
||||
def _get_app_start_time() -> float:
|
||||
"""Return the application start timestamp from the main app module."""
|
||||
@@ -138,6 +151,38 @@ def _collect_process_status() -> dict[str, bool]:
|
||||
return {}
|
||||
|
||||
|
||||
def _collect_throttle_flags() -> str | None:
|
||||
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['vcgencmd', 'get_throttled'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if result.returncode == 0 and 'throttled=' in result.stdout:
|
||||
return result.stdout.strip().split('=', 1)[1]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _collect_power_draw() -> float | None:
|
||||
"""Read power draw in watts from sysfs (Linux only)."""
|
||||
try:
|
||||
power_supply = Path('/sys/class/power_supply')
|
||||
if not power_supply.exists():
|
||||
return None
|
||||
for supply_dir in power_supply.iterdir():
|
||||
power_file = supply_dir / 'power_now'
|
||||
if power_file.exists():
|
||||
val = int(power_file.read_text().strip())
|
||||
return round(val / 1_000_000, 2) # microwatts to watts
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _collect_metrics() -> dict[str, Any]:
|
||||
"""Gather a snapshot of system metrics."""
|
||||
now = time.time()
|
||||
@@ -159,7 +204,7 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
if _HAS_PSUTIL:
|
||||
# CPU
|
||||
# CPU — overall + per-core + frequency
|
||||
cpu_percent = psutil.cpu_percent(interval=None)
|
||||
cpu_count = psutil.cpu_count() or 1
|
||||
try:
|
||||
@@ -167,12 +212,28 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
except (OSError, AttributeError):
|
||||
load_1 = load_5 = load_15 = 0.0
|
||||
|
||||
per_core = []
|
||||
with contextlib.suppress(Exception):
|
||||
per_core = psutil.cpu_percent(interval=None, percpu=True)
|
||||
|
||||
freq_data = None
|
||||
with contextlib.suppress(Exception):
|
||||
freq = psutil.cpu_freq()
|
||||
if freq:
|
||||
freq_data = {
|
||||
'current': round(freq.current, 0),
|
||||
'min': round(freq.min, 0),
|
||||
'max': round(freq.max, 0),
|
||||
}
|
||||
|
||||
metrics['cpu'] = {
|
||||
'percent': cpu_percent,
|
||||
'count': cpu_count,
|
||||
'load_1': round(load_1, 2),
|
||||
'load_5': round(load_5, 2),
|
||||
'load_15': round(load_15, 2),
|
||||
'per_core': per_core,
|
||||
'freq': freq_data,
|
||||
}
|
||||
|
||||
# Memory
|
||||
@@ -191,7 +252,7 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
'percent': swap.percent,
|
||||
}
|
||||
|
||||
# Disk
|
||||
# Disk — usage + I/O counters
|
||||
try:
|
||||
disk = psutil.disk_usage('/')
|
||||
metrics['disk'] = {
|
||||
@@ -204,6 +265,18 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
except Exception:
|
||||
metrics['disk'] = None
|
||||
|
||||
disk_io = None
|
||||
with contextlib.suppress(Exception):
|
||||
dio = psutil.disk_io_counters()
|
||||
if dio:
|
||||
disk_io = {
|
||||
'read_bytes': dio.read_bytes,
|
||||
'write_bytes': dio.write_bytes,
|
||||
'read_count': dio.read_count,
|
||||
'write_count': dio.write_count,
|
||||
}
|
||||
metrics['disk_io'] = disk_io
|
||||
|
||||
# Temperatures
|
||||
try:
|
||||
temps = psutil.sensors_temperatures()
|
||||
@@ -224,12 +297,102 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
metrics['temperatures'] = None
|
||||
except (AttributeError, Exception):
|
||||
metrics['temperatures'] = None
|
||||
|
||||
# Fans
|
||||
fans_data = None
|
||||
with contextlib.suppress(Exception):
|
||||
fans = psutil.sensors_fans()
|
||||
if fans:
|
||||
fans_data = {}
|
||||
for chip, entries in fans.items():
|
||||
fans_data[chip] = [
|
||||
{'label': e.label or chip, 'current': e.current}
|
||||
for e in entries
|
||||
]
|
||||
metrics['fans'] = fans_data
|
||||
|
||||
# Battery
|
||||
battery_data = None
|
||||
with contextlib.suppress(Exception):
|
||||
bat = psutil.sensors_battery()
|
||||
if bat:
|
||||
battery_data = {
|
||||
'percent': bat.percent,
|
||||
'plugged': bat.power_plugged,
|
||||
'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
|
||||
}
|
||||
metrics['battery'] = battery_data
|
||||
|
||||
# Network interfaces
|
||||
net_ifaces: list[dict[str, Any]] = []
|
||||
with contextlib.suppress(Exception):
|
||||
addrs = psutil.net_if_addrs()
|
||||
stats = psutil.net_if_stats()
|
||||
for iface_name in sorted(addrs.keys()):
|
||||
if iface_name == 'lo':
|
||||
continue
|
||||
iface_info: dict[str, Any] = {'name': iface_name}
|
||||
# Get addresses
|
||||
for addr in addrs[iface_name]:
|
||||
if addr.family == socket.AF_INET:
|
||||
iface_info['ipv4'] = addr.address
|
||||
elif addr.family == socket.AF_INET6:
|
||||
iface_info.setdefault('ipv6', addr.address)
|
||||
elif addr.family == psutil.AF_LINK:
|
||||
iface_info['mac'] = addr.address
|
||||
# Get stats
|
||||
if iface_name in stats:
|
||||
st = stats[iface_name]
|
||||
iface_info['is_up'] = st.isup
|
||||
iface_info['speed'] = st.speed # Mbps
|
||||
iface_info['mtu'] = st.mtu
|
||||
net_ifaces.append(iface_info)
|
||||
metrics['network'] = {'interfaces': net_ifaces}
|
||||
|
||||
# Network I/O counters (raw — JS computes deltas)
|
||||
net_io = None
|
||||
with contextlib.suppress(Exception):
|
||||
counters = psutil.net_io_counters(pernic=True)
|
||||
if counters:
|
||||
net_io = {}
|
||||
for nic, c in counters.items():
|
||||
if nic == 'lo':
|
||||
continue
|
||||
net_io[nic] = {
|
||||
'bytes_sent': c.bytes_sent,
|
||||
'bytes_recv': c.bytes_recv,
|
||||
}
|
||||
metrics['network']['io'] = net_io
|
||||
|
||||
# Connection count
|
||||
conn_count = 0
|
||||
with contextlib.suppress(Exception):
|
||||
conn_count = len(psutil.net_connections())
|
||||
metrics['network']['connections'] = conn_count
|
||||
|
||||
# Boot time
|
||||
boot_ts = None
|
||||
with contextlib.suppress(Exception):
|
||||
boot_ts = psutil.boot_time()
|
||||
metrics['boot_time'] = boot_ts
|
||||
|
||||
# Power / throttle (Pi-specific)
|
||||
metrics['power'] = {
|
||||
'throttled': _collect_throttle_flags(),
|
||||
'draw_watts': _collect_power_draw(),
|
||||
}
|
||||
else:
|
||||
metrics['cpu'] = None
|
||||
metrics['memory'] = None
|
||||
metrics['swap'] = None
|
||||
metrics['disk'] = None
|
||||
metrics['disk_io'] = None
|
||||
metrics['temperatures'] = None
|
||||
metrics['fans'] = None
|
||||
metrics['battery'] = None
|
||||
metrics['network'] = None
|
||||
metrics['boot_time'] = None
|
||||
metrics['power'] = None
|
||||
|
||||
return metrics
|
||||
|
||||
@@ -270,6 +433,47 @@ def _ensure_collector() -> None:
|
||||
logger.info('System metrics collector started')
|
||||
|
||||
|
||||
def _get_observer_location() -> dict[str, Any]:
|
||||
"""Get observer location from GPS state or config defaults."""
|
||||
lat, lon, source = None, None, 'none'
|
||||
gps_meta: dict[str, Any] = {}
|
||||
|
||||
# Try GPS via utils.gps
|
||||
with contextlib.suppress(Exception):
|
||||
from utils.gps import get_current_position
|
||||
|
||||
pos = get_current_position()
|
||||
if pos and pos.fix_quality >= 2:
|
||||
lat, lon, source = pos.latitude, pos.longitude, 'gps'
|
||||
gps_meta['fix_quality'] = pos.fix_quality
|
||||
gps_meta['satellites'] = pos.satellites
|
||||
if pos.epx is not None and pos.epy is not None:
|
||||
gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1)
|
||||
if pos.altitude is not None:
|
||||
gps_meta['altitude'] = round(pos.altitude, 1)
|
||||
|
||||
# Fall back to config env vars
|
||||
if lat is None:
|
||||
with contextlib.suppress(Exception):
|
||||
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
|
||||
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
|
||||
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
|
||||
|
||||
# Fall back to hardcoded constants (London)
|
||||
if lat is None:
|
||||
with contextlib.suppress(Exception):
|
||||
from utils.constants import DEFAULT_LATITUDE as CONST_LAT
|
||||
from utils.constants import DEFAULT_LONGITUDE as CONST_LON
|
||||
|
||||
lat, lon, source = CONST_LAT, CONST_LON, 'default'
|
||||
|
||||
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
|
||||
if gps_meta:
|
||||
result['gps'] = gps_meta
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -321,3 +525,59 @@ def get_sdr_devices() -> Response:
|
||||
except Exception as exc:
|
||||
logger.warning('SDR device detection failed: %s', exc)
|
||||
return jsonify({'devices': [], 'error': str(exc)})
|
||||
|
||||
|
||||
@system_bp.route('/location')
|
||||
def get_location() -> Response:
|
||||
"""Return observer location from GPS or config."""
|
||||
return jsonify(_get_observer_location())
|
||||
|
||||
|
||||
@system_bp.route('/weather')
|
||||
def get_weather() -> Response:
|
||||
"""Proxy weather from wttr.in, cached for 10 minutes."""
|
||||
global _weather_cache, _weather_cache_time
|
||||
|
||||
now = time.time()
|
||||
if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL:
|
||||
return jsonify(_weather_cache)
|
||||
|
||||
lat = request.args.get('lat', type=float)
|
||||
lon = request.args.get('lon', type=float)
|
||||
if lat is None or lon is None:
|
||||
loc = _get_observer_location()
|
||||
lat, lon = loc.get('lat'), loc.get('lon')
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({'error': 'No location available'})
|
||||
|
||||
if _requests is None:
|
||||
return jsonify({'error': 'requests library not available'})
|
||||
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f'https://wttr.in/{lat},{lon}?format=j1',
|
||||
timeout=5,
|
||||
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
current = data.get('current_condition', [{}])[0]
|
||||
weather = {
|
||||
'temp_c': current.get('temp_C'),
|
||||
'temp_f': current.get('temp_F'),
|
||||
'condition': current.get('weatherDesc', [{}])[0].get('value', ''),
|
||||
'humidity': current.get('humidity'),
|
||||
'wind_mph': current.get('windspeedMiles'),
|
||||
'wind_dir': current.get('winddir16Point'),
|
||||
'feels_like_c': current.get('FeelsLikeC'),
|
||||
'visibility': current.get('visibility'),
|
||||
'pressure': current.get('pressure'),
|
||||
}
|
||||
_weather_cache = weather
|
||||
_weather_cache_time = now
|
||||
return jsonify(weather)
|
||||
except Exception as exc:
|
||||
logger.debug('Weather fetch failed: %s', exc)
|
||||
return jsonify({'error': str(exc)})
|
||||
|
||||
@@ -48,6 +48,7 @@ vdl2_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
vdl2_active_device: int | None = None
|
||||
vdl2_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def find_dumpvdl2():
|
||||
@@ -126,7 +127,7 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
logger.error(f"VDL2 stream error: {e}")
|
||||
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global vdl2_active_device
|
||||
global vdl2_active_device, vdl2_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
@@ -142,8 +143,9 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
app_module.vdl2_process = None
|
||||
# Release SDR device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
|
||||
|
||||
@vdl2_bp.route('/tools')
|
||||
@@ -175,7 +177,7 @@ def vdl2_status() -> Response:
|
||||
@vdl2_bp.route('/start', methods=['POST'])
|
||||
def start_vdl2() -> Response:
|
||||
"""Start VDL2 decoder."""
|
||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
|
||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||
@@ -202,9 +204,16 @@ def start_vdl2() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2')
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -213,6 +222,7 @@ def start_vdl2() -> Response:
|
||||
}), 409
|
||||
|
||||
vdl2_active_device = device_int
|
||||
vdl2_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
# dumpvdl2 expects frequencies in Hz (integers)
|
||||
@@ -231,13 +241,6 @@ def start_vdl2() -> Response:
|
||||
vdl2_message_count = 0
|
||||
vdl2_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||
|
||||
# Build dumpvdl2 command
|
||||
@@ -297,8 +300,9 @@ def start_vdl2() -> Response:
|
||||
if process.poll() is not None:
|
||||
# Process died - release device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
@@ -329,8 +333,9 @@ def start_vdl2() -> Response:
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -338,7 +343,7 @@ def start_vdl2() -> Response:
|
||||
@vdl2_bp.route('/stop', methods=['POST'])
|
||||
def stop_vdl2() -> Response:
|
||||
"""Stop VDL2 decoder."""
|
||||
global vdl2_active_device
|
||||
global vdl2_active_device, vdl2_active_sdr_type
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if not app_module.vdl2_process:
|
||||
@@ -359,8 +364,9 @@ def stop_vdl2() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -386,6 +392,7 @@ def stream_vdl2() -> Response:
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@vdl2_bp.route('/messages')
|
||||
def get_vdl2_messages() -> Response:
|
||||
"""Get recent VDL2 messages from correlator (for history reload)."""
|
||||
|
||||
@@ -367,6 +367,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
my_generation = None # tracks which capture generation this handler owns
|
||||
capture_center_mhz = 0.0
|
||||
capture_start_freq = 0.0
|
||||
@@ -430,8 +431,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
_set_shared_capture_state(running=False, generation=my_generation)
|
||||
my_generation = None
|
||||
stop_event.clear()
|
||||
@@ -513,7 +515,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
max_claim_attempts = 4 if was_restarting else 1
|
||||
claim_err = None
|
||||
for _claim_attempt in range(max_claim_attempts):
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type_str)
|
||||
if not claim_err:
|
||||
break
|
||||
if _claim_attempt < max_claim_attempts - 1:
|
||||
@@ -526,6 +528,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
claimed_sdr_type = sdr_type_str
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
@@ -539,8 +542,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index)
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
@@ -549,8 +553,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
|
||||
# Pre-flight: check the capture binary exists
|
||||
if not shutil.which(iq_cmd[0]):
|
||||
app_module.release_sdr_device(device_index)
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).',
|
||||
@@ -602,8 +607,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index)
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
@@ -806,8 +812,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
_set_shared_capture_state(running=False, generation=my_generation)
|
||||
my_generation = None
|
||||
stop_event.clear()
|
||||
@@ -825,7 +832,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
_set_shared_capture_state(running=False, generation=my_generation)
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
|
||||
@@ -33,11 +33,12 @@ _wefax_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track active SDR device
|
||||
wefax_active_device: int | None = None
|
||||
wefax_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
global wefax_active_device
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
|
||||
try:
|
||||
_wefax_queue.put_nowait(data)
|
||||
@@ -56,8 +57,9 @@ def _progress_callback(data: dict) -> None:
|
||||
and data.get('status') in ('complete', 'error', 'stopped')
|
||||
and wefax_active_device is not None
|
||||
):
|
||||
app_module.release_sdr_device(wefax_active_device)
|
||||
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
|
||||
wefax_active_device = None
|
||||
wefax_active_sdr_type = None
|
||||
|
||||
|
||||
@wefax_bp.route('/status')
|
||||
@@ -169,9 +171,9 @@ def start_decoder():
|
||||
}), 400
|
||||
|
||||
# Claim SDR device
|
||||
global wefax_active_device
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'wefax')
|
||||
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -194,6 +196,7 @@ def start_decoder():
|
||||
|
||||
if success:
|
||||
wefax_active_device = device_int
|
||||
wefax_active_sdr_type = sdr_type_str
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency_khz': frequency_khz,
|
||||
@@ -209,7 +212,7 @@ def start_decoder():
|
||||
'device': device_int,
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
@@ -219,13 +222,14 @@ def start_decoder():
|
||||
@wefax_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop WeFax decoder."""
|
||||
global wefax_active_device
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
decoder = get_wefax_decoder()
|
||||
decoder.stop()
|
||||
|
||||
if wefax_active_device is not None:
|
||||
app_module.release_sdr_device(wefax_active_device)
|
||||
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
|
||||
wefax_active_device = None
|
||||
wefax_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
80
setup.sh
80
setup.sh
@@ -229,6 +229,7 @@ check_tools() {
|
||||
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||
check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py
|
||||
echo
|
||||
info "GPS:"
|
||||
check_required "gpsd" "GPS daemon" gpsd
|
||||
@@ -816,6 +817,53 @@ WRAPPER
|
||||
)
|
||||
}
|
||||
|
||||
install_radiosonde_auto_rx() {
|
||||
info "Installing radiosonde_auto_rx (weather balloon decoder)..."
|
||||
local install_dir="/opt/radiosonde_auto_rx"
|
||||
local project_dir="$(pwd)"
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning radiosonde_auto_rx..."
|
||||
if ! git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git "$tmp_dir/radiosonde_auto_rx"; then
|
||||
warn "Failed to clone radiosonde_auto_rx"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Installing Python dependencies..."
|
||||
cd "$tmp_dir/radiosonde_auto_rx/auto_rx"
|
||||
# Use project venv pip to avoid PEP 668 externally-managed-environment errors
|
||||
if [ -x "$project_dir/venv/bin/pip" ]; then
|
||||
"$project_dir/venv/bin/pip" install --quiet -r requirements.txt || {
|
||||
warn "Failed to install radiosonde_auto_rx Python dependencies"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
pip3 install --quiet --break-system-packages -r requirements.txt 2>/dev/null \
|
||||
|| pip3 install --quiet -r requirements.txt || {
|
||||
warn "Failed to install radiosonde_auto_rx Python dependencies"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
info "Building radiosonde_auto_rx C decoders..."
|
||||
if ! bash build.sh; then
|
||||
warn "Failed to build radiosonde_auto_rx decoders"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Installing to ${install_dir}..."
|
||||
refresh_sudo
|
||||
$SUDO mkdir -p "$install_dir/auto_rx"
|
||||
$SUDO cp -r . "$install_dir/auto_rx/"
|
||||
$SUDO chmod +x "$install_dir/auto_rx/auto_rx.py"
|
||||
|
||||
ok "radiosonde_auto_rx installed to ${install_dir}"
|
||||
)
|
||||
}
|
||||
|
||||
install_macos_packages() {
|
||||
need_sudo
|
||||
|
||||
@@ -825,7 +873,7 @@ install_macos_packages() {
|
||||
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=21
|
||||
TOTAL_STEPS=22
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
@@ -912,6 +960,20 @@ install_macos_packages() {
|
||||
ok "SatDump already installed"
|
||||
fi
|
||||
|
||||
progress "Installing radiosonde_auto_rx (optional)"
|
||||
if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] \
|
||||
|| { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then
|
||||
echo
|
||||
info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking."
|
||||
if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then
|
||||
install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available."
|
||||
else
|
||||
warn "Skipping radiosonde_auto_rx. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "radiosonde_auto_rx already installed"
|
||||
fi
|
||||
|
||||
progress "Installing aircrack-ng"
|
||||
brew_install aircrack-ng
|
||||
|
||||
@@ -1303,7 +1365,7 @@ install_debian_packages() {
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=27
|
||||
TOTAL_STEPS=28
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
@@ -1485,6 +1547,20 @@ install_debian_packages() {
|
||||
ok "SatDump already installed"
|
||||
fi
|
||||
|
||||
progress "Installing radiosonde_auto_rx (optional)"
|
||||
if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] \
|
||||
|| { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then
|
||||
echo
|
||||
info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking."
|
||||
if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then
|
||||
install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available."
|
||||
else
|
||||
warn "Skipping radiosonde_auto_rx. You can install it later if needed."
|
||||
fi
|
||||
else
|
||||
ok "radiosonde_auto_rx already installed"
|
||||
fi
|
||||
|
||||
progress "Configuring udev rules"
|
||||
setup_udev_rules_debian
|
||||
|
||||
|
||||
152
static/css/modes/radiosonde.css
Normal file
152
static/css/modes/radiosonde.css
Normal file
@@ -0,0 +1,152 @@
|
||||
/* ============================================
|
||||
RADIOSONDE MODE — Scoped Styles
|
||||
============================================ */
|
||||
|
||||
/* Visuals container */
|
||||
.radiosonde-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Map container */
|
||||
#radiosondeMapContainer {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Card container below map */
|
||||
.radiosonde-card-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Individual balloon card */
|
||||
.radiosonde-card {
|
||||
background: var(--bg-card, #1a1e2e);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
flex: 1 1 280px;
|
||||
min-width: 260px;
|
||||
max-width: 400px;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.radiosonde-card:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background: rgba(0, 204, 255, 0.04);
|
||||
}
|
||||
|
||||
.radiosonde-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.radiosonde-serial {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.radiosonde-type {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Telemetry stat grid */
|
||||
.radiosonde-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.radiosonde-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.radiosonde-stat-value {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.radiosonde-stat-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Leaflet popup overrides for radiosonde */
|
||||
#radiosondeMapContainer .leaflet-popup-content-wrapper {
|
||||
background: var(--bg-card, #1a1e2e);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#radiosondeMapContainer .leaflet-popup-tip {
|
||||
background: var(--bg-card, #1a1e2e);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Scrollbar for card container */
|
||||
.radiosonde-card-container::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.radiosonde-card-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.radiosonde-card-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Responsive: stack cards on narrow screens */
|
||||
@media (max-width: 600px) {
|
||||
.radiosonde-card {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.radiosonde-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,45 @@
|
||||
/* System Health Mode Styles */
|
||||
/* System Health Mode Styles — Enhanced Dashboard */
|
||||
|
||||
.sys-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Group headers span full width */
|
||||
.sys-group-header {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||
padding-bottom: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sys-group-header:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.sys-card {
|
||||
background: var(--bg-card, #1a1a2e);
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.sys-card-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.sys-card-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sys-card-header {
|
||||
@@ -99,7 +124,285 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Process items */
|
||||
/* SVG Arc Gauge */
|
||||
.sys-gauge-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sys-gauge-arc {
|
||||
position: relative;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sys-gauge-arc svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sys-gauge-arc .arc-bg {
|
||||
fill: none;
|
||||
stroke: var(--bg-primary, #0d0d1a);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.sys-gauge-arc .arc-fill {
|
||||
fill: none;
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;
|
||||
filter: drop-shadow(0 0 4px currentColor);
|
||||
}
|
||||
|
||||
.sys-gauge-arc .arc-fill.ok { stroke: var(--accent-green, #00ff88); }
|
||||
.sys-gauge-arc .arc-fill.warn { stroke: var(--accent-yellow, #ffcc00); }
|
||||
.sys-gauge-arc .arc-fill.crit { stroke: var(--accent-red, #ff3366); }
|
||||
|
||||
.sys-gauge-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
color: var(--text-primary, #e0e0ff);
|
||||
}
|
||||
|
||||
.sys-gauge-details {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Per-core bars */
|
||||
.sys-core-bars {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: flex-end;
|
||||
height: 48px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sys-core-bar {
|
||||
flex: 1;
|
||||
background: var(--bg-primary, #0d0d1a);
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
min-width: 6px;
|
||||
max-width: 32px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sys-core-bar-fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 2px;
|
||||
transition: height 0.4s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
/* Temperature sparkline */
|
||||
.sys-sparkline-wrap {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.sys-sparkline-wrap svg {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.sys-sparkline-line {
|
||||
fill: none;
|
||||
stroke: var(--accent-cyan, #00d4ff);
|
||||
stroke-width: 1.5;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 212, 255, 0.4));
|
||||
}
|
||||
|
||||
.sys-sparkline-area {
|
||||
fill: url(#sparkGradient);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.sys-temp-big {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
color: var(--text-primary, #e0e0ff);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Network interface rows */
|
||||
.sys-net-iface {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-color, #2a2a4a);
|
||||
}
|
||||
|
||||
.sys-net-iface:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sys-net-iface-name {
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sys-net-iface-ip {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e0e0ff);
|
||||
}
|
||||
|
||||
.sys-net-iface-detail {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8888aa);
|
||||
}
|
||||
|
||||
/* Bandwidth arrows */
|
||||
.sys-bandwidth {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sys-bw-up {
|
||||
color: var(--accent-green, #00ff88);
|
||||
}
|
||||
|
||||
.sys-bw-down {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* Globe container — compact vertical layout */
|
||||
.sys-location-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sys-globe-wrap {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
flex-shrink: 0;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sys-location-details {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* GPS status indicator */
|
||||
.sys-gps-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8888aa);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.sys-gps-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sys-gps-dot.fix-3d {
|
||||
background: var(--accent-green, #00ff88);
|
||||
box-shadow: 0 0 4px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
.sys-gps-dot.fix-2d {
|
||||
background: var(--accent-yellow, #ffcc00);
|
||||
box-shadow: 0 0 4px rgba(255, 204, 0, 0.4);
|
||||
}
|
||||
|
||||
.sys-gps-dot.no-fix {
|
||||
background: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.sys-location-coords {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #e0e0ff);
|
||||
}
|
||||
|
||||
.sys-location-source {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8888aa);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Weather overlay */
|
||||
.sys-weather {
|
||||
margin-top: auto;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #2a2a4a);
|
||||
}
|
||||
|
||||
.sys-weather-temp {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
color: var(--text-primary, #e0e0ff);
|
||||
}
|
||||
|
||||
.sys-weather-condition {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim, #8888aa);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sys-weather-detail {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8888aa);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Disk I/O indicators */
|
||||
.sys-disk-io {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sys-disk-io-read {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.sys-disk-io-write {
|
||||
color: var(--accent-green, #00ff88);
|
||||
}
|
||||
|
||||
/* Process grid — dot-matrix style */
|
||||
.sys-process-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 12px;
|
||||
}
|
||||
|
||||
.sys-process-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -128,6 +431,12 @@
|
||||
background: var(--text-dim, #555);
|
||||
}
|
||||
|
||||
.sys-process-summary {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #8888aa);
|
||||
}
|
||||
|
||||
/* SDR Devices */
|
||||
.sys-sdr-device {
|
||||
padding: 6px 0;
|
||||
@@ -154,6 +463,39 @@
|
||||
background: var(--bg-primary, #0d0d1a);
|
||||
}
|
||||
|
||||
/* System info — vertical layout to fill card */
|
||||
.sys-info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim, #8888aa);
|
||||
}
|
||||
|
||||
.sys-info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sys-info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sys-info-item strong {
|
||||
color: var(--text-primary, #e0e0ff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Battery indicator */
|
||||
.sys-battery-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Sidebar Quick Grid */
|
||||
.sys-quick-grid {
|
||||
display: grid;
|
||||
@@ -206,10 +548,32 @@
|
||||
padding: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sys-card-wide,
|
||||
.sys-card-full {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.sys-globe-wrap {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.sys-process-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.sys-dashboard {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.sys-card-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.sys-card-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1909,7 +1909,42 @@ const BtLocate = (function() {
|
||||
handleDetection,
|
||||
invalidateMap,
|
||||
fetchPairedIrks,
|
||||
destroy,
|
||||
};
|
||||
|
||||
/**
|
||||
* Destroy — close SSE stream and clear all timers for clean mode switching.
|
||||
*/
|
||||
function destroy() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (durationTimer) {
|
||||
clearInterval(durationTimer);
|
||||
durationTimer = null;
|
||||
}
|
||||
if (mapStabilizeTimer) {
|
||||
clearInterval(mapStabilizeTimer);
|
||||
mapStabilizeTimer = null;
|
||||
}
|
||||
if (queuedDetectionTimer) {
|
||||
clearTimeout(queuedDetectionTimer);
|
||||
queuedDetectionTimer = null;
|
||||
}
|
||||
if (crosshairResetTimer) {
|
||||
clearTimeout(crosshairResetTimer);
|
||||
crosshairResetTimer = null;
|
||||
}
|
||||
if (beepTimer) {
|
||||
clearInterval(beepTimer);
|
||||
beepTimer = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
window.BtLocate = BtLocate;
|
||||
|
||||
@@ -117,13 +117,13 @@ const Meshtastic = (function() {
|
||||
Settings.createTileLayer().addTo(meshMap);
|
||||
Settings.registerMap(meshMap);
|
||||
} else {
|
||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(meshMap);
|
||||
}
|
||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
className: 'tile-layer-cyan'
|
||||
}).addTo(meshMap);
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
setTimeout(() => {
|
||||
@@ -401,10 +401,10 @@ const Meshtastic = (function() {
|
||||
|
||||
// Position is nested in the response
|
||||
const pos = info.position;
|
||||
if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
|
||||
if (posRow) posRow.style.display = 'flex';
|
||||
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
|
||||
} else {
|
||||
if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
|
||||
if (posRow) posRow.style.display = 'flex';
|
||||
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
|
||||
} else {
|
||||
if (posRow) posRow.style.display = 'none';
|
||||
}
|
||||
}
|
||||
@@ -2295,7 +2295,8 @@ const Meshtastic = (function() {
|
||||
// Store & Forward
|
||||
showStoreForwardModal,
|
||||
requestStoreForward,
|
||||
closeStoreForwardModal
|
||||
closeStoreForwardModal,
|
||||
destroy
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2306,6 +2307,13 @@ const Meshtastic = (function() {
|
||||
setTimeout(() => meshMap.invalidateSize(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy — tear down SSE, timers, and event listeners for clean mode switching.
|
||||
*/
|
||||
function destroy() {
|
||||
stopStream();
|
||||
}
|
||||
})();
|
||||
|
||||
// Initialize when DOM is ready (will be called by selectMode)
|
||||
|
||||
@@ -515,6 +515,13 @@ const SpyStations = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy — no-op placeholder for consistent lifecycle interface.
|
||||
*/
|
||||
function destroy() {
|
||||
// SpyStations has no background timers or streams to clean up.
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
@@ -524,7 +531,8 @@ const SpyStations = (function() {
|
||||
showDetails,
|
||||
closeDetails,
|
||||
showHelp,
|
||||
closeHelp
|
||||
closeHelp,
|
||||
destroy
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@@ -858,6 +858,13 @@ const SSTVGeneral = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy — close SSE stream and stop scope animation for clean mode switching.
|
||||
*/
|
||||
function destroy() {
|
||||
stopStream();
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
@@ -869,6 +876,7 @@ const SSTVGeneral = (function() {
|
||||
deleteImage,
|
||||
deleteAllImages,
|
||||
downloadImage,
|
||||
selectPreset
|
||||
selectPreset,
|
||||
destroy
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -12,12 +12,12 @@ const SSTV = (function() {
|
||||
let progress = 0;
|
||||
let issMap = null;
|
||||
let issMarker = null;
|
||||
let issTrackLine = null;
|
||||
let issPosition = null;
|
||||
let issUpdateInterval = null;
|
||||
let countdownInterval = null;
|
||||
let nextPassData = null;
|
||||
let pendingMapInvalidate = false;
|
||||
let issTrackLine = null;
|
||||
let issPosition = null;
|
||||
let issUpdateInterval = null;
|
||||
let countdownInterval = null;
|
||||
let nextPassData = null;
|
||||
let pendingMapInvalidate = false;
|
||||
|
||||
// ISS frequency
|
||||
const ISS_FREQ = 145.800;
|
||||
@@ -38,31 +38,31 @@ const SSTV = (function() {
|
||||
/**
|
||||
* Initialize the SSTV mode
|
||||
*/
|
||||
function init() {
|
||||
checkStatus();
|
||||
loadImages();
|
||||
loadLocationInputs();
|
||||
loadIssSchedule();
|
||||
initMap();
|
||||
startIssTracking();
|
||||
startCountdown();
|
||||
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
|
||||
setTimeout(() => invalidateMap(), 80);
|
||||
setTimeout(() => invalidateMap(), 260);
|
||||
}
|
||||
|
||||
function isMapContainerVisible() {
|
||||
if (!issMap || typeof issMap.getContainer !== 'function') return false;
|
||||
const container = issMap.getContainer();
|
||||
if (!container) return false;
|
||||
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
|
||||
if (container.style && container.style.display === 'none') return false;
|
||||
if (typeof window.getComputedStyle === 'function') {
|
||||
const style = window.getComputedStyle(container);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function init() {
|
||||
checkStatus();
|
||||
loadImages();
|
||||
loadLocationInputs();
|
||||
loadIssSchedule();
|
||||
initMap();
|
||||
startIssTracking();
|
||||
startCountdown();
|
||||
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
|
||||
setTimeout(() => invalidateMap(), 80);
|
||||
setTimeout(() => invalidateMap(), 260);
|
||||
}
|
||||
|
||||
function isMapContainerVisible() {
|
||||
if (!issMap || typeof issMap.getContainer !== 'function') return false;
|
||||
const container = issMap.getContainer();
|
||||
if (!container) return false;
|
||||
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
|
||||
if (container.style && container.style.display === 'none') return false;
|
||||
if (typeof window.getComputedStyle === 'function') {
|
||||
const style = window.getComputedStyle(container);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load location into input fields
|
||||
@@ -189,9 +189,9 @@ const SSTV = (function() {
|
||||
/**
|
||||
* Initialize Leaflet map for ISS tracking
|
||||
*/
|
||||
async function initMap() {
|
||||
const mapContainer = document.getElementById('sstvIssMap');
|
||||
if (!mapContainer || issMap) return;
|
||||
async function initMap() {
|
||||
const mapContainer = document.getElementById('sstvIssMap');
|
||||
if (!mapContainer || issMap) return;
|
||||
|
||||
// Create map
|
||||
issMap = L.map('sstvIssMap', {
|
||||
@@ -231,21 +231,21 @@ const SSTV = (function() {
|
||||
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
|
||||
|
||||
// Create ground track line
|
||||
issTrackLine = L.polyline([], {
|
||||
color: '#00d4ff',
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
dashArray: '5, 5'
|
||||
}).addTo(issMap);
|
||||
|
||||
issMap.on('resize moveend zoomend', () => {
|
||||
if (pendingMapInvalidate) invalidateMap();
|
||||
});
|
||||
|
||||
// Initial layout passes for first-time mode load.
|
||||
setTimeout(() => invalidateMap(), 40);
|
||||
setTimeout(() => invalidateMap(), 180);
|
||||
}
|
||||
issTrackLine = L.polyline([], {
|
||||
color: '#00d4ff',
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
dashArray: '5, 5'
|
||||
}).addTo(issMap);
|
||||
|
||||
issMap.on('resize moveend zoomend', () => {
|
||||
if (pendingMapInvalidate) invalidateMap();
|
||||
});
|
||||
|
||||
// Initial layout passes for first-time mode load.
|
||||
setTimeout(() => invalidateMap(), 40);
|
||||
setTimeout(() => invalidateMap(), 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ISS position tracking
|
||||
@@ -454,9 +454,9 @@ const SSTV = (function() {
|
||||
/**
|
||||
* Update map with ISS position
|
||||
*/
|
||||
function updateMap() {
|
||||
if (!issMap || !issPosition) return;
|
||||
if (pendingMapInvalidate) invalidateMap();
|
||||
function updateMap() {
|
||||
if (!issMap || !issPosition) return;
|
||||
if (pendingMapInvalidate) invalidateMap();
|
||||
|
||||
const lat = issPosition.lat;
|
||||
const lon = issPosition.lon;
|
||||
@@ -516,13 +516,13 @@ const SSTV = (function() {
|
||||
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
||||
}
|
||||
|
||||
// Pan map to follow ISS only when the map pane is currently renderable.
|
||||
if (isMapContainerVisible()) {
|
||||
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
|
||||
} else {
|
||||
pendingMapInvalidate = true;
|
||||
}
|
||||
}
|
||||
// Pan map to follow ISS only when the map pane is currently renderable.
|
||||
if (isMapContainerVisible()) {
|
||||
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
|
||||
} else {
|
||||
pendingMapInvalidate = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current decoder status
|
||||
@@ -1335,27 +1335,27 @@ const SSTV = (function() {
|
||||
/**
|
||||
* Show status message
|
||||
*/
|
||||
function showStatusMessage(message, type) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('SSTV', message);
|
||||
} else {
|
||||
console.log(`[SSTV ${type}] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate ISS map size after pane/layout changes.
|
||||
*/
|
||||
function invalidateMap() {
|
||||
if (!issMap) return false;
|
||||
if (!isMapContainerVisible()) {
|
||||
pendingMapInvalidate = true;
|
||||
return false;
|
||||
}
|
||||
issMap.invalidateSize({ pan: false, animate: false });
|
||||
pendingMapInvalidate = false;
|
||||
return true;
|
||||
}
|
||||
function showStatusMessage(message, type) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('SSTV', message);
|
||||
} else {
|
||||
console.log(`[SSTV ${type}] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate ISS map size after pane/layout changes.
|
||||
*/
|
||||
function invalidateMap() {
|
||||
if (!issMap) return false;
|
||||
if (!isMapContainerVisible()) {
|
||||
pendingMapInvalidate = true;
|
||||
return false;
|
||||
}
|
||||
issMap.invalidateSize({ pan: false, animate: false });
|
||||
pendingMapInvalidate = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
@@ -1370,12 +1370,25 @@ const SSTV = (function() {
|
||||
deleteAllImages,
|
||||
downloadImage,
|
||||
useGPS,
|
||||
updateTLE,
|
||||
stopIssTracking,
|
||||
stopCountdown,
|
||||
invalidateMap
|
||||
};
|
||||
})();
|
||||
updateTLE,
|
||||
stopIssTracking,
|
||||
stopCountdown,
|
||||
invalidateMap,
|
||||
destroy
|
||||
};
|
||||
|
||||
/**
|
||||
* Destroy — close SSE stream and clear ISS tracking/countdown timers for clean mode switching.
|
||||
*/
|
||||
function destroy() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
stopIssTracking();
|
||||
stopCountdown();
|
||||
}
|
||||
})();
|
||||
|
||||
// Initialize when DOM is ready (will be called by selectMode)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* System Health – IIFE module
|
||||
* System Health – Enhanced Dashboard IIFE module
|
||||
*
|
||||
* Always-on monitoring that auto-connects when the mode is entered.
|
||||
* Streams real-time system metrics via SSE and provides SDR device enumeration.
|
||||
* Streams real-time system metrics via SSE with rich visualizations:
|
||||
* SVG arc gauge, per-core bars, temperature sparkline, network bandwidth,
|
||||
* disk I/O, 3D globe, weather, and process grid.
|
||||
*/
|
||||
const SystemHealth = (function () {
|
||||
'use strict';
|
||||
@@ -11,19 +12,46 @@ const SystemHealth = (function () {
|
||||
let connected = false;
|
||||
let lastMetrics = null;
|
||||
|
||||
// Temperature sparkline ring buffer (last 20 readings)
|
||||
const SPARKLINE_SIZE = 20;
|
||||
let tempHistory = [];
|
||||
|
||||
// Network I/O delta tracking
|
||||
let prevNetIo = null;
|
||||
let prevNetTimestamp = null;
|
||||
|
||||
// Disk I/O delta tracking
|
||||
let prevDiskIo = null;
|
||||
let prevDiskTimestamp = null;
|
||||
|
||||
// Location & weather state
|
||||
let locationData = null;
|
||||
let weatherData = null;
|
||||
let weatherTimer = null;
|
||||
let globeInstance = null;
|
||||
let globeDestroyed = false;
|
||||
|
||||
const GLOBE_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js';
|
||||
const GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes == null) return '--';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let val = bytes;
|
||||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var i = 0;
|
||||
var val = bytes;
|
||||
while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
|
||||
return val.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function formatRate(bytesPerSec) {
|
||||
if (bytesPerSec == null) return '--';
|
||||
return formatBytes(bytesPerSec) + '/s';
|
||||
}
|
||||
|
||||
function barClass(pct) {
|
||||
if (pct >= 85) return 'crit';
|
||||
if (pct >= 60) return 'warn';
|
||||
@@ -32,8 +60,8 @@ const SystemHealth = (function () {
|
||||
|
||||
function barHtml(pct, label) {
|
||||
if (pct == null) return '<span class="sys-metric-na">N/A</span>';
|
||||
const cls = barClass(pct);
|
||||
const rounded = Math.round(pct);
|
||||
var cls = barClass(pct);
|
||||
var rounded = Math.round(pct);
|
||||
return '<div class="sys-metric-bar-wrap">' +
|
||||
(label ? '<span class="sys-metric-bar-label">' + label + '</span>' : '') +
|
||||
'<div class="sys-metric-bar"><div class="sys-metric-bar-fill ' + cls + '" style="width:' + rounded + '%"></div></div>' +
|
||||
@@ -41,71 +69,531 @@ const SystemHealth = (function () {
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// SVG Arc Gauge
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function arcGaugeSvg(pct) {
|
||||
var radius = 36;
|
||||
var cx = 45, cy = 45;
|
||||
var startAngle = -225;
|
||||
var endAngle = 45;
|
||||
var totalAngle = endAngle - startAngle; // 270 degrees
|
||||
var fillAngle = startAngle + (totalAngle * Math.min(pct, 100) / 100);
|
||||
|
||||
function polarToCart(angle) {
|
||||
var r = angle * Math.PI / 180;
|
||||
return { x: cx + radius * Math.cos(r), y: cy + radius * Math.sin(r) };
|
||||
}
|
||||
|
||||
var bgStart = polarToCart(startAngle);
|
||||
var bgEnd = polarToCart(endAngle);
|
||||
var fillEnd = polarToCart(fillAngle);
|
||||
var largeArcBg = totalAngle > 180 ? 1 : 0;
|
||||
var fillArc = (fillAngle - startAngle) > 180 ? 1 : 0;
|
||||
var cls = barClass(pct);
|
||||
|
||||
return '<svg viewBox="0 0 90 90">' +
|
||||
'<path class="arc-bg" d="M ' + bgStart.x + ' ' + bgStart.y +
|
||||
' A ' + radius + ' ' + radius + ' 0 ' + largeArcBg + ' 1 ' + bgEnd.x + ' ' + bgEnd.y + '"/>' +
|
||||
'<path class="arc-fill ' + cls + '" d="M ' + bgStart.x + ' ' + bgStart.y +
|
||||
' A ' + radius + ' ' + radius + ' 0 ' + fillArc + ' 1 ' + fillEnd.x + ' ' + fillEnd.y + '"/>' +
|
||||
'</svg>';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Temperature Sparkline
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function sparklineSvg(values) {
|
||||
if (!values || values.length < 2) return '';
|
||||
var w = 200, h = 40;
|
||||
var min = Math.min.apply(null, values);
|
||||
var max = Math.max.apply(null, values);
|
||||
var range = max - min || 1;
|
||||
var step = w / (values.length - 1);
|
||||
|
||||
var points = values.map(function (v, i) {
|
||||
var x = Math.round(i * step);
|
||||
var y = Math.round(h - ((v - min) / range) * (h - 4) - 2);
|
||||
return x + ',' + y;
|
||||
});
|
||||
|
||||
var areaPoints = points.join(' ') + ' ' + w + ',' + h + ' 0,' + h;
|
||||
|
||||
return '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none">' +
|
||||
'<defs><linearGradient id="sparkGradient" x1="0" y1="0" x2="0" y2="1">' +
|
||||
'<stop offset="0%" stop-color="var(--accent-cyan, #00d4ff)" stop-opacity="0.3"/>' +
|
||||
'<stop offset="100%" stop-color="var(--accent-cyan, #00d4ff)" stop-opacity="0.0"/>' +
|
||||
'</linearGradient></defs>' +
|
||||
'<polygon class="sys-sparkline-area" points="' + areaPoints + '"/>' +
|
||||
'<polyline class="sys-sparkline-line" points="' + points.join(' ') + '"/>' +
|
||||
'</svg>';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering — CPU Card
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderCpuCard(m) {
|
||||
const el = document.getElementById('sysCardCpu');
|
||||
var el = document.getElementById('sysCardCpu');
|
||||
if (!el) return;
|
||||
const cpu = m.cpu;
|
||||
var cpu = m.cpu;
|
||||
if (!cpu) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">psutil not available</span></div>'; return; }
|
||||
|
||||
var pct = Math.round(cpu.percent);
|
||||
var coreHtml = '';
|
||||
if (cpu.per_core && cpu.per_core.length) {
|
||||
coreHtml = '<div class="sys-core-bars">';
|
||||
cpu.per_core.forEach(function (c) {
|
||||
var cls = barClass(c);
|
||||
var h = Math.max(3, Math.round(c / 100 * 48));
|
||||
coreHtml += '<div class="sys-core-bar"><div class="sys-core-bar-fill ' + cls +
|
||||
'" style="height:' + h + 'px;background:var(--accent-' +
|
||||
(cls === 'ok' ? 'green' : cls === 'warn' ? 'yellow' : 'red') +
|
||||
', #00ff88)"></div></div>';
|
||||
});
|
||||
coreHtml += '</div>';
|
||||
}
|
||||
|
||||
var freqHtml = '';
|
||||
if (cpu.freq) {
|
||||
var freqGhz = (cpu.freq.current / 1000).toFixed(2);
|
||||
freqHtml = '<div class="sys-card-detail">Freq: ' + freqGhz + ' GHz</div>';
|
||||
}
|
||||
|
||||
el.innerHTML =
|
||||
'<div class="sys-card-header">CPU</div>' +
|
||||
'<div class="sys-card-body">' +
|
||||
barHtml(cpu.percent, '') +
|
||||
'<div class="sys-gauge-wrap">' +
|
||||
'<div class="sys-gauge-arc">' + arcGaugeSvg(pct) +
|
||||
'<div class="sys-gauge-label">' + pct + '%</div></div>' +
|
||||
'<div class="sys-gauge-details">' +
|
||||
'<div class="sys-card-detail">Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '</div>' +
|
||||
'<div class="sys-card-detail">Cores: ' + cpu.count + '</div>' +
|
||||
freqHtml +
|
||||
'</div></div>' +
|
||||
coreHtml +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Memory Card
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderMemoryCard(m) {
|
||||
const el = document.getElementById('sysCardMemory');
|
||||
var el = document.getElementById('sysCardMemory');
|
||||
if (!el) return;
|
||||
const mem = m.memory;
|
||||
var mem = m.memory;
|
||||
if (!mem) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
|
||||
const swap = m.swap || {};
|
||||
var swap = m.swap || {};
|
||||
el.innerHTML =
|
||||
'<div class="sys-card-header">Memory</div>' +
|
||||
'<div class="sys-card-body">' +
|
||||
barHtml(mem.percent, '') +
|
||||
barHtml(mem.percent, 'RAM') +
|
||||
'<div class="sys-card-detail">' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '</div>' +
|
||||
'<div class="sys-card-detail">Swap: ' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '</div>' +
|
||||
(swap.total > 0 ? barHtml(swap.percent, 'Swap') +
|
||||
'<div class="sys-card-detail">' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderDiskCard(m) {
|
||||
const el = document.getElementById('sysCardDisk');
|
||||
if (!el) return;
|
||||
const disk = m.disk;
|
||||
if (!disk) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
|
||||
el.innerHTML =
|
||||
'<div class="sys-card-header">Disk</div>' +
|
||||
'<div class="sys-card-body">' +
|
||||
barHtml(disk.percent, '') +
|
||||
'<div class="sys-card-detail">' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '</div>' +
|
||||
'<div class="sys-card-detail">Path: ' + (disk.path || '/') + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
// -----------------------------------------------------------------------
|
||||
// Temperature & Power Card
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function _extractPrimaryTemp(temps) {
|
||||
if (!temps) return null;
|
||||
// Prefer common chip names
|
||||
const preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal'];
|
||||
for (const name of preferred) {
|
||||
if (temps[name] && temps[name].length) return temps[name][0];
|
||||
var preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal'];
|
||||
for (var i = 0; i < preferred.length; i++) {
|
||||
if (temps[preferred[i]] && temps[preferred[i]].length) return temps[preferred[i]][0];
|
||||
}
|
||||
// Fall back to first available
|
||||
for (const key of Object.keys(temps)) {
|
||||
for (var key in temps) {
|
||||
if (temps[key] && temps[key].length) return temps[key][0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSdrCard(devices) {
|
||||
const el = document.getElementById('sysCardSdr');
|
||||
function renderTempCard(m) {
|
||||
var el = document.getElementById('sysCardTemp');
|
||||
if (!el) return;
|
||||
let html = '<div class="sys-card-header">SDR Devices <button class="sys-rescan-btn" onclick="SystemHealth.refreshSdr()">Rescan</button></div>';
|
||||
|
||||
var temp = _extractPrimaryTemp(m.temperatures);
|
||||
var html = '<div class="sys-card-header">Temperature & Power</div><div class="sys-card-body">';
|
||||
|
||||
if (temp) {
|
||||
// Update sparkline history
|
||||
tempHistory.push(temp.current);
|
||||
if (tempHistory.length > SPARKLINE_SIZE) tempHistory.shift();
|
||||
|
||||
html += '<div class="sys-temp-big">' + Math.round(temp.current) + '°C</div>';
|
||||
html += '<div class="sys-sparkline-wrap">' + sparklineSvg(tempHistory) + '</div>';
|
||||
|
||||
// Additional sensors
|
||||
if (m.temperatures) {
|
||||
for (var chip in m.temperatures) {
|
||||
m.temperatures[chip].forEach(function (s) {
|
||||
html += '<div class="sys-card-detail">' + escHtml(s.label) + ': ' + Math.round(s.current) + '°C</div>';
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html += '<span class="sys-metric-na">No temperature sensors</span>';
|
||||
}
|
||||
|
||||
// Fans
|
||||
if (m.fans) {
|
||||
for (var fChip in m.fans) {
|
||||
m.fans[fChip].forEach(function (f) {
|
||||
html += '<div class="sys-card-detail">Fan ' + escHtml(f.label) + ': ' + f.current + ' RPM</div>';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Battery
|
||||
if (m.battery) {
|
||||
html += '<div class="sys-card-detail" style="margin-top:8px">' +
|
||||
'Battery: ' + Math.round(m.battery.percent) + '%' +
|
||||
(m.battery.plugged ? ' (plugged)' : '') + '</div>';
|
||||
}
|
||||
|
||||
// Throttle flags (Pi)
|
||||
if (m.power && m.power.throttled) {
|
||||
html += '<div class="sys-card-detail" style="color:var(--accent-yellow,#ffcc00)">Throttle: 0x' + m.power.throttled + '</div>';
|
||||
}
|
||||
|
||||
// Power draw
|
||||
if (m.power && m.power.draw_watts != null) {
|
||||
html += '<div class="sys-card-detail">Power: ' + m.power.draw_watts + ' W</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Disk Card
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderDiskCard(m) {
|
||||
var el = document.getElementById('sysCardDisk');
|
||||
if (!el) return;
|
||||
var disk = m.disk;
|
||||
if (!disk) { el.innerHTML = '<div class="sys-card-header">Disk & Storage</div><div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
|
||||
|
||||
var html = '<div class="sys-card-header">Disk & Storage</div><div class="sys-card-body">';
|
||||
html += barHtml(disk.percent, '');
|
||||
html += '<div class="sys-card-detail">' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '</div>';
|
||||
|
||||
// Disk I/O rates
|
||||
if (m.disk_io && prevDiskIo && prevDiskTimestamp) {
|
||||
var dt = (m.timestamp - prevDiskTimestamp);
|
||||
if (dt > 0) {
|
||||
var readRate = (m.disk_io.read_bytes - prevDiskIo.read_bytes) / dt;
|
||||
var writeRate = (m.disk_io.write_bytes - prevDiskIo.write_bytes) / dt;
|
||||
var readIops = Math.round((m.disk_io.read_count - prevDiskIo.read_count) / dt);
|
||||
var writeIops = Math.round((m.disk_io.write_count - prevDiskIo.write_count) / dt);
|
||||
html += '<div class="sys-disk-io">' +
|
||||
'<span class="sys-disk-io-read">R: ' + formatRate(Math.max(0, readRate)) + '</span>' +
|
||||
'<span class="sys-disk-io-write">W: ' + formatRate(Math.max(0, writeRate)) + '</span>' +
|
||||
'</div>';
|
||||
html += '<div class="sys-card-detail">IOPS: ' + Math.max(0, readIops) + 'r / ' + Math.max(0, writeIops) + 'w</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (m.disk_io) {
|
||||
prevDiskIo = m.disk_io;
|
||||
prevDiskTimestamp = m.timestamp;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Network Card
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderNetworkCard(m) {
|
||||
var el = document.getElementById('sysCardNetwork');
|
||||
if (!el) return;
|
||||
var net = m.network;
|
||||
if (!net) { el.innerHTML = '<div class="sys-card-header">Network</div><div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
|
||||
|
||||
var html = '<div class="sys-card-header">Network</div><div class="sys-card-body">';
|
||||
|
||||
// Interfaces
|
||||
var ifaces = net.interfaces || [];
|
||||
if (ifaces.length === 0) {
|
||||
html += '<span class="sys-metric-na">No interfaces</span>';
|
||||
} else {
|
||||
ifaces.forEach(function (iface) {
|
||||
html += '<div class="sys-net-iface">';
|
||||
html += '<div class="sys-net-iface-name">' + escHtml(iface.name) +
|
||||
(iface.is_up ? '' : ' <span style="color:var(--text-dim)">(down)</span>') + '</div>';
|
||||
if (iface.ipv4) html += '<div class="sys-net-iface-ip">' + escHtml(iface.ipv4) + '</div>';
|
||||
var details = [];
|
||||
if (iface.mac) details.push('MAC: ' + iface.mac);
|
||||
if (iface.speed) details.push(iface.speed + ' Mbps');
|
||||
if (details.length) html += '<div class="sys-net-iface-detail">' + escHtml(details.join(' | ')) + '</div>';
|
||||
|
||||
// Bandwidth for this interface
|
||||
if (net.io && net.io[iface.name] && prevNetIo && prevNetIo[iface.name] && prevNetTimestamp) {
|
||||
var dt = (m.timestamp - prevNetTimestamp);
|
||||
if (dt > 0) {
|
||||
var prev = prevNetIo[iface.name];
|
||||
var cur = net.io[iface.name];
|
||||
var upRate = (cur.bytes_sent - prev.bytes_sent) / dt;
|
||||
var downRate = (cur.bytes_recv - prev.bytes_recv) / dt;
|
||||
html += '<div class="sys-bandwidth">' +
|
||||
'<span class="sys-bw-up">↑ ' + formatRate(Math.max(0, upRate)) + '</span>' +
|
||||
'<span class="sys-bw-down">↓ ' + formatRate(Math.max(0, downRate)) + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Connection count
|
||||
if (net.connections != null) {
|
||||
html += '<div class="sys-card-detail" style="margin-top:8px">Connections: ' + net.connections + '</div>';
|
||||
}
|
||||
|
||||
// Save for next delta
|
||||
if (net.io) {
|
||||
prevNetIo = net.io;
|
||||
prevNetTimestamp = m.timestamp;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Location & Weather Card
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderLocationCard() {
|
||||
var el = document.getElementById('sysCardLocation');
|
||||
if (!el) return;
|
||||
|
||||
// Preserve the globe DOM node if it already has a canvas
|
||||
var existingGlobe = document.getElementById('sysGlobeContainer');
|
||||
var savedGlobe = null;
|
||||
if (existingGlobe && existingGlobe.querySelector('canvas')) {
|
||||
savedGlobe = existingGlobe;
|
||||
existingGlobe.parentNode.removeChild(existingGlobe);
|
||||
}
|
||||
|
||||
var html = '<div class="sys-card-header">Location & Weather</div><div class="sys-card-body">';
|
||||
html += '<div class="sys-location-inner">';
|
||||
|
||||
// Globe placeholder (will be replaced with saved node or initialized fresh)
|
||||
if (!savedGlobe) {
|
||||
html += '<div class="sys-globe-wrap" id="sysGlobeContainer"></div>';
|
||||
} else {
|
||||
html += '<div id="sysGlobePlaceholder"></div>';
|
||||
}
|
||||
|
||||
// Details below globe
|
||||
html += '<div class="sys-location-details">';
|
||||
|
||||
if (locationData && locationData.lat != null) {
|
||||
html += '<div class="sys-location-coords">' +
|
||||
locationData.lat.toFixed(4) + '°' + (locationData.lat >= 0 ? 'N' : 'S') + ', ' +
|
||||
locationData.lon.toFixed(4) + '°' + (locationData.lon >= 0 ? 'E' : 'W') + '</div>';
|
||||
|
||||
// GPS status indicator
|
||||
if (locationData.source === 'gps' && locationData.gps) {
|
||||
var gps = locationData.gps;
|
||||
var fixLabel = gps.fix_quality === 3 ? '3D Fix' : '2D Fix';
|
||||
var dotCls = gps.fix_quality === 3 ? 'fix-3d' : 'fix-2d';
|
||||
html += '<div class="sys-gps-status">' +
|
||||
'<span class="sys-gps-dot ' + dotCls + '"></span> ' + fixLabel;
|
||||
if (gps.satellites != null) html += ' · ' + gps.satellites + ' sats';
|
||||
if (gps.accuracy != null) html += ' · ±' + gps.accuracy + 'm';
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="sys-location-source">Source: ' + escHtml(locationData.source || 'unknown') + '</div>';
|
||||
}
|
||||
} else {
|
||||
html += '<div class="sys-location-coords" style="color:var(--text-dim)">No location</div>';
|
||||
}
|
||||
|
||||
// Weather
|
||||
if (weatherData && !weatherData.error) {
|
||||
html += '<div class="sys-weather">';
|
||||
html += '<div class="sys-weather-temp">' + (weatherData.temp_c || '--') + '°C</div>';
|
||||
html += '<div class="sys-weather-condition">' + escHtml(weatherData.condition || '') + '</div>';
|
||||
var details = [];
|
||||
if (weatherData.humidity) details.push('Humidity: ' + weatherData.humidity + '%');
|
||||
if (weatherData.wind_mph) details.push('Wind: ' + weatherData.wind_mph + ' mph ' + (weatherData.wind_dir || ''));
|
||||
if (weatherData.feels_like_c) details.push('Feels like: ' + weatherData.feels_like_c + '°C');
|
||||
details.forEach(function (d) {
|
||||
html += '<div class="sys-weather-detail">' + escHtml(d) + '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
} else if (weatherData && weatherData.error) {
|
||||
html += '<div class="sys-weather"><div class="sys-weather-condition" style="color:var(--text-dim)">Weather unavailable</div></div>';
|
||||
}
|
||||
|
||||
html += '</div>'; // .sys-location-details
|
||||
html += '</div>'; // .sys-location-inner
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
|
||||
// Re-insert saved globe or initialize fresh
|
||||
if (savedGlobe) {
|
||||
var placeholder = document.getElementById('sysGlobePlaceholder');
|
||||
if (placeholder) placeholder.parentNode.replaceChild(savedGlobe, placeholder);
|
||||
} else {
|
||||
requestAnimationFrame(function () { initGlobe(); });
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Globe (reuses globe.gl from GPS mode)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function ensureGlobeLibrary() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (typeof window.Globe === 'function') { resolve(true); return; }
|
||||
|
||||
// Check if script already exists
|
||||
var existing = document.querySelector(
|
||||
'script[data-intercept-globe-src="' + GLOBE_SCRIPT_URL + '"], ' +
|
||||
'script[src="' + GLOBE_SCRIPT_URL + '"]'
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.dataset.loaded === 'true') { resolve(true); return; }
|
||||
if (existing.dataset.failed === 'true') { resolve(false); return; }
|
||||
existing.addEventListener('load', function () { resolve(true); }, { once: true });
|
||||
existing.addEventListener('error', function () { resolve(false); }, { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
var script = document.createElement('script');
|
||||
script.src = GLOBE_SCRIPT_URL;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.dataset.interceptGlobeSrc = GLOBE_SCRIPT_URL;
|
||||
script.onload = function () { script.dataset.loaded = 'true'; resolve(true); };
|
||||
script.onerror = function () { script.dataset.failed = 'true'; resolve(false); };
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function initGlobe() {
|
||||
var container = document.getElementById('sysGlobeContainer');
|
||||
if (!container || globeDestroyed) return;
|
||||
|
||||
// Don't reinitialize if globe canvas is still alive in this container
|
||||
if (globeInstance && container.querySelector('canvas')) return;
|
||||
|
||||
// Clear stale reference if canvas was destroyed by innerHTML replacement
|
||||
if (globeInstance && !container.querySelector('canvas')) {
|
||||
globeInstance = null;
|
||||
}
|
||||
|
||||
ensureGlobeLibrary().then(function (ready) {
|
||||
if (!ready || typeof window.Globe !== 'function' || globeDestroyed) return;
|
||||
|
||||
// Wait for layout — container may have 0 dimensions right after
|
||||
// display:none is removed by switchMode(). Use RAF retry like GPS mode.
|
||||
var attempts = 0;
|
||||
function tryInit() {
|
||||
if (globeDestroyed) return;
|
||||
container = document.getElementById('sysGlobeContainer');
|
||||
if (!container) return;
|
||||
|
||||
if ((!container.clientWidth || !container.clientHeight) && attempts < 8) {
|
||||
attempts++;
|
||||
requestAnimationFrame(tryInit);
|
||||
return;
|
||||
}
|
||||
if (!container.clientWidth || !container.clientHeight) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.style.background = 'radial-gradient(circle, rgba(10,20,40,0.9), rgba(2,4,8,0.98) 70%)';
|
||||
|
||||
try {
|
||||
globeInstance = window.Globe()(container)
|
||||
.backgroundColor('rgba(0,0,0,0)')
|
||||
.globeImageUrl(GLOBE_TEXTURE_URL)
|
||||
.showAtmosphere(true)
|
||||
.atmosphereColor('#3bb9ff')
|
||||
.atmosphereAltitude(0.12)
|
||||
.pointsData([])
|
||||
.pointRadius(0.8)
|
||||
.pointAltitude(0.01)
|
||||
.pointColor(function () { return '#00d4ff'; });
|
||||
|
||||
var controls = globeInstance.controls();
|
||||
if (controls) {
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 0.5;
|
||||
controls.enablePan = false;
|
||||
controls.minDistance = 120;
|
||||
controls.maxDistance = 300;
|
||||
}
|
||||
|
||||
// Size the globe
|
||||
globeInstance.width(container.clientWidth);
|
||||
globeInstance.height(container.clientHeight);
|
||||
|
||||
updateGlobePosition();
|
||||
} catch (e) {
|
||||
// Globe.gl / WebGL init failed — show static fallback
|
||||
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--text-dim);font-size:11px;">Globe unavailable</div>';
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tryInit);
|
||||
});
|
||||
}
|
||||
|
||||
function updateGlobePosition() {
|
||||
if (!globeInstance || !locationData || locationData.lat == null) return;
|
||||
|
||||
// Observer point
|
||||
globeInstance.pointsData([{
|
||||
lat: locationData.lat,
|
||||
lng: locationData.lon,
|
||||
size: 0.8,
|
||||
color: '#00d4ff',
|
||||
}]);
|
||||
|
||||
// Snap view
|
||||
globeInstance.pointOfView({ lat: locationData.lat, lng: locationData.lon, altitude: 2.0 }, 1000);
|
||||
|
||||
// Stop auto-rotate when we have a fix
|
||||
var controls = globeInstance.controls();
|
||||
if (controls) controls.autoRotate = false;
|
||||
}
|
||||
|
||||
function destroyGlobe() {
|
||||
globeDestroyed = true;
|
||||
if (globeInstance) {
|
||||
var container = document.getElementById('sysGlobeContainer');
|
||||
if (container) container.innerHTML = '';
|
||||
globeInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SDR Card
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderSdrCard(devices) {
|
||||
var el = document.getElementById('sysCardSdr');
|
||||
if (!el) return;
|
||||
var html = '<div class="sys-card-header">SDR Devices <button class="sys-rescan-btn" onclick="SystemHealth.refreshSdr()">Rescan</button></div>';
|
||||
html += '<div class="sys-card-body">';
|
||||
if (!devices || !devices.length) {
|
||||
html += '<span class="sys-metric-na">No devices found</span>';
|
||||
@@ -113,9 +601,9 @@ const SystemHealth = (function () {
|
||||
devices.forEach(function (d) {
|
||||
html += '<div class="sys-sdr-device">' +
|
||||
'<span class="sys-process-dot running"></span> ' +
|
||||
'<strong>' + d.type + ' #' + d.index + '</strong>' +
|
||||
'<div class="sys-card-detail">' + (d.name || 'Unknown') + '</div>' +
|
||||
(d.serial ? '<div class="sys-card-detail">S/N: ' + d.serial + '</div>' : '') +
|
||||
'<strong>' + escHtml(d.type) + ' #' + d.index + '</strong>' +
|
||||
'<div class="sys-card-detail">' + escHtml(d.name || 'Unknown') + '</div>' +
|
||||
(d.serial ? '<div class="sys-card-detail">S/N: ' + escHtml(d.serial) + '</div>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
@@ -123,93 +611,197 @@ const SystemHealth = (function () {
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Process Card
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderProcessCard(m) {
|
||||
const el = document.getElementById('sysCardProcesses');
|
||||
var el = document.getElementById('sysCardProcesses');
|
||||
if (!el) return;
|
||||
const procs = m.processes || {};
|
||||
const keys = Object.keys(procs).sort();
|
||||
let html = '<div class="sys-card-header">Processes</div><div class="sys-card-body">';
|
||||
var procs = m.processes || {};
|
||||
var keys = Object.keys(procs).sort();
|
||||
var html = '<div class="sys-card-header">Active Processes</div><div class="sys-card-body">';
|
||||
if (!keys.length) {
|
||||
html += '<span class="sys-metric-na">No data</span>';
|
||||
} else {
|
||||
var running = 0, stopped = 0;
|
||||
html += '<div class="sys-process-grid">';
|
||||
keys.forEach(function (k) {
|
||||
const running = procs[k];
|
||||
const dotCls = running ? 'running' : 'stopped';
|
||||
const label = k.charAt(0).toUpperCase() + k.slice(1);
|
||||
var isRunning = procs[k];
|
||||
if (isRunning) running++; else stopped++;
|
||||
var dotCls = isRunning ? 'running' : 'stopped';
|
||||
var label = k.charAt(0).toUpperCase() + k.slice(1);
|
||||
html += '<div class="sys-process-item">' +
|
||||
'<span class="sys-process-dot ' + dotCls + '"></span> ' +
|
||||
'<span class="sys-process-name">' + label + '</span>' +
|
||||
'<span class="sys-process-name">' + escHtml(label) + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '<div class="sys-process-summary">' + running + ' running / ' + stopped + ' idle</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// System Info Card
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderSystemInfoCard(m) {
|
||||
const el = document.getElementById('sysCardInfo');
|
||||
var el = document.getElementById('sysCardInfo');
|
||||
if (!el) return;
|
||||
const sys = m.system || {};
|
||||
const temp = _extractPrimaryTemp(m.temperatures);
|
||||
let html = '<div class="sys-card-header">System Info</div><div class="sys-card-body">';
|
||||
html += '<div class="sys-card-detail">Host: ' + (sys.hostname || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">OS: ' + (sys.platform || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">Python: ' + (sys.python || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">App: v' + (sys.version || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">Uptime: ' + (sys.uptime_human || '--') + '</div>';
|
||||
if (temp) {
|
||||
html += '<div class="sys-card-detail">Temp: ' + Math.round(temp.current) + '°C';
|
||||
if (temp.high) html += ' / ' + Math.round(temp.high) + '°C max';
|
||||
html += '</div>';
|
||||
var sys = m.system || {};
|
||||
var html = '<div class="sys-card-header">System Info</div><div class="sys-card-body"><div class="sys-info-grid">';
|
||||
|
||||
html += '<div class="sys-info-item"><strong>Host</strong><span>' + escHtml(sys.hostname || '--') + '</span></div>';
|
||||
html += '<div class="sys-info-item"><strong>OS</strong><span>' + escHtml((sys.platform || '--').replace(/-with-glibc[\d.]+/, '')) + '</span></div>';
|
||||
html += '<div class="sys-info-item"><strong>Python</strong><span>' + escHtml(sys.python || '--') + '</span></div>';
|
||||
html += '<div class="sys-info-item"><strong>App</strong><span>v' + escHtml(sys.version || '--') + '</span></div>';
|
||||
html += '<div class="sys-info-item"><strong>Uptime</strong><span>' + escHtml(sys.uptime_human || '--') + '</span></div>';
|
||||
|
||||
if (m.boot_time) {
|
||||
var bootDate = new Date(m.boot_time * 1000);
|
||||
html += '<div class="sys-info-item"><strong>Boot</strong><span>' + escHtml(bootDate.toLocaleString()) + '</span></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
if (m.network && m.network.connections != null) {
|
||||
html += '<div class="sys-info-item"><strong>Connections</strong><span>' + m.network.connections + '</span></div>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sidebar Updates
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function updateSidebarQuickStats(m) {
|
||||
const cpuEl = document.getElementById('sysQuickCpu');
|
||||
const tempEl = document.getElementById('sysQuickTemp');
|
||||
const ramEl = document.getElementById('sysQuickRam');
|
||||
const diskEl = document.getElementById('sysQuickDisk');
|
||||
var cpuEl = document.getElementById('sysQuickCpu');
|
||||
var tempEl = document.getElementById('sysQuickTemp');
|
||||
var ramEl = document.getElementById('sysQuickRam');
|
||||
var diskEl = document.getElementById('sysQuickDisk');
|
||||
|
||||
if (cpuEl) cpuEl.textContent = m.cpu ? Math.round(m.cpu.percent) + '%' : '--';
|
||||
if (ramEl) ramEl.textContent = m.memory ? Math.round(m.memory.percent) + '%' : '--';
|
||||
if (diskEl) diskEl.textContent = m.disk ? Math.round(m.disk.percent) + '%' : '--';
|
||||
|
||||
const temp = _extractPrimaryTemp(m.temperatures);
|
||||
var temp = _extractPrimaryTemp(m.temperatures);
|
||||
if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '°C' : '--';
|
||||
|
||||
// Color-code values
|
||||
[cpuEl, ramEl, diskEl].forEach(function (el) {
|
||||
if (!el) return;
|
||||
const val = parseInt(el.textContent);
|
||||
var val = parseInt(el.textContent);
|
||||
el.classList.remove('sys-val-ok', 'sys-val-warn', 'sys-val-crit');
|
||||
if (!isNaN(val)) el.classList.add('sys-val-' + barClass(val));
|
||||
});
|
||||
}
|
||||
|
||||
function updateSidebarProcesses(m) {
|
||||
const el = document.getElementById('sysProcessList');
|
||||
var el = document.getElementById('sysProcessList');
|
||||
if (!el) return;
|
||||
const procs = m.processes || {};
|
||||
const keys = Object.keys(procs).sort();
|
||||
var procs = m.processes || {};
|
||||
var keys = Object.keys(procs).sort();
|
||||
if (!keys.length) { el.textContent = 'No data'; return; }
|
||||
const running = keys.filter(function (k) { return procs[k]; });
|
||||
const stopped = keys.filter(function (k) { return !procs[k]; });
|
||||
var running = keys.filter(function (k) { return procs[k]; });
|
||||
var stopped = keys.filter(function (k) { return !procs[k]; });
|
||||
el.innerHTML =
|
||||
(running.length ? '<span style="color: var(--accent-green, #00ff88);">' + running.length + ' running</span>' : '') +
|
||||
(running.length && stopped.length ? ' · ' : '') +
|
||||
(stopped.length ? '<span style="color: var(--text-dim);">' + stopped.length + ' stopped</span>' : '');
|
||||
}
|
||||
|
||||
function updateSidebarNetwork(m) {
|
||||
var el = document.getElementById('sysQuickNet');
|
||||
if (!el || !m.network) return;
|
||||
var ifaces = m.network.interfaces || [];
|
||||
var ips = [];
|
||||
ifaces.forEach(function (iface) {
|
||||
if (iface.ipv4 && iface.is_up) {
|
||||
ips.push(iface.name + ': ' + iface.ipv4);
|
||||
}
|
||||
});
|
||||
el.textContent = ips.length ? ips.join(', ') : '--';
|
||||
}
|
||||
|
||||
function updateSidebarBattery(m) {
|
||||
var section = document.getElementById('sysQuickBatterySection');
|
||||
var el = document.getElementById('sysQuickBattery');
|
||||
if (!section || !el) return;
|
||||
if (m.battery) {
|
||||
section.style.display = '';
|
||||
el.textContent = Math.round(m.battery.percent) + '%' + (m.battery.plugged ? ' (plugged)' : '');
|
||||
} else {
|
||||
section.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateSidebarLocation() {
|
||||
var el = document.getElementById('sysQuickLocation');
|
||||
if (!el) return;
|
||||
if (locationData && locationData.lat != null) {
|
||||
el.textContent = locationData.lat.toFixed(4) + ', ' + locationData.lon.toFixed(4) + ' (' + locationData.source + ')';
|
||||
} else {
|
||||
el.textContent = 'No location';
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render all
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderAll(m) {
|
||||
renderCpuCard(m);
|
||||
renderMemoryCard(m);
|
||||
renderTempCard(m);
|
||||
renderDiskCard(m);
|
||||
renderNetworkCard(m);
|
||||
renderProcessCard(m);
|
||||
renderSystemInfoCard(m);
|
||||
updateSidebarQuickStats(m);
|
||||
updateSidebarProcesses(m);
|
||||
updateSidebarNetwork(m);
|
||||
updateSidebarBattery(m);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Location & Weather Fetching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function fetchLocation() {
|
||||
fetch('/system/location')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
// If server only has default/none, check client-side saved location
|
||||
if ((data.source === 'default' || data.source === 'none') &&
|
||||
window.ObserverLocation && ObserverLocation.getShared) {
|
||||
var shared = ObserverLocation.getShared();
|
||||
if (shared && shared.lat && shared.lon) {
|
||||
data.lat = shared.lat;
|
||||
data.lon = shared.lon;
|
||||
data.source = 'manual';
|
||||
}
|
||||
}
|
||||
locationData = data;
|
||||
updateSidebarLocation();
|
||||
renderLocationCard();
|
||||
if (data.lat != null) fetchWeather();
|
||||
})
|
||||
.catch(function () {
|
||||
renderLocationCard();
|
||||
});
|
||||
}
|
||||
|
||||
function fetchWeather() {
|
||||
if (!locationData || locationData.lat == null) return;
|
||||
fetch('/system/weather?lat=' + locationData.lat + '&lon=' + locationData.lon)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
weatherData = data;
|
||||
renderLocationCard();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -267,7 +859,7 @@ const SystemHealth = (function () {
|
||||
var html = '';
|
||||
devices.forEach(function (d) {
|
||||
html += '<div style="margin-bottom: 4px;"><span class="sys-process-dot running"></span> ' +
|
||||
d.type + ' #' + d.index + ' — ' + (d.name || 'Unknown') + '</div>';
|
||||
escHtml(d.type) + ' #' + d.index + ' — ' + escHtml(d.name || 'Unknown') + '</div>';
|
||||
});
|
||||
sidebarEl.innerHTML = html;
|
||||
}
|
||||
@@ -284,12 +876,24 @@ const SystemHealth = (function () {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function init() {
|
||||
globeDestroyed = false;
|
||||
connect();
|
||||
refreshSdr();
|
||||
fetchLocation();
|
||||
|
||||
// Refresh weather every 10 minutes
|
||||
weatherTimer = setInterval(function () {
|
||||
fetchWeather();
|
||||
}, 600000);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
disconnect();
|
||||
destroyGlobe();
|
||||
if (weatherTimer) {
|
||||
clearInterval(weatherTimer);
|
||||
weatherTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1005,6 +1005,15 @@ function escapeHtmlWebsdr(str) {
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
/**
|
||||
* Destroy — disconnect audio and clear S-meter timer for clean mode switching.
|
||||
*/
|
||||
function destroyWebSDR() {
|
||||
disconnectFromReceiver();
|
||||
}
|
||||
|
||||
const WebSDR = { destroy: destroyWebSDR };
|
||||
|
||||
window.initWebSDR = initWebSDR;
|
||||
window.searchReceivers = searchReceivers;
|
||||
window.selectReceiver = selectReceiver;
|
||||
@@ -1015,3 +1024,4 @@ window.disconnectFromReceiver = disconnectFromReceiver;
|
||||
window.tuneKiwi = tuneKiwi;
|
||||
window.tuneFromBar = tuneFromBar;
|
||||
window.setKiwiVolume = setKiwiVolume;
|
||||
window.WebSDR = WebSDR;
|
||||
|
||||
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
|
||||
maxProbes: 1000,
|
||||
};
|
||||
|
||||
// ==========================================================================
|
||||
// Agent Support
|
||||
// ==========================================================================
|
||||
// ==========================================================================
|
||||
// Agent Support
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get the API base URL, routing through agent proxy if agent is selected.
|
||||
@@ -59,49 +59,49 @@ const WiFiMode = (function() {
|
||||
/**
|
||||
* Check for agent mode conflicts before starting WiFi scan.
|
||||
*/
|
||||
function checkAgentConflicts() {
|
||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||
return true;
|
||||
}
|
||||
if (typeof checkAgentModeConflict === 'function') {
|
||||
return checkAgentModeConflict('wifi');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getChannelPresetList(preset) {
|
||||
switch (preset) {
|
||||
case '2.4-common':
|
||||
return '1,6,11';
|
||||
case '2.4-all':
|
||||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||||
case '5-low':
|
||||
return '36,40,44,48';
|
||||
case '5-mid':
|
||||
return '52,56,60,64';
|
||||
case '5-high':
|
||||
return '149,153,157,161,165';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildChannelConfig() {
|
||||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||||
|
||||
const listValue = listInput.trim();
|
||||
const presetValue = getChannelPresetList(preset);
|
||||
|
||||
const channels = listValue || presetValue || '';
|
||||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||||
|
||||
return {
|
||||
channels: channels || null,
|
||||
channel: Number.isFinite(channel) ? channel : null,
|
||||
};
|
||||
}
|
||||
function checkAgentConflicts() {
|
||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||
return true;
|
||||
}
|
||||
if (typeof checkAgentModeConflict === 'function') {
|
||||
return checkAgentModeConflict('wifi');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getChannelPresetList(preset) {
|
||||
switch (preset) {
|
||||
case '2.4-common':
|
||||
return '1,6,11';
|
||||
case '2.4-all':
|
||||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||||
case '5-low':
|
||||
return '36,40,44,48';
|
||||
case '5-mid':
|
||||
return '52,56,60,64';
|
||||
case '5-high':
|
||||
return '149,153,157,161,165';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildChannelConfig() {
|
||||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||||
|
||||
const listValue = listInput.trim();
|
||||
const presetValue = getChannelPresetList(preset);
|
||||
|
||||
const channels = listValue || presetValue || '';
|
||||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||||
|
||||
return {
|
||||
channels: channels || null,
|
||||
channel: Number.isFinite(channel) ? channel : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// State
|
||||
@@ -120,23 +120,23 @@ const WiFiMode = (function() {
|
||||
let channelStats = [];
|
||||
let recommendations = [];
|
||||
|
||||
// UI state
|
||||
let selectedNetwork = null;
|
||||
let currentFilter = 'all';
|
||||
let currentSort = { field: 'rssi', order: 'desc' };
|
||||
let renderFramePending = false;
|
||||
const pendingRender = {
|
||||
table: false,
|
||||
stats: false,
|
||||
radar: false,
|
||||
chart: false,
|
||||
detail: false,
|
||||
};
|
||||
const listenersBound = {
|
||||
scanTabs: false,
|
||||
filters: false,
|
||||
sort: false,
|
||||
};
|
||||
// UI state
|
||||
let selectedNetwork = null;
|
||||
let currentFilter = 'all';
|
||||
let currentSort = { field: 'rssi', order: 'desc' };
|
||||
let renderFramePending = false;
|
||||
const pendingRender = {
|
||||
table: false,
|
||||
stats: false,
|
||||
radar: false,
|
||||
chart: false,
|
||||
detail: false,
|
||||
};
|
||||
const listenersBound = {
|
||||
scanTabs: false,
|
||||
filters: false,
|
||||
sort: false,
|
||||
};
|
||||
|
||||
// Agent state
|
||||
let showAllAgentsMode = false; // Show combined results from all agents
|
||||
@@ -165,11 +165,11 @@ const WiFiMode = (function() {
|
||||
|
||||
// Initialize components
|
||||
initScanModeTabs();
|
||||
initNetworkFilters();
|
||||
initSortControls();
|
||||
initProximityRadar();
|
||||
initChannelChart();
|
||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||
initNetworkFilters();
|
||||
initSortControls();
|
||||
initProximityRadar();
|
||||
initChannelChart();
|
||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||
|
||||
// Check if already scanning
|
||||
checkScanStatus();
|
||||
@@ -378,16 +378,16 @@ const WiFiMode = (function() {
|
||||
// Scan Mode Tabs
|
||||
// ==========================================================================
|
||||
|
||||
function initScanModeTabs() {
|
||||
if (listenersBound.scanTabs) return;
|
||||
if (elements.scanModeQuick) {
|
||||
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
|
||||
}
|
||||
if (elements.scanModeDeep) {
|
||||
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
|
||||
}
|
||||
listenersBound.scanTabs = true;
|
||||
}
|
||||
function initScanModeTabs() {
|
||||
if (listenersBound.scanTabs) return;
|
||||
if (elements.scanModeQuick) {
|
||||
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
|
||||
}
|
||||
if (elements.scanModeDeep) {
|
||||
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
|
||||
}
|
||||
listenersBound.scanTabs = true;
|
||||
}
|
||||
|
||||
function setScanMode(mode) {
|
||||
scanMode = mode;
|
||||
@@ -511,10 +511,10 @@ const WiFiMode = (function() {
|
||||
setScanning(true, 'deep');
|
||||
|
||||
try {
|
||||
const iface = elements.interfaceSelect?.value || null;
|
||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||
const channelConfig = buildChannelConfig();
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
const iface = elements.interfaceSelect?.value || null;
|
||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||
const channelConfig = buildChannelConfig();
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
|
||||
let response;
|
||||
if (isAgentMode) {
|
||||
@@ -523,25 +523,25 @@ const WiFiMode = (function() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
scan_type: 'deep',
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
}
|
||||
interface: iface,
|
||||
scan_type: 'deep',
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interface: iface,
|
||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||
channel: channelConfig.channel,
|
||||
channels: channelConfig.channels,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -572,8 +572,8 @@ const WiFiMode = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopScan() {
|
||||
console.log('[WiFiMode] Stopping scan...');
|
||||
async function stopScan() {
|
||||
console.log('[WiFiMode] Stopping scan...');
|
||||
|
||||
// Stop polling
|
||||
if (pollTimer) {
|
||||
@@ -585,41 +585,41 @@ const WiFiMode = (function() {
|
||||
stopAgentDeepScanPolling();
|
||||
|
||||
// Close event stream
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
// Update UI immediately so mode transitions are responsive even if the
|
||||
// backend needs extra time to terminate subprocesses.
|
||||
setScanning(false);
|
||||
|
||||
// Stop scan on server (local or agent)
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
const timeoutMs = isAgentMode ? 8000 : 2200;
|
||||
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||
|
||||
try {
|
||||
if (isAgentMode) {
|
||||
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
|
||||
method: 'POST',
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
});
|
||||
} else if (scanMode === 'deep') {
|
||||
await fetch(`${CONFIG.apiBase}/scan/stop`, {
|
||||
method: 'POST',
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[WiFiMode] Error stopping scan:', error);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
// Update UI immediately so mode transitions are responsive even if the
|
||||
// backend needs extra time to terminate subprocesses.
|
||||
setScanning(false);
|
||||
|
||||
// Stop scan on server (local or agent)
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
const timeoutMs = isAgentMode ? 8000 : 2200;
|
||||
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||
|
||||
try {
|
||||
if (isAgentMode) {
|
||||
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
|
||||
method: 'POST',
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
});
|
||||
} else if (scanMode === 'deep') {
|
||||
await fetch(`${CONFIG.apiBase}/scan/stop`, {
|
||||
method: 'POST',
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[WiFiMode] Error stopping scan:', error);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setScanning(scanning, mode = null) {
|
||||
isScanning = scanning;
|
||||
@@ -713,10 +713,10 @@ const WiFiMode = (function() {
|
||||
}, CONFIG.pollInterval);
|
||||
}
|
||||
|
||||
function processQuickScanResult(result) {
|
||||
// Update networks
|
||||
result.access_points.forEach(ap => {
|
||||
networks.set(ap.bssid, ap);
|
||||
function processQuickScanResult(result) {
|
||||
// Update networks
|
||||
result.access_points.forEach(ap => {
|
||||
networks.set(ap.bssid, ap);
|
||||
});
|
||||
|
||||
// Update channel stats (calculate from networks if not provided by API)
|
||||
@@ -724,12 +724,12 @@ const WiFiMode = (function() {
|
||||
recommendations = result.recommendations || [];
|
||||
|
||||
// If no channel stats from API, calculate from networks
|
||||
if (channelStats.length === 0 && networks.size > 0) {
|
||||
channelStats = calculateChannelStats();
|
||||
}
|
||||
|
||||
// Update UI
|
||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||
if (channelStats.length === 0 && networks.size > 0) {
|
||||
channelStats = calculateChannelStats();
|
||||
}
|
||||
|
||||
// Update UI
|
||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||
|
||||
// Callbacks
|
||||
result.access_points.forEach(ap => {
|
||||
@@ -938,25 +938,25 @@ const WiFiMode = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleNetworkUpdate(network) {
|
||||
networks.set(network.bssid, network);
|
||||
scheduleRender({
|
||||
table: true,
|
||||
stats: true,
|
||||
radar: true,
|
||||
chart: true,
|
||||
detail: selectedNetwork === network.bssid,
|
||||
});
|
||||
|
||||
if (onNetworkUpdate) onNetworkUpdate(network);
|
||||
}
|
||||
|
||||
function handleClientUpdate(client) {
|
||||
clients.set(client.mac, client);
|
||||
scheduleRender({ stats: true });
|
||||
|
||||
// Update client display if this client belongs to the selected network
|
||||
updateClientInList(client);
|
||||
function handleNetworkUpdate(network) {
|
||||
networks.set(network.bssid, network);
|
||||
scheduleRender({
|
||||
table: true,
|
||||
stats: true,
|
||||
radar: true,
|
||||
chart: true,
|
||||
detail: selectedNetwork === network.bssid,
|
||||
});
|
||||
|
||||
if (onNetworkUpdate) onNetworkUpdate(network);
|
||||
}
|
||||
|
||||
function handleClientUpdate(client) {
|
||||
clients.set(client.mac, client);
|
||||
scheduleRender({ stats: true });
|
||||
|
||||
// Update client display if this client belongs to the selected network
|
||||
updateClientInList(client);
|
||||
|
||||
if (onClientUpdate) onClientUpdate(client);
|
||||
}
|
||||
@@ -970,37 +970,37 @@ const WiFiMode = (function() {
|
||||
if (onProbeRequest) onProbeRequest(probe);
|
||||
}
|
||||
|
||||
function handleHiddenRevealed(bssid, revealedSsid) {
|
||||
const network = networks.get(bssid);
|
||||
if (network) {
|
||||
network.revealed_essid = revealedSsid;
|
||||
network.display_name = `${revealedSsid} (revealed)`;
|
||||
scheduleRender({
|
||||
table: true,
|
||||
detail: selectedNetwork === bssid,
|
||||
});
|
||||
|
||||
// Show notification
|
||||
showInfo(`Hidden SSID revealed: ${revealedSsid}`);
|
||||
}
|
||||
}
|
||||
function handleHiddenRevealed(bssid, revealedSsid) {
|
||||
const network = networks.get(bssid);
|
||||
if (network) {
|
||||
network.revealed_essid = revealedSsid;
|
||||
network.display_name = `${revealedSsid} (revealed)`;
|
||||
scheduleRender({
|
||||
table: true,
|
||||
detail: selectedNetwork === bssid,
|
||||
});
|
||||
|
||||
// Show notification
|
||||
showInfo(`Hidden SSID revealed: ${revealedSsid}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Network Table
|
||||
// ==========================================================================
|
||||
|
||||
function initNetworkFilters() {
|
||||
if (listenersBound.filters) return;
|
||||
if (!elements.networkFilters) return;
|
||||
|
||||
elements.networkFilters.addEventListener('click', (e) => {
|
||||
if (e.target.matches('.wifi-filter-btn')) {
|
||||
const filter = e.target.dataset.filter;
|
||||
setNetworkFilter(filter);
|
||||
}
|
||||
});
|
||||
listenersBound.filters = true;
|
||||
}
|
||||
function initNetworkFilters() {
|
||||
if (listenersBound.filters) return;
|
||||
if (!elements.networkFilters) return;
|
||||
|
||||
elements.networkFilters.addEventListener('click', (e) => {
|
||||
if (e.target.matches('.wifi-filter-btn')) {
|
||||
const filter = e.target.dataset.filter;
|
||||
setNetworkFilter(filter);
|
||||
}
|
||||
});
|
||||
listenersBound.filters = true;
|
||||
}
|
||||
|
||||
function setNetworkFilter(filter) {
|
||||
currentFilter = filter;
|
||||
@@ -1015,11 +1015,11 @@ const WiFiMode = (function() {
|
||||
updateNetworkTable();
|
||||
}
|
||||
|
||||
function initSortControls() {
|
||||
if (listenersBound.sort) return;
|
||||
if (!elements.networkTable) return;
|
||||
|
||||
elements.networkTable.addEventListener('click', (e) => {
|
||||
function initSortControls() {
|
||||
if (listenersBound.sort) return;
|
||||
if (!elements.networkTable) return;
|
||||
|
||||
elements.networkTable.addEventListener('click', (e) => {
|
||||
const th = e.target.closest('th[data-sort]');
|
||||
if (th) {
|
||||
const field = th.dataset.sort;
|
||||
@@ -1029,54 +1029,54 @@ const WiFiMode = (function() {
|
||||
currentSort.field = field;
|
||||
currentSort.order = 'desc';
|
||||
}
|
||||
updateNetworkTable();
|
||||
}
|
||||
});
|
||||
|
||||
if (elements.networkTableBody) {
|
||||
elements.networkTableBody.addEventListener('click', (e) => {
|
||||
const row = e.target.closest('tr[data-bssid]');
|
||||
if (!row) return;
|
||||
selectNetwork(row.dataset.bssid);
|
||||
});
|
||||
}
|
||||
listenersBound.sort = true;
|
||||
}
|
||||
|
||||
function scheduleRender(flags = {}) {
|
||||
pendingRender.table = pendingRender.table || Boolean(flags.table);
|
||||
pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
|
||||
pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
|
||||
pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
|
||||
pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
|
||||
|
||||
if (renderFramePending) return;
|
||||
renderFramePending = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
renderFramePending = false;
|
||||
|
||||
if (pendingRender.table) updateNetworkTable();
|
||||
if (pendingRender.stats) updateStats();
|
||||
if (pendingRender.radar) updateProximityRadar();
|
||||
if (pendingRender.chart) updateChannelChart();
|
||||
if (pendingRender.detail && selectedNetwork) {
|
||||
updateDetailPanel(selectedNetwork, { refreshClients: false });
|
||||
}
|
||||
|
||||
pendingRender.table = false;
|
||||
pendingRender.stats = false;
|
||||
pendingRender.radar = false;
|
||||
pendingRender.chart = false;
|
||||
pendingRender.detail = false;
|
||||
});
|
||||
}
|
||||
|
||||
function updateNetworkTable() {
|
||||
if (!elements.networkTableBody) return;
|
||||
|
||||
// Filter networks
|
||||
let filtered = Array.from(networks.values());
|
||||
updateNetworkTable();
|
||||
}
|
||||
});
|
||||
|
||||
if (elements.networkTableBody) {
|
||||
elements.networkTableBody.addEventListener('click', (e) => {
|
||||
const row = e.target.closest('tr[data-bssid]');
|
||||
if (!row) return;
|
||||
selectNetwork(row.dataset.bssid);
|
||||
});
|
||||
}
|
||||
listenersBound.sort = true;
|
||||
}
|
||||
|
||||
function scheduleRender(flags = {}) {
|
||||
pendingRender.table = pendingRender.table || Boolean(flags.table);
|
||||
pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
|
||||
pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
|
||||
pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
|
||||
pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
|
||||
|
||||
if (renderFramePending) return;
|
||||
renderFramePending = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
renderFramePending = false;
|
||||
|
||||
if (pendingRender.table) updateNetworkTable();
|
||||
if (pendingRender.stats) updateStats();
|
||||
if (pendingRender.radar) updateProximityRadar();
|
||||
if (pendingRender.chart) updateChannelChart();
|
||||
if (pendingRender.detail && selectedNetwork) {
|
||||
updateDetailPanel(selectedNetwork, { refreshClients: false });
|
||||
}
|
||||
|
||||
pendingRender.table = false;
|
||||
pendingRender.stats = false;
|
||||
pendingRender.radar = false;
|
||||
pendingRender.chart = false;
|
||||
pendingRender.detail = false;
|
||||
});
|
||||
}
|
||||
|
||||
function updateNetworkTable() {
|
||||
if (!elements.networkTableBody) return;
|
||||
|
||||
// Filter networks
|
||||
let filtered = Array.from(networks.values());
|
||||
|
||||
switch (currentFilter) {
|
||||
case 'hidden':
|
||||
@@ -1126,44 +1126,44 @@ const WiFiMode = (function() {
|
||||
return bVal > aVal ? 1 : bVal < aVal ? -1 : 0;
|
||||
} else {
|
||||
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
let message = 'Start scanning to discover networks';
|
||||
let type = 'empty';
|
||||
if (isScanning) {
|
||||
message = 'Scanning for networks...';
|
||||
type = 'loading';
|
||||
} else if (networks.size > 0) {
|
||||
message = 'No networks match current filters';
|
||||
}
|
||||
if (typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(elements.networkTableBody, {
|
||||
type,
|
||||
message,
|
||||
columns: 7,
|
||||
});
|
||||
} else {
|
||||
elements.networkTableBody.innerHTML = `<tr class="wifi-network-placeholder"><td colspan="7"><div class="placeholder-text">${escapeHtml(message)}</div></td></tr>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Render table
|
||||
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function createNetworkRow(network) {
|
||||
const rssi = network.rssi_current;
|
||||
const security = network.security || 'Unknown';
|
||||
const signalClass = rssi >= -50 ? 'signal-strong' :
|
||||
rssi >= -70 ? 'signal-medium' :
|
||||
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
|
||||
|
||||
const securityClass = security === 'Open' ? 'security-open' :
|
||||
security === 'WEP' ? 'security-wep' :
|
||||
security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
|
||||
if (filtered.length === 0) {
|
||||
let message = 'Start scanning to discover networks';
|
||||
let type = 'empty';
|
||||
if (isScanning) {
|
||||
message = 'Scanning for networks...';
|
||||
type = 'loading';
|
||||
} else if (networks.size > 0) {
|
||||
message = 'No networks match current filters';
|
||||
}
|
||||
if (typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(elements.networkTableBody, {
|
||||
type,
|
||||
message,
|
||||
columns: 7,
|
||||
});
|
||||
} else {
|
||||
elements.networkTableBody.innerHTML = `<tr class="wifi-network-placeholder"><td colspan="7"><div class="placeholder-text">${escapeHtml(message)}</div></td></tr>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Render table
|
||||
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
|
||||
}
|
||||
|
||||
function createNetworkRow(network) {
|
||||
const rssi = network.rssi_current;
|
||||
const security = network.security || 'Unknown';
|
||||
const signalClass = rssi >= -50 ? 'signal-strong' :
|
||||
rssi >= -70 ? 'signal-medium' :
|
||||
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
|
||||
|
||||
const securityClass = security === 'Open' ? 'security-open' :
|
||||
security === 'WEP' ? 'security-wep' :
|
||||
security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
|
||||
|
||||
const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : '';
|
||||
const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : '';
|
||||
@@ -1172,25 +1172,25 @@ const WiFiMode = (function() {
|
||||
const agentName = network._agent || 'Local';
|
||||
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
|
||||
|
||||
return `
|
||||
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
|
||||
data-bssid="${escapeHtml(network.bssid)}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-keyboard-activate="true"
|
||||
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
|
||||
<td class="col-essid">
|
||||
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
|
||||
${hiddenBadge}${newBadge}
|
||||
</td>
|
||||
return `
|
||||
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
|
||||
data-bssid="${escapeHtml(network.bssid)}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-keyboard-activate="true"
|
||||
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
|
||||
<td class="col-essid">
|
||||
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
|
||||
${hiddenBadge}${newBadge}
|
||||
</td>
|
||||
<td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td>
|
||||
<td class="col-channel">${network.channel || '-'}</td>
|
||||
<td class="col-rssi">
|
||||
<span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
|
||||
</td>
|
||||
<td class="col-security">
|
||||
<span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
|
||||
</td>
|
||||
<td class="col-rssi">
|
||||
<span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
|
||||
</td>
|
||||
<td class="col-security">
|
||||
<span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
|
||||
</td>
|
||||
<td class="col-clients">${network.client_count || 0}</td>
|
||||
<td class="col-agent">
|
||||
<span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span>
|
||||
@@ -1199,12 +1199,12 @@ const WiFiMode = (function() {
|
||||
`;
|
||||
}
|
||||
|
||||
function updateNetworkRow(network) {
|
||||
scheduleRender({
|
||||
table: true,
|
||||
detail: selectedNetwork === network.bssid,
|
||||
});
|
||||
}
|
||||
function updateNetworkRow(network) {
|
||||
scheduleRender({
|
||||
table: true,
|
||||
detail: selectedNetwork === network.bssid,
|
||||
});
|
||||
}
|
||||
|
||||
function selectNetwork(bssid) {
|
||||
selectedNetwork = bssid;
|
||||
@@ -1227,9 +1227,9 @@ const WiFiMode = (function() {
|
||||
// Detail Panel
|
||||
// ==========================================================================
|
||||
|
||||
function updateDetailPanel(bssid, options = {}) {
|
||||
const { refreshClients = true } = options;
|
||||
if (!elements.detailDrawer) return;
|
||||
function updateDetailPanel(bssid, options = {}) {
|
||||
const { refreshClients = true } = options;
|
||||
if (!elements.detailDrawer) return;
|
||||
|
||||
const network = networks.get(bssid);
|
||||
if (!network) {
|
||||
@@ -1274,11 +1274,11 @@ const WiFiMode = (function() {
|
||||
// Show the drawer
|
||||
elements.detailDrawer.classList.add('open');
|
||||
|
||||
// Fetch and display clients for this network
|
||||
if (refreshClients) {
|
||||
fetchClientsForNetwork(network.bssid);
|
||||
}
|
||||
}
|
||||
// Fetch and display clients for this network
|
||||
if (refreshClients) {
|
||||
fetchClientsForNetwork(network.bssid);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
selectedNetwork = null;
|
||||
@@ -1294,18 +1294,18 @@ const WiFiMode = (function() {
|
||||
// Client Display
|
||||
// ==========================================================================
|
||||
|
||||
async function fetchClientsForNetwork(bssid) {
|
||||
if (!elements.detailClientList) return;
|
||||
const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
|
||||
|
||||
if (listContainer && typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
|
||||
elements.detailClientList.style.display = 'block';
|
||||
}
|
||||
|
||||
try {
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
let response;
|
||||
async function fetchClientsForNetwork(bssid) {
|
||||
if (!elements.detailClientList) return;
|
||||
const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
|
||||
|
||||
if (listContainer && typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
|
||||
elements.detailClientList.style.display = 'block';
|
||||
}
|
||||
|
||||
try {
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
let response;
|
||||
|
||||
if (isAgentMode) {
|
||||
// Route through agent proxy
|
||||
@@ -1314,44 +1314,44 @@ const WiFiMode = (function() {
|
||||
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (listContainer && typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
||||
elements.detailClientList.style.display = 'block';
|
||||
} else {
|
||||
elements.detailClientList.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (listContainer && typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
||||
elements.detailClientList.style.display = 'block';
|
||||
} else {
|
||||
elements.detailClientList.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Handle agent response format (may be nested in 'result')
|
||||
const result = isAgentMode && data.result ? data.result : data;
|
||||
const clientList = result.clients || [];
|
||||
|
||||
if (clientList.length > 0) {
|
||||
renderClientList(clientList, bssid);
|
||||
elements.detailClientList.style.display = 'block';
|
||||
} else {
|
||||
const countBadge = document.getElementById('wifiClientCountBadge');
|
||||
if (countBadge) countBadge.textContent = '0';
|
||||
if (listContainer && typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
|
||||
elements.detailClientList.style.display = 'block';
|
||||
} else {
|
||||
elements.detailClientList.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('[WiFiMode] Error fetching clients:', error);
|
||||
if (listContainer && typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
||||
elements.detailClientList.style.display = 'block';
|
||||
} else {
|
||||
elements.detailClientList.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (clientList.length > 0) {
|
||||
renderClientList(clientList, bssid);
|
||||
elements.detailClientList.style.display = 'block';
|
||||
} else {
|
||||
const countBadge = document.getElementById('wifiClientCountBadge');
|
||||
if (countBadge) countBadge.textContent = '0';
|
||||
if (listContainer && typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
|
||||
elements.detailClientList.style.display = 'block';
|
||||
} else {
|
||||
elements.detailClientList.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('[WiFiMode] Error fetching clients:', error);
|
||||
if (listContainer && typeof renderCollectionState === 'function') {
|
||||
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
||||
elements.detailClientList.style.display = 'block';
|
||||
} else {
|
||||
elements.detailClientList.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderClientList(clientList, bssid) {
|
||||
const container = elements.detailClientList?.querySelector('.wifi-client-list');
|
||||
@@ -1708,16 +1708,16 @@ const WiFiMode = (function() {
|
||||
/**
|
||||
* Clear all collected data.
|
||||
*/
|
||||
function clearData() {
|
||||
networks.clear();
|
||||
clients.clear();
|
||||
probeRequests = [];
|
||||
channelStats = [];
|
||||
recommendations = [];
|
||||
if (selectedNetwork) {
|
||||
closeDetail();
|
||||
}
|
||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||
function clearData() {
|
||||
networks.clear();
|
||||
clients.clear();
|
||||
probeRequests = [];
|
||||
channelStats = [];
|
||||
recommendations = [];
|
||||
if (selectedNetwork) {
|
||||
closeDetail();
|
||||
}
|
||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1763,12 +1763,12 @@ const WiFiMode = (function() {
|
||||
clientsToRemove.push(mac);
|
||||
}
|
||||
});
|
||||
clientsToRemove.forEach(mac => clients.delete(mac));
|
||||
if (selectedNetwork && !networks.has(selectedNetwork)) {
|
||||
closeDetail();
|
||||
}
|
||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||
}
|
||||
clientsToRemove.forEach(mac => clients.delete(mac));
|
||||
if (selectedNetwork && !networks.has(selectedNetwork)) {
|
||||
closeDetail();
|
||||
}
|
||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh WiFi interfaces from current agent.
|
||||
@@ -1811,7 +1811,28 @@ const WiFiMode = (function() {
|
||||
onNetworkUpdate: (cb) => { onNetworkUpdate = cb; },
|
||||
onClientUpdate: (cb) => { onClientUpdate = cb; },
|
||||
onProbeRequest: (cb) => { onProbeRequest = cb; },
|
||||
|
||||
// Lifecycle
|
||||
destroy,
|
||||
};
|
||||
|
||||
/**
|
||||
* Destroy — close SSE stream and clear polling timers for clean mode switching.
|
||||
*/
|
||||
function destroy() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (agentPollTimer) {
|
||||
clearInterval(agentPollTimer);
|
||||
agentPollTimer = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
|
||||
@@ -1762,31 +1762,37 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
||||
airbandSelect.innerHTML = '';
|
||||
|
||||
if (devices.length === 0) {
|
||||
adsbSelect.innerHTML = '<option value="0">No SDR found</option>';
|
||||
airbandSelect.innerHTML = '<option value="0">No SDR found</option>';
|
||||
adsbSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
||||
airbandSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
||||
airbandSelect.disabled = true;
|
||||
} else {
|
||||
devices.forEach((dev, i) => {
|
||||
const idx = dev.index !== undefined ? dev.index : i;
|
||||
const sdrType = dev.sdr_type || 'rtlsdr';
|
||||
const compositeVal = `${sdrType}:${idx}`;
|
||||
const displayName = `SDR ${idx}: ${dev.name}`;
|
||||
|
||||
// Add to ADS-B selector
|
||||
const adsbOpt = document.createElement('option');
|
||||
adsbOpt.value = idx;
|
||||
adsbOpt.value = compositeVal;
|
||||
adsbOpt.dataset.sdrType = sdrType;
|
||||
adsbOpt.dataset.index = idx;
|
||||
adsbOpt.textContent = displayName;
|
||||
adsbSelect.appendChild(adsbOpt);
|
||||
|
||||
// Add to Airband selector
|
||||
const airbandOpt = document.createElement('option');
|
||||
airbandOpt.value = idx;
|
||||
airbandOpt.value = compositeVal;
|
||||
airbandOpt.dataset.sdrType = sdrType;
|
||||
airbandOpt.dataset.index = idx;
|
||||
airbandOpt.textContent = displayName;
|
||||
airbandSelect.appendChild(airbandOpt);
|
||||
});
|
||||
|
||||
// Default: ADS-B uses first device, Airband uses second (if available)
|
||||
adsbSelect.value = devices[0].index !== undefined ? devices[0].index : 0;
|
||||
adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
|
||||
if (devices.length > 1) {
|
||||
airbandSelect.value = devices[1].index !== undefined ? devices[1].index : 1;
|
||||
airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
|
||||
}
|
||||
|
||||
// Show warning if only one device
|
||||
@@ -1797,8 +1803,8 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="0">Error</option>';
|
||||
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="0">Error</option>';
|
||||
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
||||
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2161,11 +2167,14 @@ sudo make install</code>
|
||||
}
|
||||
}
|
||||
|
||||
// Get selected ADS-B device
|
||||
const adsbDevice = parseInt(document.getElementById('adsbDeviceSelect').value) || 0;
|
||||
// Get selected ADS-B device (composite value "sdr_type:index")
|
||||
const adsbSelectVal = document.getElementById('adsbDeviceSelect').value || 'rtlsdr:0';
|
||||
const [adsbSdrType, adsbDeviceIdx] = adsbSelectVal.includes(':') ? adsbSelectVal.split(':') : ['rtlsdr', adsbSelectVal];
|
||||
const adsbDevice = parseInt(adsbDeviceIdx) || 0;
|
||||
|
||||
const requestBody = {
|
||||
device: adsbDevice,
|
||||
sdr_type: adsbSdrType,
|
||||
bias_t: getBiasTEnabled()
|
||||
};
|
||||
if (remoteConfig) {
|
||||
@@ -2316,11 +2325,13 @@ sudo make install</code>
|
||||
}
|
||||
|
||||
const sessionDevice = session.device_index;
|
||||
const sessionSdrType = session.sdr_type || 'rtlsdr';
|
||||
if (sessionDevice !== null && sessionDevice !== undefined) {
|
||||
adsbActiveDevice = sessionDevice;
|
||||
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||||
if (adsbSelect) {
|
||||
adsbSelect.value = sessionDevice;
|
||||
// Use composite value to select the correct device+type
|
||||
adsbSelect.value = `${sessionSdrType}:${sessionDevice}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3834,8 +3845,9 @@ sudo make install</code>
|
||||
|
||||
function startAcars() {
|
||||
const acarsSelect = document.getElementById('acarsDeviceSelect');
|
||||
const device = acarsSelect.value;
|
||||
const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
|
||||
const compositeVal = acarsSelect.value || 'rtlsdr:0';
|
||||
const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
|
||||
const device = deviceIdx;
|
||||
const frequencies = getAcarsRegionFreqs();
|
||||
|
||||
// Check if using agent mode
|
||||
@@ -4179,13 +4191,16 @@ sudo make install</code>
|
||||
const select = document.getElementById('acarsDeviceSelect');
|
||||
select.innerHTML = '';
|
||||
if (devices.length === 0) {
|
||||
select.innerHTML = '<option value="0">No SDR detected</option>';
|
||||
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
|
||||
} else {
|
||||
devices.forEach((d, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.index || i;
|
||||
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
|
||||
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
|
||||
const sdrType = d.sdr_type || 'rtlsdr';
|
||||
const idx = d.index !== undefined ? d.index : i;
|
||||
opt.value = `${sdrType}:${idx}`;
|
||||
opt.dataset.sdrType = sdrType;
|
||||
opt.dataset.index = idx;
|
||||
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
@@ -4277,8 +4292,9 @@ sudo make install</code>
|
||||
|
||||
function startVdl2() {
|
||||
const vdl2Select = document.getElementById('vdl2DeviceSelect');
|
||||
const device = vdl2Select.value;
|
||||
const sdr_type = vdl2Select.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
|
||||
const compositeVal = vdl2Select.value || 'rtlsdr:0';
|
||||
const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
|
||||
const device = deviceIdx;
|
||||
const frequencies = getVdl2RegionFreqs();
|
||||
|
||||
// Check if using agent mode
|
||||
@@ -4723,13 +4739,16 @@ sudo make install</code>
|
||||
const select = document.getElementById('vdl2DeviceSelect');
|
||||
select.innerHTML = '';
|
||||
if (devices.length === 0) {
|
||||
select.innerHTML = '<option value="0">No SDR detected</option>';
|
||||
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
|
||||
} else {
|
||||
devices.forEach((d, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.index || i;
|
||||
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
|
||||
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
|
||||
const sdrType = d.sdr_type || 'rtlsdr';
|
||||
const idx = d.index !== undefined ? d.index : i;
|
||||
opt.value = `${sdrType}:${idx}`;
|
||||
opt.dataset.sdrType = sdrType;
|
||||
opt.dataset.index = idx;
|
||||
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
@@ -5715,13 +5734,16 @@ sudo make install</code>
|
||||
select.innerHTML = '';
|
||||
|
||||
if (devices.length === 0) {
|
||||
select.innerHTML = '<option value="0">No SDR found</option>';
|
||||
select.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
||||
} else {
|
||||
devices.forEach(device => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.index;
|
||||
opt.dataset.sdrType = device.sdr_type || 'rtlsdr';
|
||||
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
|
||||
const sdrType = device.sdr_type || 'rtlsdr';
|
||||
const idx = device.index !== undefined ? device.index : 0;
|
||||
opt.value = `${sdrType}:${idx}`;
|
||||
opt.dataset.sdrType = sdrType;
|
||||
opt.dataset.index = idx;
|
||||
opt.textContent = `SDR ${idx}: ${device.name || device.type || 'SDR'}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
||||
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
||||
morse: "{{ url_for('static', filename='css/modes/morse.css') }}",
|
||||
radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}",
|
||||
system: "{{ url_for('static', filename='css/modes/system.css') }}"
|
||||
};
|
||||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
||||
@@ -307,6 +308,10 @@
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span>
|
||||
<span class="mode-name">GPS</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('radiosonde')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="12" r="4"/><path d="M12 16v6"/><path d="M4.93 4.93l4.24 4.24"/><path d="M14.83 14.83l4.24 4.24"/></svg></span>
|
||||
<span class="mode-name">Radiosonde</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -696,6 +701,8 @@
|
||||
|
||||
{% include 'partials/modes/ais.html' %}
|
||||
|
||||
{% include 'partials/modes/radiosonde.html' %}
|
||||
|
||||
{% include 'partials/modes/spy-stations.html' %}
|
||||
|
||||
{% include 'partials/modes/meshtastic.html' %}
|
||||
@@ -3127,9 +3134,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radiosonde Visuals -->
|
||||
<div id="radiosondeVisuals" class="radiosonde-visuals-container" style="display: none;">
|
||||
<div id="radiosondeMapContainer" style="flex: 1; min-height: 300px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-primary);"></div>
|
||||
<div id="radiosondeCardContainer" class="radiosonde-card-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- System Health Visuals -->
|
||||
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
|
||||
<div class="sys-dashboard">
|
||||
<!-- Row 1: COMPUTE -->
|
||||
<div class="sys-group-header">Compute</div>
|
||||
<div class="sys-card" id="sysCardCpu">
|
||||
<div class="sys-card-header">CPU</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
@@ -3138,8 +3153,30 @@
|
||||
<div class="sys-card-header">Memory</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardTemp">
|
||||
<div class="sys-card-header">Temperature & Power</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: NETWORK & LOCATION -->
|
||||
<div class="sys-group-header">Network & Location</div>
|
||||
<div class="sys-card" id="sysCardNetwork">
|
||||
<div class="sys-card-header">Network</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardLocation">
|
||||
<div class="sys-card-header">Location & Weather</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Loading…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardInfo">
|
||||
<div class="sys-card-header">System Info</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: EQUIPMENT & OPERATIONS -->
|
||||
<div class="sys-group-header">Equipment & Operations</div>
|
||||
<div class="sys-card" id="sysCardDisk">
|
||||
<div class="sys-card-header">Disk</div>
|
||||
<div class="sys-card-header">Disk & Storage</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardSdr">
|
||||
@@ -3147,11 +3184,7 @@
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Scanning…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardProcesses">
|
||||
<div class="sys-card-header">Processes</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardInfo">
|
||||
<div class="sys-card-header">System Info</div>
|
||||
<div class="sys-card-header">Active Processes</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3367,6 +3400,7 @@
|
||||
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
||||
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
||||
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', group: 'tracking' },
|
||||
radiosonde: { label: 'Radiosonde', indicator: 'SONDE', outputTitle: 'Radiosonde Decoder', group: 'tracking' },
|
||||
satellite: { label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space' },
|
||||
sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', group: 'space' },
|
||||
weathersat: { label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space' },
|
||||
@@ -4106,12 +4140,27 @@
|
||||
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
||||
await styleReadyPromise;
|
||||
|
||||
// Clean up SubGHz SSE connection when leaving the mode
|
||||
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
|
||||
SubGhz.destroy();
|
||||
}
|
||||
if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') {
|
||||
MorseMode.destroy();
|
||||
// Generic module cleanup — destroy previous mode's timers, SSE, etc.
|
||||
const moduleDestroyMap = {
|
||||
subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(),
|
||||
morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(),
|
||||
spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(),
|
||||
weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.suspend?.(),
|
||||
wefax: () => typeof WeFax !== 'undefined' && WeFax.destroy?.(),
|
||||
system: () => typeof SystemHealth !== 'undefined' && SystemHealth.destroy?.(),
|
||||
waterfall: () => typeof Waterfall !== 'undefined' && Waterfall.destroy?.(),
|
||||
gps: () => typeof GPS !== 'undefined' && GPS.destroy?.(),
|
||||
meshtastic: () => typeof Meshtastic !== 'undefined' && Meshtastic.destroy?.(),
|
||||
bluetooth: () => typeof BluetoothMode !== 'undefined' && BluetoothMode.destroy?.(),
|
||||
wifi: () => typeof WiFiMode !== 'undefined' && WiFiMode.destroy?.(),
|
||||
bt_locate: () => typeof BtLocate !== 'undefined' && BtLocate.destroy?.(),
|
||||
sstv: () => typeof SSTV !== 'undefined' && SSTV.destroy?.(),
|
||||
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
|
||||
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(),
|
||||
spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(),
|
||||
};
|
||||
if (previousMode && previousMode !== mode && moduleDestroyMap[previousMode]) {
|
||||
try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
|
||||
}
|
||||
|
||||
currentMode = mode;
|
||||
@@ -4155,6 +4204,7 @@
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
||||
document.getElementById('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||
@@ -4204,6 +4254,7 @@
|
||||
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||||
const radiosondeVisuals = document.getElementById('radiosondeVisuals');
|
||||
const systemVisuals = document.getElementById('systemVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
@@ -4222,6 +4273,7 @@
|
||||
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
|
||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
|
||||
if (radiosondeVisuals) radiosondeVisuals.style.display = mode === 'radiosonde' ? 'flex' : 'none';
|
||||
if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
|
||||
|
||||
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||||
@@ -4264,25 +4316,7 @@
|
||||
refreshTscmDevices();
|
||||
}
|
||||
|
||||
// Initialize/destroy Space Weather mode
|
||||
if (mode !== 'spaceweather') {
|
||||
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
|
||||
}
|
||||
|
||||
// Suspend Weather Satellite background timers/streams when leaving the mode
|
||||
if (mode !== 'weathersat') {
|
||||
if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend();
|
||||
}
|
||||
|
||||
// Suspend WeFax background streams when leaving the mode
|
||||
if (mode !== 'wefax') {
|
||||
if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
|
||||
}
|
||||
|
||||
// Disconnect System Health SSE when leaving the mode
|
||||
if (mode !== 'system') {
|
||||
if (typeof SystemHealth !== 'undefined' && SystemHealth.destroy) SystemHealth.destroy();
|
||||
}
|
||||
// Module destroy is now handled by moduleDestroyMap above.
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
@@ -4309,7 +4343,7 @@
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||
if (rtlDeviceSection) {
|
||||
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse') ? 'block' : 'none';
|
||||
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse' || mode === 'radiosonde') ? 'block' : 'none';
|
||||
// Save original sidebar position of SDR device section (once)
|
||||
if (!rtlDeviceSection._origParent) {
|
||||
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
|
||||
@@ -4414,14 +4448,16 @@
|
||||
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||||
} else if (mode === 'morse') {
|
||||
MorseMode.init();
|
||||
} else if (mode === 'radiosonde') {
|
||||
initRadiosondeMap();
|
||||
setTimeout(() => {
|
||||
if (radiosondeMap) radiosondeMap.invalidateSize();
|
||||
}, 100);
|
||||
} else if (mode === 'system') {
|
||||
SystemHealth.init();
|
||||
}
|
||||
|
||||
// Destroy Waterfall WebSocket when leaving SDR receiver modes
|
||||
if (mode !== 'waterfall' && typeof Waterfall !== 'undefined' && Waterfall.destroy) {
|
||||
Promise.resolve(Waterfall.destroy()).catch(() => {});
|
||||
}
|
||||
// Waterfall destroy is now handled by moduleDestroyMap above.
|
||||
|
||||
const totalMs = Math.round(performance.now() - switchStartMs);
|
||||
console.info(
|
||||
@@ -5626,37 +5662,41 @@
|
||||
let currentDeviceList = [];
|
||||
|
||||
// SDR Device Usage Tracking
|
||||
// Tracks which mode is using which device index
|
||||
// Tracks which mode is using which device (keyed by "sdr_type:index")
|
||||
const sdrDeviceUsage = {
|
||||
// deviceIndex: 'modeName' (e.g., 0: 'pager', 1: 'scanner')
|
||||
// "sdr_type:index": 'modeName' (e.g., "rtlsdr:0": 'pager', "hackrf:0": 'scanner')
|
||||
};
|
||||
|
||||
function getDeviceInUseBy(deviceIndex) {
|
||||
return sdrDeviceUsage[deviceIndex] || null;
|
||||
function getDeviceInUseBy(deviceIndex, sdrType) {
|
||||
const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
|
||||
return sdrDeviceUsage[key] || null;
|
||||
}
|
||||
|
||||
function isDeviceInUse(deviceIndex) {
|
||||
return sdrDeviceUsage[deviceIndex] !== undefined;
|
||||
function isDeviceInUse(deviceIndex, sdrType) {
|
||||
const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
|
||||
return sdrDeviceUsage[key] !== undefined;
|
||||
}
|
||||
|
||||
function reserveDevice(deviceIndex, modeName) {
|
||||
sdrDeviceUsage[deviceIndex] = modeName;
|
||||
function reserveDevice(deviceIndex, modeName, sdrType) {
|
||||
const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
|
||||
sdrDeviceUsage[key] = modeName;
|
||||
updateDeviceSelectStatus();
|
||||
}
|
||||
|
||||
function releaseDevice(modeName) {
|
||||
for (const [idx, mode] of Object.entries(sdrDeviceUsage)) {
|
||||
for (const [key, mode] of Object.entries(sdrDeviceUsage)) {
|
||||
if (mode === modeName) {
|
||||
delete sdrDeviceUsage[idx];
|
||||
delete sdrDeviceUsage[key];
|
||||
}
|
||||
}
|
||||
updateDeviceSelectStatus();
|
||||
}
|
||||
|
||||
function getAvailableDevice() {
|
||||
// Find first device not in use
|
||||
// Find first device not in use (within selected SDR type)
|
||||
const sdrType = getSelectedSDRType();
|
||||
for (const device of currentDeviceList) {
|
||||
if (!isDeviceInUse(device.index)) {
|
||||
if ((device.sdr_type || 'rtlsdr') === sdrType && !isDeviceInUse(device.index, sdrType)) {
|
||||
return device.index;
|
||||
}
|
||||
}
|
||||
@@ -5668,10 +5708,11 @@
|
||||
const select = document.getElementById('deviceSelect');
|
||||
if (!select) return;
|
||||
|
||||
const sdrType = getSelectedSDRType();
|
||||
const options = select.querySelectorAll('option');
|
||||
options.forEach(opt => {
|
||||
const idx = parseInt(opt.value);
|
||||
const usedBy = getDeviceInUseBy(idx);
|
||||
const usedBy = getDeviceInUseBy(idx, sdrType);
|
||||
const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status
|
||||
if (usedBy) {
|
||||
opt.textContent = `${baseName} [${usedBy.toUpperCase()}]`;
|
||||
|
||||
383
templates/partials/modes/radiosonde.html
Normal file
383
templates/partials/modes/radiosonde.html
Normal file
@@ -0,0 +1,383 @@
|
||||
<!-- RADIOSONDE WEATHER BALLOON TRACKING MODE -->
|
||||
<div id="radiosondeMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Radiosonde Decoder</h3>
|
||||
<div class="info-text" style="margin-bottom: 15px;">
|
||||
Track weather balloons via radiosonde telemetry on 400–406 MHz. Decodes position, altitude, temperature, humidity, and pressure.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Region / Frequency Band</label>
|
||||
<select id="radiosondeRegionSelect" onchange="updateRadiosondeFreqRange()">
|
||||
<option value="global" selected>Global (400–406 MHz)</option>
|
||||
<option value="eu">Europe (400–403 MHz)</option>
|
||||
<option value="us">US (400–406 MHz)</option>
|
||||
<option value="au">Australia (400–403 MHz)</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="radiosondeCustomFreqGroup" style="display: none;">
|
||||
<label>Frequency Range (MHz)</label>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<input type="number" id="radiosondeFreqMin" value="400.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Min">
|
||||
<span style="color: var(--text-dim);">–</span>
|
||||
<input type="number" id="radiosondeFreqMax" value="406.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Max">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB, 0 = auto)</label>
|
||||
<input type="number" id="radiosondeGainInput" value="40" min="0" max="50" placeholder="0-50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<div id="radiosondeStatusDisplay" class="info-text">
|
||||
<p>Status: <span id="radiosondeStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
||||
<p>Balloons: <span id="radiosondeBalloonCount">0</span></p>
|
||||
<p>Last update: <span id="radiosondeLastUpdate">—</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antenna Guide -->
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||
400 MHz meteorological band — stock SDR antenna may work for nearby launches
|
||||
</p>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Quarter-Wave</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Element length:</strong> ~18.7 cm (quarter-wave at 400 MHz)</li>
|
||||
<li><strong style="color: var(--text-primary);">Material:</strong> Wire or copper rod</li>
|
||||
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical</li>
|
||||
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Tips</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Range:</strong> 200+ km with LNA and good antenna placement</li>
|
||||
<li><strong style="color: var(--text-primary);">LNA:</strong> Recommended — mount near antenna for best results</li>
|
||||
<li><strong style="color: var(--text-primary);">Launches:</strong> Typically 2×/day at 00Z and 12Z from weather stations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Frequency band</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">400–406 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">18.7 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Common types</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RS41, RS92, DFM, M10</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Max altitude</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~35 km (115,000 ft)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Flight duration</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~90 min ascent</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startRadiosondeBtn" onclick="startRadiosondeTracking()">
|
||||
Start Radiosonde Tracking
|
||||
</button>
|
||||
<button class="stop-btn" id="stopRadiosondeBtn" onclick="stopRadiosondeTracking()" style="display: none;">
|
||||
Stop Radiosonde Tracking
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let radiosondeEventSource = null;
|
||||
let radiosondeBalloons = {};
|
||||
|
||||
function updateRadiosondeFreqRange() {
|
||||
const region = document.getElementById('radiosondeRegionSelect').value;
|
||||
const customGroup = document.getElementById('radiosondeCustomFreqGroup');
|
||||
const minInput = document.getElementById('radiosondeFreqMin');
|
||||
const maxInput = document.getElementById('radiosondeFreqMax');
|
||||
|
||||
const presets = {
|
||||
global: [400.0, 406.0],
|
||||
eu: [400.0, 403.0],
|
||||
us: [400.0, 406.0],
|
||||
au: [400.0, 403.0],
|
||||
};
|
||||
|
||||
if (region === 'custom') {
|
||||
customGroup.style.display = 'block';
|
||||
} else {
|
||||
customGroup.style.display = 'none';
|
||||
if (presets[region]) {
|
||||
minInput.value = presets[region][0];
|
||||
maxInput.value = presets[region][1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startRadiosondeTracking() {
|
||||
const gain = document.getElementById('radiosondeGainInput').value || '40';
|
||||
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||
const freqMin = parseFloat(document.getElementById('radiosondeFreqMin').value) || 400.0;
|
||||
const freqMax = parseFloat(document.getElementById('radiosondeFreqMax').value) || 406.0;
|
||||
|
||||
fetch('/radiosonde/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
device,
|
||||
gain,
|
||||
freq_min: freqMin,
|
||||
freq_max: freqMax,
|
||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started' || data.status === 'already_running') {
|
||||
document.getElementById('startRadiosondeBtn').style.display = 'none';
|
||||
document.getElementById('stopRadiosondeBtn').style.display = 'block';
|
||||
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
|
||||
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
|
||||
startRadiosondeSSE();
|
||||
} else {
|
||||
alert(data.message || 'Failed to start radiosonde tracking');
|
||||
}
|
||||
})
|
||||
.catch(err => alert('Error: ' + err.message));
|
||||
}
|
||||
|
||||
function stopRadiosondeTracking() {
|
||||
// Update UI immediately so the user sees feedback
|
||||
document.getElementById('startRadiosondeBtn').style.display = 'block';
|
||||
document.getElementById('stopRadiosondeBtn').style.display = 'none';
|
||||
document.getElementById('radiosondeStatusText').textContent = 'Stopping...';
|
||||
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-yellow)';
|
||||
|
||||
if (radiosondeEventSource) {
|
||||
radiosondeEventSource.close();
|
||||
radiosondeEventSource = null;
|
||||
}
|
||||
|
||||
fetch('/radiosonde/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
document.getElementById('radiosondeStatusText').textContent = 'Standby';
|
||||
document.getElementById('radiosondeBalloonCount').textContent = '0';
|
||||
document.getElementById('radiosondeLastUpdate').textContent = '\u2014';
|
||||
radiosondeBalloons = {};
|
||||
// Clear map markers
|
||||
if (typeof radiosondeMap !== 'undefined' && radiosondeMap) {
|
||||
radiosondeMarkers.forEach(m => radiosondeMap.removeLayer(m));
|
||||
radiosondeMarkers.clear();
|
||||
radiosondeTracks.forEach(t => radiosondeMap.removeLayer(t));
|
||||
radiosondeTracks.clear();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('radiosondeStatusText').textContent = 'Standby';
|
||||
});
|
||||
}
|
||||
|
||||
function startRadiosondeSSE() {
|
||||
if (radiosondeEventSource) radiosondeEventSource.close();
|
||||
|
||||
radiosondeEventSource = new EventSource('/radiosonde/stream');
|
||||
radiosondeEventSource.onmessage = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'balloon') {
|
||||
radiosondeBalloons[data.id] = data;
|
||||
document.getElementById('radiosondeBalloonCount').textContent = Object.keys(radiosondeBalloons).length;
|
||||
const now = new Date();
|
||||
document.getElementById('radiosondeLastUpdate').textContent =
|
||||
now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
updateRadiosondeMap(data);
|
||||
updateRadiosondeCards();
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
radiosondeEventSource.onerror = function() {
|
||||
setTimeout(() => {
|
||||
if (document.getElementById('stopRadiosondeBtn').style.display === 'block') {
|
||||
startRadiosondeSSE();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Map management
|
||||
let radiosondeMap = null;
|
||||
let radiosondeMarkers = new Map();
|
||||
let radiosondeTracks = new Map();
|
||||
let radiosondeTrackPoints = new Map();
|
||||
|
||||
function initRadiosondeMap() {
|
||||
if (radiosondeMap) return;
|
||||
const container = document.getElementById('radiosondeMapContainer');
|
||||
if (!container) return;
|
||||
|
||||
radiosondeMap = L.map('radiosondeMapContainer', {
|
||||
center: [40, -95],
|
||||
zoom: 4,
|
||||
zoomControl: true,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap © CARTO',
|
||||
maxZoom: 18,
|
||||
}).addTo(radiosondeMap);
|
||||
}
|
||||
|
||||
function updateRadiosondeMap(balloon) {
|
||||
if (!radiosondeMap || !balloon.lat || !balloon.lon) return;
|
||||
|
||||
const id = balloon.id;
|
||||
const latlng = [balloon.lat, balloon.lon];
|
||||
|
||||
// Altitude-based colour coding
|
||||
const alt = balloon.alt || 0;
|
||||
let colour;
|
||||
if (alt < 5000) colour = '#00ff88';
|
||||
else if (alt < 15000) colour = '#00ccff';
|
||||
else if (alt < 25000) colour = '#ff9900';
|
||||
else colour = '#ff3366';
|
||||
|
||||
// Update or create marker
|
||||
if (radiosondeMarkers.has(id)) {
|
||||
radiosondeMarkers.get(id).setLatLng(latlng);
|
||||
} else {
|
||||
const marker = L.circleMarker(latlng, {
|
||||
radius: 7,
|
||||
color: colour,
|
||||
fillColor: colour,
|
||||
fillOpacity: 0.8,
|
||||
weight: 2,
|
||||
}).addTo(radiosondeMap);
|
||||
radiosondeMarkers.set(id, marker);
|
||||
}
|
||||
|
||||
// Update marker colour based on altitude
|
||||
radiosondeMarkers.get(id).setStyle({ color: colour, fillColor: colour });
|
||||
|
||||
// Build popup content
|
||||
const altStr = alt ? `${Math.round(alt).toLocaleString()} m` : '--';
|
||||
const tempStr = balloon.temp != null ? `${balloon.temp.toFixed(1)} °C` : '--';
|
||||
const humStr = balloon.humidity != null ? `${balloon.humidity.toFixed(0)}%` : '--';
|
||||
const velStr = balloon.vel_v != null ? `${balloon.vel_v.toFixed(1)} m/s` : '--';
|
||||
radiosondeMarkers.get(id).bindPopup(
|
||||
`<strong>${id}</strong><br>` +
|
||||
`Type: ${balloon.sonde_type || '--'}<br>` +
|
||||
`Alt: ${altStr}<br>` +
|
||||
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
|
||||
`Vert: ${velStr}<br>` +
|
||||
(balloon.freq ? `Freq: ${balloon.freq.toFixed(3)} MHz` : '')
|
||||
);
|
||||
|
||||
// Track polyline
|
||||
if (!radiosondeTrackPoints.has(id)) {
|
||||
radiosondeTrackPoints.set(id, []);
|
||||
}
|
||||
radiosondeTrackPoints.get(id).push(latlng);
|
||||
|
||||
if (radiosondeTracks.has(id)) {
|
||||
radiosondeTracks.get(id).setLatLngs(radiosondeTrackPoints.get(id));
|
||||
} else {
|
||||
const track = L.polyline(radiosondeTrackPoints.get(id), {
|
||||
color: colour,
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
dashArray: '4 4',
|
||||
}).addTo(radiosondeMap);
|
||||
radiosondeTracks.set(id, track);
|
||||
}
|
||||
|
||||
// Auto-centre on first balloon
|
||||
if (radiosondeMarkers.size === 1) {
|
||||
radiosondeMap.setView(latlng, 8);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRadiosondeCards() {
|
||||
const container = document.getElementById('radiosondeCardContainer');
|
||||
if (!container) return;
|
||||
|
||||
const sorted = Object.values(radiosondeBalloons).sort((a, b) => (b.alt || 0) - (a.alt || 0));
|
||||
container.innerHTML = sorted.map(b => {
|
||||
const alt = b.alt ? `${Math.round(b.alt).toLocaleString()} m` : '--';
|
||||
const temp = b.temp != null ? `${b.temp.toFixed(1)}°C` : '--';
|
||||
const hum = b.humidity != null ? `${b.humidity.toFixed(0)}%` : '--';
|
||||
const press = b.pressure != null ? `${b.pressure.toFixed(1)} hPa` : '--';
|
||||
const vel = b.vel_v != null ? `${b.vel_v > 0 ? '+' : ''}${b.vel_v.toFixed(1)} m/s` : '--';
|
||||
const freq = b.freq ? `${b.freq.toFixed(3)} MHz` : '--';
|
||||
return `
|
||||
<div class="radiosonde-card" onclick="radiosondeMap && radiosondeMap.setView([${b.lat || 0}, ${b.lon || 0}], 10)">
|
||||
<div class="radiosonde-card-header">
|
||||
<span class="radiosonde-serial">${b.id}</span>
|
||||
<span class="radiosonde-type">${b.sonde_type || '??'}</span>
|
||||
</div>
|
||||
<div class="radiosonde-stats">
|
||||
<div class="radiosonde-stat">
|
||||
<span class="radiosonde-stat-value">${alt}</span>
|
||||
<span class="radiosonde-stat-label">ALT</span>
|
||||
</div>
|
||||
<div class="radiosonde-stat">
|
||||
<span class="radiosonde-stat-value">${temp}</span>
|
||||
<span class="radiosonde-stat-label">TEMP</span>
|
||||
</div>
|
||||
<div class="radiosonde-stat">
|
||||
<span class="radiosonde-stat-value">${hum}</span>
|
||||
<span class="radiosonde-stat-label">HUM</span>
|
||||
</div>
|
||||
<div class="radiosonde-stat">
|
||||
<span class="radiosonde-stat-value">${press}</span>
|
||||
<span class="radiosonde-stat-label">PRESS</span>
|
||||
</div>
|
||||
<div class="radiosonde-stat">
|
||||
<span class="radiosonde-stat-value">${vel}</span>
|
||||
<span class="radiosonde-stat-label">VERT</span>
|
||||
</div>
|
||||
<div class="radiosonde-stat">
|
||||
<span class="radiosonde-stat-value">${freq}</span>
|
||||
<span class="radiosonde-stat-label">FREQ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Check initial status on load
|
||||
fetch('/radiosonde/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.tracking_active) {
|
||||
document.getElementById('startRadiosondeBtn').style.display = 'none';
|
||||
document.getElementById('stopRadiosondeBtn').style.display = 'block';
|
||||
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
|
||||
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
|
||||
document.getElementById('radiosondeBalloonCount').textContent = data.balloon_count || 0;
|
||||
startRadiosondeSSE();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
</script>
|
||||
@@ -31,6 +31,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network & Location -->
|
||||
<div class="section">
|
||||
<h3>Network</h3>
|
||||
<div id="sysQuickNet" style="font-size: 11px; color: var(--text-dim);">--</div>
|
||||
</div>
|
||||
|
||||
<!-- Battery (shown only when available) -->
|
||||
<div class="section" id="sysQuickBatterySection" style="display: none;">
|
||||
<h3>Battery</h3>
|
||||
<div id="sysQuickBattery" style="font-size: 11px; color: var(--text-dim);">--</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="section">
|
||||
<h3>Location</h3>
|
||||
<div id="sysQuickLocation" style="font-size: 11px; color: var(--text-dim);">--</div>
|
||||
</div>
|
||||
|
||||
<!-- SDR Devices -->
|
||||
<div class="section">
|
||||
<h3>SDR Devices</h3>
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||
{{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
|
||||
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||
{{ mode_item('radiosonde', 'Radiosonde', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><circle cx="12" cy="12" r="4"/><path d="M12 16v6"/><path d="M4.93 4.93l4.24 4.24"/><path d="M14.83 14.83l4.24 4.24"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -257,8 +257,8 @@ class TestMorseLifecycleRoutes:
|
||||
|
||||
released_devices = []
|
||||
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
|
||||
|
||||
class DummyDevice:
|
||||
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
@@ -337,8 +337,8 @@ class TestMorseLifecycleRoutes:
|
||||
|
||||
released_devices = []
|
||||
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
|
||||
|
||||
class DummyDevice:
|
||||
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
@@ -421,8 +421,8 @@ class TestMorseLifecycleRoutes:
|
||||
|
||||
released_devices = []
|
||||
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
|
||||
|
||||
class DummyDevice:
|
||||
def __init__(self, index: int):
|
||||
|
||||
@@ -30,6 +30,32 @@ def test_metrics_returns_expected_keys(client):
|
||||
assert 'uptime_human' in data['system']
|
||||
|
||||
|
||||
def test_metrics_enhanced_keys(client):
|
||||
"""GET /system/metrics returns enhanced metric keys."""
|
||||
_login(client)
|
||||
resp = client.get('/system/metrics')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
# New enhanced keys
|
||||
assert 'network' in data
|
||||
assert 'disk_io' in data
|
||||
assert 'boot_time' in data
|
||||
assert 'battery' in data
|
||||
assert 'fans' in data
|
||||
assert 'power' in data
|
||||
|
||||
# CPU should have per_core and freq
|
||||
if data['cpu'] is not None:
|
||||
assert 'per_core' in data['cpu']
|
||||
assert 'freq' in data['cpu']
|
||||
|
||||
# Network should have interfaces and connections
|
||||
if data['network'] is not None:
|
||||
assert 'interfaces' in data['network']
|
||||
assert 'connections' in data['network']
|
||||
assert 'io' in data['network']
|
||||
|
||||
|
||||
def test_metrics_without_psutil(client):
|
||||
"""Metrics degrade gracefully when psutil is unavailable."""
|
||||
_login(client)
|
||||
@@ -45,6 +71,11 @@ def test_metrics_without_psutil(client):
|
||||
assert data['cpu'] is None
|
||||
assert data['memory'] is None
|
||||
assert data['disk'] is None
|
||||
assert data['network'] is None
|
||||
assert data['disk_io'] is None
|
||||
assert data['battery'] is None
|
||||
assert data['boot_time'] is None
|
||||
assert data['power'] is None
|
||||
finally:
|
||||
mod._HAS_PSUTIL = orig
|
||||
|
||||
@@ -87,3 +118,113 @@ def test_stream_returns_sse_content_type(client):
|
||||
resp = client.get('/system/stream')
|
||||
assert resp.status_code == 200
|
||||
assert 'text/event-stream' in resp.content_type
|
||||
|
||||
|
||||
def test_location_returns_shape(client):
|
||||
"""GET /system/location returns lat/lon/source shape."""
|
||||
_login(client)
|
||||
resp = client.get('/system/location')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'lat' in data
|
||||
assert 'lon' in data
|
||||
assert 'source' in data
|
||||
|
||||
|
||||
def test_location_from_gps(client):
|
||||
"""Location endpoint returns GPS data when fix available."""
|
||||
_login(client)
|
||||
mock_pos = MagicMock()
|
||||
mock_pos.fix_quality = 3
|
||||
mock_pos.latitude = 51.5074
|
||||
mock_pos.longitude = -0.1278
|
||||
mock_pos.satellites = 12
|
||||
mock_pos.epx = 2.5
|
||||
mock_pos.epy = 3.1
|
||||
mock_pos.altitude = 45.0
|
||||
|
||||
with patch('routes.system.get_current_position', return_value=mock_pos, create=True):
|
||||
# Patch the import inside the function
|
||||
import routes.system as mod
|
||||
original = mod._get_observer_location
|
||||
|
||||
def _patched():
|
||||
with patch('utils.gps.get_current_position', return_value=mock_pos):
|
||||
return original()
|
||||
|
||||
mod._get_observer_location = _patched
|
||||
try:
|
||||
resp = client.get('/system/location')
|
||||
finally:
|
||||
mod._get_observer_location = original
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['source'] == 'gps'
|
||||
assert data['lat'] == 51.5074
|
||||
assert data['lon'] == -0.1278
|
||||
assert data['gps']['fix_quality'] == 3
|
||||
assert data['gps']['satellites'] == 12
|
||||
assert data['gps']['accuracy'] == 3.1
|
||||
assert data['gps']['altitude'] == 45.0
|
||||
|
||||
|
||||
def test_location_falls_back_to_defaults(client):
|
||||
"""Location endpoint returns constants defaults when GPS and config unavailable."""
|
||||
_login(client)
|
||||
resp = client.get('/system/location')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'source' in data
|
||||
# Should get location from config or default constants
|
||||
assert data['lat'] is not None
|
||||
assert data['lon'] is not None
|
||||
assert data['source'] in ('config', 'default')
|
||||
|
||||
|
||||
def test_weather_requires_location(client):
|
||||
"""Weather endpoint returns error when no location available."""
|
||||
_login(client)
|
||||
# Without lat/lon params and no GPS state or config
|
||||
resp = client.get('/system/weather')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
# Either returns weather or error (depending on config)
|
||||
assert 'error' in data or 'temp_c' in data
|
||||
|
||||
|
||||
def test_weather_with_mocked_response(client):
|
||||
"""Weather endpoint returns parsed weather data with mocked HTTP."""
|
||||
_login(client)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
'current_condition': [{
|
||||
'temp_C': '22',
|
||||
'temp_F': '72',
|
||||
'weatherDesc': [{'value': 'Clear'}],
|
||||
'humidity': '45',
|
||||
'windspeedMiles': '8',
|
||||
'winddir16Point': 'NW',
|
||||
'FeelsLikeC': '20',
|
||||
'visibility': '10',
|
||||
'pressure': '1013',
|
||||
}]
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
import routes.system as mod
|
||||
# Clear cache
|
||||
mod._weather_cache.clear()
|
||||
mod._weather_cache_time = 0.0
|
||||
|
||||
with patch('routes.system._requests') as mock_requests:
|
||||
mock_requests.get.return_value = mock_resp
|
||||
resp = client.get('/system/weather?lat=40.7&lon=-74.0')
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['temp_c'] == '22'
|
||||
assert data['condition'] == 'Clear'
|
||||
assert data['humidity'] == '45'
|
||||
assert data['wind_mph'] == '8'
|
||||
|
||||
@@ -54,72 +54,72 @@ class TestWeFaxStations:
|
||||
from utils.wefax_stations import get_station
|
||||
assert get_station('noj') is not None
|
||||
|
||||
def test_get_station_not_found(self):
|
||||
"""get_station() should return None for unknown callsign."""
|
||||
from utils.wefax_stations import get_station
|
||||
assert get_station('XXXXX') is None
|
||||
|
||||
def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self):
|
||||
"""Known station frequencies default to carrier-list behavior in auto mode."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='auto',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4296.1, abs_tol=1e-6)
|
||||
assert reference == 'carrier'
|
||||
assert offset_applied is True
|
||||
|
||||
def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self):
|
||||
"""Ad-hoc frequencies (no station metadata) should be treated as dial."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='',
|
||||
frequency_reference='auto',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||
assert reference == 'dial'
|
||||
assert offset_applied is False
|
||||
|
||||
def test_resolve_tuning_frequency_dial_override(self):
|
||||
"""Explicit dial reference must bypass USB alignment."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='dial',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||
assert reference == 'dial'
|
||||
assert offset_applied is False
|
||||
|
||||
def test_resolve_tuning_frequency_rejects_invalid_reference(self):
|
||||
"""Invalid frequency reference values should raise a validation error."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
try:
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='invalid',
|
||||
)
|
||||
assert False, "Expected ValueError for invalid frequency_reference"
|
||||
except ValueError as exc:
|
||||
assert 'frequency_reference' in str(exc)
|
||||
|
||||
def test_station_frequencies_have_khz(self):
|
||||
"""Each frequency entry must have 'khz' and 'description'."""
|
||||
from utils.wefax_stations import load_stations
|
||||
for station in load_stations():
|
||||
for freq in station['frequencies']:
|
||||
def test_get_station_not_found(self):
|
||||
"""get_station() should return None for unknown callsign."""
|
||||
from utils.wefax_stations import get_station
|
||||
assert get_station('XXXXX') is None
|
||||
|
||||
def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self):
|
||||
"""Known station frequencies default to carrier-list behavior in auto mode."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='auto',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4296.1, abs_tol=1e-6)
|
||||
assert reference == 'carrier'
|
||||
assert offset_applied is True
|
||||
|
||||
def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self):
|
||||
"""Ad-hoc frequencies (no station metadata) should be treated as dial."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='',
|
||||
frequency_reference='auto',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||
assert reference == 'dial'
|
||||
assert offset_applied is False
|
||||
|
||||
def test_resolve_tuning_frequency_dial_override(self):
|
||||
"""Explicit dial reference must bypass USB alignment."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='dial',
|
||||
)
|
||||
|
||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||
assert reference == 'dial'
|
||||
assert offset_applied is False
|
||||
|
||||
def test_resolve_tuning_frequency_rejects_invalid_reference(self):
|
||||
"""Invalid frequency reference values should raise a validation error."""
|
||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||
|
||||
try:
|
||||
resolve_tuning_frequency_khz(
|
||||
listed_frequency_khz=4298.0,
|
||||
station_callsign='NOJ',
|
||||
frequency_reference='invalid',
|
||||
)
|
||||
assert False, "Expected ValueError for invalid frequency_reference"
|
||||
except ValueError as exc:
|
||||
assert 'frequency_reference' in str(exc)
|
||||
|
||||
def test_station_frequencies_have_khz(self):
|
||||
"""Each frequency entry must have 'khz' and 'description'."""
|
||||
from utils.wefax_stations import load_stations
|
||||
for station in load_stations():
|
||||
for freq in station['frequencies']:
|
||||
assert 'khz' in freq, f"{station['callsign']} missing khz"
|
||||
assert 'description' in freq, f"{station['callsign']} missing description"
|
||||
assert isinstance(freq['khz'], (int, float))
|
||||
@@ -281,7 +281,7 @@ class TestWeFaxDecoder:
|
||||
# Route tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWeFaxRoutes:
|
||||
class TestWeFaxRoutes:
|
||||
"""WeFax route endpoint tests."""
|
||||
|
||||
def test_status(self, client):
|
||||
@@ -390,11 +390,11 @@ class TestWeFaxRoutes:
|
||||
data = response.get_json()
|
||||
assert 'LPM' in data['message']
|
||||
|
||||
def test_start_success(self, client):
|
||||
"""POST /wefax/start with valid params should succeed."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
def test_start_success(self, client):
|
||||
"""POST /wefax/start with valid params should succeed."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||
@@ -411,46 +411,46 @@ class TestWeFaxRoutes:
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data['frequency_khz'] == 4298
|
||||
assert data['usb_offset_applied'] is True
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
assert data['frequency_reference'] == 'carrier'
|
||||
assert data['station'] == 'NOJ'
|
||||
mock_decoder.start.assert_called_once()
|
||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||
assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
|
||||
def test_start_respects_dial_reference_override(self, client):
|
||||
"""POST /wefax/start with dial reference should not apply USB offset."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({
|
||||
'frequency_khz': 4298,
|
||||
'station': 'NOJ',
|
||||
'device': 0,
|
||||
'frequency_reference': 'dial',
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data['usb_offset_applied'] is False
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||
assert data['frequency_reference'] == 'dial'
|
||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||
assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data['frequency_khz'] == 4298
|
||||
assert data['usb_offset_applied'] is True
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
assert data['frequency_reference'] == 'carrier'
|
||||
assert data['station'] == 'NOJ'
|
||||
mock_decoder.start.assert_called_once()
|
||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||
assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
|
||||
def test_start_respects_dial_reference_override(self, client):
|
||||
"""POST /wefax/start with dial reference should not apply USB offset."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
mock_decoder.is_running = False
|
||||
mock_decoder.start.return_value = True
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
|
||||
response = client.post(
|
||||
'/wefax/start',
|
||||
data=json.dumps({
|
||||
'frequency_khz': 4298,
|
||||
'station': 'NOJ',
|
||||
'device': 0,
|
||||
'frequency_reference': 'dial',
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'started'
|
||||
assert data['usb_offset_applied'] is False
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||
assert data['frequency_reference'] == 'dial'
|
||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||
assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||
|
||||
def test_start_device_busy(self, client):
|
||||
"""POST /wefax/start should return 409 when device is busy."""
|
||||
@@ -509,83 +509,83 @@ class TestWeFaxRoutes:
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_delete_image_wrong_extension(self, client):
|
||||
"""DELETE /wefax/images/<filename> should reject non-PNG."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.delete('/wefax/images/test.jpg')
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_schedule_enable_applies_usb_alignment(self, client):
|
||||
"""Scheduler should receive tuned USB dial frequency in auto mode."""
|
||||
_login_session(client)
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.enable.return_value = {
|
||||
'enabled': True,
|
||||
'scheduled_count': 2,
|
||||
'total_broadcasts': 2,
|
||||
}
|
||||
|
||||
with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler):
|
||||
response = client.post(
|
||||
'/wefax/schedule/enable',
|
||||
data=json.dumps({
|
||||
'station': 'NOJ',
|
||||
'frequency_khz': 4298,
|
||||
'device': 0,
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['usb_offset_applied'] is True
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
enable_kwargs = mock_scheduler.enable.call_args.kwargs
|
||||
assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
|
||||
|
||||
class TestWeFaxProgressCallback:
|
||||
"""Regression tests for WeFax route-level progress callback behavior."""
|
||||
|
||||
def test_terminal_progress_releases_active_device(self):
|
||||
"""Terminal decoder events must release any manually claimed SDR."""
|
||||
import routes.wefax as wefax_routes
|
||||
|
||||
original_device = wefax_routes.wefax_active_device
|
||||
try:
|
||||
wefax_routes.wefax_active_device = 3
|
||||
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||
wefax_routes._progress_callback({
|
||||
'type': 'wefax_progress',
|
||||
'status': 'error',
|
||||
'message': 'decode failed',
|
||||
})
|
||||
|
||||
mock_release.assert_called_once_with(3)
|
||||
assert wefax_routes.wefax_active_device is None
|
||||
finally:
|
||||
wefax_routes.wefax_active_device = original_device
|
||||
|
||||
def test_non_terminal_progress_does_not_release_active_device(self):
|
||||
"""Non-terminal progress updates must not release SDR ownership."""
|
||||
import routes.wefax as wefax_routes
|
||||
|
||||
original_device = wefax_routes.wefax_active_device
|
||||
try:
|
||||
wefax_routes.wefax_active_device = 4
|
||||
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||
wefax_routes._progress_callback({
|
||||
'type': 'wefax_progress',
|
||||
'status': 'receiving',
|
||||
'line_count': 120,
|
||||
})
|
||||
|
||||
mock_release.assert_not_called()
|
||||
assert wefax_routes.wefax_active_device == 4
|
||||
finally:
|
||||
wefax_routes.wefax_active_device = original_device
|
||||
def test_delete_image_wrong_extension(self, client):
|
||||
"""DELETE /wefax/images/<filename> should reject non-PNG."""
|
||||
_login_session(client)
|
||||
mock_decoder = MagicMock()
|
||||
|
||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||
response = client.delete('/wefax/images/test.jpg')
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_schedule_enable_applies_usb_alignment(self, client):
|
||||
"""Scheduler should receive tuned USB dial frequency in auto mode."""
|
||||
_login_session(client)
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.enable.return_value = {
|
||||
'enabled': True,
|
||||
'scheduled_count': 2,
|
||||
'total_broadcasts': 2,
|
||||
}
|
||||
|
||||
with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler):
|
||||
response = client.post(
|
||||
'/wefax/schedule/enable',
|
||||
data=json.dumps({
|
||||
'station': 'NOJ',
|
||||
'frequency_khz': 4298,
|
||||
'device': 0,
|
||||
}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'ok'
|
||||
assert data['usb_offset_applied'] is True
|
||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
enable_kwargs = mock_scheduler.enable.call_args.kwargs
|
||||
assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||
|
||||
|
||||
class TestWeFaxProgressCallback:
|
||||
"""Regression tests for WeFax route-level progress callback behavior."""
|
||||
|
||||
def test_terminal_progress_releases_active_device(self):
|
||||
"""Terminal decoder events must release any manually claimed SDR."""
|
||||
import routes.wefax as wefax_routes
|
||||
|
||||
original_device = wefax_routes.wefax_active_device
|
||||
try:
|
||||
wefax_routes.wefax_active_device = 3
|
||||
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||
wefax_routes._progress_callback({
|
||||
'type': 'wefax_progress',
|
||||
'status': 'error',
|
||||
'message': 'decode failed',
|
||||
})
|
||||
|
||||
mock_release.assert_called_once_with(3, 'rtlsdr')
|
||||
assert wefax_routes.wefax_active_device is None
|
||||
finally:
|
||||
wefax_routes.wefax_active_device = original_device
|
||||
|
||||
def test_non_terminal_progress_does_not_release_active_device(self):
|
||||
"""Non-terminal progress updates must not release SDR ownership."""
|
||||
import routes.wefax as wefax_routes
|
||||
|
||||
original_device = wefax_routes.wefax_active_device
|
||||
try:
|
||||
wefax_routes.wefax_active_device = 4
|
||||
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||
wefax_routes._progress_callback({
|
||||
'type': 'wefax_progress',
|
||||
'status': 'receiving',
|
||||
'line_count': 120,
|
||||
})
|
||||
|
||||
mock_release.assert_not_called()
|
||||
assert wefax_routes.wefax_active_device == 4
|
||||
finally:
|
||||
wefax_routes.wefax_active_device = original_device
|
||||
|
||||
@@ -300,6 +300,20 @@ SUBGHZ_PRESETS = {
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RADIOSONDE (Weather Balloon Tracking)
|
||||
# =============================================================================
|
||||
|
||||
# UDP port for radiosonde_auto_rx telemetry broadcast
|
||||
RADIOSONDE_UDP_PORT = 55673
|
||||
|
||||
# Radiosonde process termination timeout
|
||||
RADIOSONDE_TERMINATE_TIMEOUT = 5
|
||||
|
||||
# Maximum age for balloon data before cleanup (30 min — balloons move slowly)
|
||||
MAX_RADIOSONDE_AGE_SECONDS = 1800
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEAUTH ATTACK DETECTION
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,49 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger('intercept.dependencies')
|
||||
|
||||
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
|
||||
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
|
||||
|
||||
# Tools installed to non-standard locations (not on PATH)
|
||||
KNOWN_TOOL_PATHS: dict[str, list[str]] = {
|
||||
'auto_rx.py': [
|
||||
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
|
||||
'/opt/auto_rx/auto_rx.py',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def check_tool(name: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
return get_tool_path(name) is not None
|
||||
|
||||
|
||||
def get_tool_path(name: str) -> str | None:
|
||||
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
||||
# Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
|
||||
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
|
||||
env_path = os.environ.get(env_key)
|
||||
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
||||
return env_path
|
||||
|
||||
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
|
||||
# /usr/local tools with arm64 Python/runtime.
|
||||
if platform.system() == 'Darwin':
|
||||
machine = platform.machine().lower()
|
||||
preferred_paths: list[str] = []
|
||||
if machine in {'arm64', 'aarch64'}:
|
||||
preferred_paths.append('/opt/homebrew/bin')
|
||||
preferred_paths.append('/usr/local/bin')
|
||||
|
||||
for base in preferred_paths:
|
||||
full_path = os.path.join(base, name)
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
|
||||
# First check standard PATH
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
def get_tool_path(name: str) -> str | None:
|
||||
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
||||
# Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
|
||||
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
|
||||
env_path = os.environ.get(env_key)
|
||||
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
||||
return env_path
|
||||
|
||||
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
|
||||
# /usr/local tools with arm64 Python/runtime.
|
||||
if platform.system() == 'Darwin':
|
||||
machine = platform.machine().lower()
|
||||
preferred_paths: list[str] = []
|
||||
if machine in {'arm64', 'aarch64'}:
|
||||
preferred_paths.append('/opt/homebrew/bin')
|
||||
preferred_paths.append('/usr/local/bin')
|
||||
|
||||
for base in preferred_paths:
|
||||
full_path = os.path.join(base, name)
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
|
||||
# First check standard PATH
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
|
||||
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
||||
for extra_path in EXTRA_TOOL_PATHS:
|
||||
@@ -51,6 +59,11 @@ def get_tool_path(name: str) -> str | None:
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
|
||||
# Check known non-standard install locations
|
||||
for known_path in KNOWN_TOOL_PATHS.get(name, []):
|
||||
if os.path.isfile(known_path):
|
||||
return known_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -447,6 +460,20 @@ TOOL_DEPENDENCIES = {
|
||||
}
|
||||
}
|
||||
},
|
||||
'radiosonde': {
|
||||
'name': 'Radiosonde Tracking',
|
||||
'tools': {
|
||||
'auto_rx.py': {
|
||||
'required': True,
|
||||
'description': 'Radiosonde weather balloon decoder',
|
||||
'install': {
|
||||
'apt': 'Run ./setup.sh (clones from GitHub)',
|
||||
'brew': 'Run ./setup.sh (clones from GitHub)',
|
||||
'manual': 'https://github.com/projecthorus/radiosonde_auto_rx'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'tscm': {
|
||||
'name': 'TSCM Counter-Surveillance',
|
||||
'tools': {
|
||||
|
||||
@@ -6,31 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .base import SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache HackRF detection results so polling endpoints don't repeatedly run
|
||||
# hackrf_info while the device is actively streaming in SubGHz mode.
|
||||
_hackrf_cache: list[SDRDevice] = []
|
||||
_hackrf_cache_ts: float = 0.0
|
||||
_HACKRF_CACHE_TTL_SECONDS = 3.0
|
||||
|
||||
|
||||
def _hackrf_probe_blocked() -> bool:
|
||||
"""Return True when probing HackRF would interfere with an active stream."""
|
||||
try:
|
||||
from utils.subghz import get_subghz_manager
|
||||
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
|
||||
except Exception:
|
||||
return False
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache HackRF detection results so polling endpoints don't repeatedly run
|
||||
# hackrf_info while the device is actively streaming in SubGHz mode.
|
||||
_hackrf_cache: list[SDRDevice] = []
|
||||
_hackrf_cache_ts: float = 0.0
|
||||
_HACKRF_CACHE_TTL_SECONDS = 3.0
|
||||
|
||||
|
||||
def _hackrf_probe_blocked() -> bool:
|
||||
"""Return True when probing HackRF would interfere with an active stream."""
|
||||
try:
|
||||
from utils.subghz import get_subghz_manager
|
||||
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _check_tool(name: str) -> bool:
|
||||
@@ -112,21 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
||||
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
||||
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=5,
|
||||
env=env
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
# Parse device info from rtl_test output
|
||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||
# Require a non-empty serial to avoid matching malformed lines like "SN:".
|
||||
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=5,
|
||||
env=env
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
# Parse device info from rtl_test output
|
||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||
# Require a non-empty serial to avoid matching malformed lines like "SN:".
|
||||
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
|
||||
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
|
||||
@@ -134,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
line = line.strip()
|
||||
match = re.match(device_pattern, line)
|
||||
if match:
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
index=int(match.group(1)),
|
||||
name=match.group(2).strip().rstrip(','),
|
||||
serial=match.group(3),
|
||||
driver='rtlsdr',
|
||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||
))
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
index=int(match.group(1)),
|
||||
name=match.group(2).strip().rstrip(','),
|
||||
serial=match.group(3),
|
||||
driver='rtlsdr',
|
||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||
))
|
||||
|
||||
# Fallback: if we found devices but couldn't parse details
|
||||
if not devices:
|
||||
@@ -314,29 +314,29 @@ def _add_soapy_device(
|
||||
))
|
||||
|
||||
|
||||
def detect_hackrf_devices() -> list[SDRDevice]:
|
||||
"""
|
||||
Detect HackRF devices using native hackrf_info tool.
|
||||
|
||||
Fallback for when SoapySDR is not available.
|
||||
"""
|
||||
global _hackrf_cache, _hackrf_cache_ts
|
||||
now = time.time()
|
||||
|
||||
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
|
||||
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
|
||||
if _hackrf_probe_blocked():
|
||||
return list(_hackrf_cache)
|
||||
|
||||
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
|
||||
return list(_hackrf_cache)
|
||||
|
||||
devices: list[SDRDevice] = []
|
||||
|
||||
if not _check_tool('hackrf_info'):
|
||||
_hackrf_cache = devices
|
||||
_hackrf_cache_ts = now
|
||||
return devices
|
||||
def detect_hackrf_devices() -> list[SDRDevice]:
|
||||
"""
|
||||
Detect HackRF devices using native hackrf_info tool.
|
||||
|
||||
Fallback for when SoapySDR is not available.
|
||||
"""
|
||||
global _hackrf_cache, _hackrf_cache_ts
|
||||
now = time.time()
|
||||
|
||||
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
|
||||
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
|
||||
if _hackrf_probe_blocked():
|
||||
return list(_hackrf_cache)
|
||||
|
||||
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
|
||||
return list(_hackrf_cache)
|
||||
|
||||
devices: list[SDRDevice] = []
|
||||
|
||||
if not _check_tool('hackrf_info'):
|
||||
_hackrf_cache = devices
|
||||
_hackrf_cache_ts = now
|
||||
return devices
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -374,12 +374,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
||||
capabilities=HackRFCommandBuilder.CAPABILITIES
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"HackRF detection error: {e}")
|
||||
|
||||
_hackrf_cache = list(devices)
|
||||
_hackrf_cache_ts = now
|
||||
return devices
|
||||
except Exception as e:
|
||||
logger.debug(f"HackRF detection error: {e}")
|
||||
|
||||
_hackrf_cache = list(devices)
|
||||
_hackrf_cache_ts = now
|
||||
return devices
|
||||
|
||||
|
||||
def probe_rtlsdr_device(device_index: int) -> str | None:
|
||||
@@ -413,31 +413,73 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
|
||||
lib_paths + [current_ld] if current_ld else lib_paths
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
# Use Popen with early termination instead of run() with full timeout.
|
||||
# rtl_test prints device info to stderr quickly, then keeps running
|
||||
# its test loop. We kill it as soon as we see success or failure.
|
||||
proc = subprocess.Popen(
|
||||
['rtl_test', '-d', str(device_index), '-t'],
|
||||
capture_output=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=3,
|
||||
env=env,
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
if 'usb_claim_interface' in output or 'Failed to open' in output:
|
||||
import select
|
||||
error_found = False
|
||||
device_found = False
|
||||
deadline = time.monotonic() + 3.0
|
||||
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
# Wait for stderr output with timeout
|
||||
ready, _, _ = select.select(
|
||||
[proc.stderr], [], [], min(remaining, 0.1)
|
||||
)
|
||||
if ready:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break # EOF — process closed stderr
|
||||
# Check for no-device messages first (before success check,
|
||||
# since "No supported devices found" also contains "Found" + "device")
|
||||
if 'no supported devices' in line.lower() or 'no matching devices' in line.lower():
|
||||
error_found = True
|
||||
break
|
||||
if 'usb_claim_interface' in line or 'Failed to open' in line:
|
||||
error_found = True
|
||||
break
|
||||
if 'Found' in line and 'device' in line.lower():
|
||||
# Device opened successfully — no need to wait longer
|
||||
device_found = True
|
||||
break
|
||||
if proc.poll() is not None:
|
||||
break # Process exited
|
||||
if not device_found and not error_found and proc.poll() is not None and proc.returncode != 0:
|
||||
# rtl_test exited with error and we never saw a success message
|
||||
error_found = True
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
proc.wait()
|
||||
if device_found:
|
||||
# Allow the kernel to fully release the USB interface
|
||||
# before the caller opens the device with dump1090/rtl_fm/etc.
|
||||
time.sleep(0.5)
|
||||
|
||||
if error_found:
|
||||
logger.warning(
|
||||
f"RTL-SDR device {device_index} USB probe failed: "
|
||||
f"device busy or unavailable"
|
||||
)
|
||||
return (
|
||||
f'SDR device {device_index} is busy at the USB level — '
|
||||
f'another process outside INTERCEPT may be using it. '
|
||||
f'Check for stale rtl_fm/rtl_433/dump1090 processes, '
|
||||
f'or try a different device.'
|
||||
f'SDR device {device_index} is not available — '
|
||||
f'check that the RTL-SDR is connected and not in use by another process.'
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# rtl_test opened the device successfully and is running the
|
||||
# test — that means the device *is* available.
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user