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 \
|
&& make install \
|
||||||
&& ldconfig \
|
&& ldconfig \
|
||||||
&& rm -rf /tmp/hackrf \
|
&& 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)
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
&& 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 . .
|
COPY . .
|
||||||
|
|
||||||
# Create data directory for persistence
|
# 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 web interface port
|
||||||
EXPOSE 5050
|
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_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
subghz_lock = threading.Lock()
|
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
|
# CW/Morse code decoder
|
||||||
morse_process = None
|
morse_process = None
|
||||||
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
@@ -257,12 +262,12 @@ cleanup_manager.register(deauth_alerts)
|
|||||||
# SDR DEVICE REGISTRY
|
# SDR DEVICE REGISTRY
|
||||||
# ============================================
|
# ============================================
|
||||||
# Tracks which mode is using which SDR device to prevent conflicts
|
# Tracks which mode is using which SDR device to prevent conflicts
|
||||||
# Key: device_index (int), Value: mode_name (str)
|
# Key: "sdr_type:device_index" (str), Value: mode_name (str)
|
||||||
sdr_device_registry: dict[int, str] = {}
|
sdr_device_registry: dict[str, str] = {}
|
||||||
sdr_device_registry_lock = threading.Lock()
|
sdr_device_registry_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
|
||||||
"""Claim an SDR device for a mode.
|
"""Claim an SDR device for a mode.
|
||||||
|
|
||||||
Checks the in-app registry first, then probes the USB device to
|
Checks the in-app registry first, then probes the USB device to
|
||||||
@@ -272,43 +277,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
|||||||
Args:
|
Args:
|
||||||
device_index: The SDR device index to claim
|
device_index: The SDR device index to claim
|
||||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||||
|
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Error message if device is in use, None if successfully claimed
|
Error message if device is in use, None if successfully claimed
|
||||||
"""
|
"""
|
||||||
|
key = f"{sdr_type}:{device_index}"
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
if device_index in sdr_device_registry:
|
if key in sdr_device_registry:
|
||||||
in_use_by = sdr_device_registry[device_index]
|
in_use_by = sdr_device_registry[key]
|
||||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
return f'SDR device {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
|
# Probe the USB device to catch external processes holding the handle
|
||||||
try:
|
if sdr_type == 'rtlsdr':
|
||||||
from utils.sdr.detection import probe_rtlsdr_device
|
try:
|
||||||
usb_error = probe_rtlsdr_device(device_index)
|
from utils.sdr.detection import probe_rtlsdr_device
|
||||||
if usb_error:
|
usb_error = probe_rtlsdr_device(device_index)
|
||||||
return usb_error
|
if usb_error:
|
||||||
except Exception:
|
return usb_error
|
||||||
pass # If probe fails, let the caller proceed normally
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def release_sdr_device(device_index: int) -> None:
|
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
|
||||||
"""Release an SDR device from the registry.
|
"""Release an SDR device from the registry.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_index: The SDR device index to release
|
device_index: The SDR device index to release
|
||||||
|
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
||||||
"""
|
"""
|
||||||
|
key = f"{sdr_type}:{device_index}"
|
||||||
with sdr_device_registry_lock:
|
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.
|
"""Get current SDR device allocations.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping device indices to mode names
|
Dictionary mapping 'sdr_type:device_index' keys to mode names
|
||||||
"""
|
"""
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
return dict(sdr_device_registry)
|
return dict(sdr_device_registry)
|
||||||
@@ -429,8 +439,9 @@ def get_devices_status() -> Response:
|
|||||||
result = []
|
result = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
d = device.to_dict()
|
d = device.to_dict()
|
||||||
d['in_use'] = device.index in registry
|
key = f"{device.sdr_type.value}:{device.index}"
|
||||||
d['used_by'] = registry.get(device.index)
|
d['in_use'] = key in registry
|
||||||
|
d['used_by'] = registry.get(key)
|
||||||
result.append(d)
|
result.append(d)
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
@@ -760,6 +771,7 @@ def health_check() -> Response:
|
|||||||
'wifi': wifi_active,
|
'wifi': wifi_active,
|
||||||
'bluetooth': bt_active,
|
'bluetooth': bt_active,
|
||||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
'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),
|
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
|
||||||
'subghz': _get_subghz_active(),
|
'subghz': _get_subghz_active(),
|
||||||
},
|
},
|
||||||
@@ -778,12 +790,13 @@ def health_check() -> Response:
|
|||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
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
|
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 adsb as adsb_module
|
||||||
from routes import ais as ais_module
|
from routes import ais as ais_module
|
||||||
|
from routes import radiosonde as radiosonde_module
|
||||||
from utils.bluetooth import reset_bluetooth_scanner
|
from utils.bluetooth import reset_bluetooth_scanner
|
||||||
|
|
||||||
killed = []
|
killed = []
|
||||||
@@ -793,7 +806,8 @@ def kill_all() -> Response:
|
|||||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'satdump',
|
'hcitool', 'bluetoothctl', 'satdump',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
'hackrf_transfer', 'hackrf_sweep'
|
'hackrf_transfer', 'hackrf_sweep',
|
||||||
|
'auto_rx'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -823,6 +837,11 @@ def kill_all() -> Response:
|
|||||||
ais_process = None
|
ais_process = None
|
||||||
ais_module.ais_running = False
|
ais_module.ais_running = False
|
||||||
|
|
||||||
|
# Reset Radiosonde state
|
||||||
|
with radiosonde_lock:
|
||||||
|
radiosonde_process = None
|
||||||
|
radiosonde_module.radiosonde_running = False
|
||||||
|
|
||||||
# Reset ACARS state
|
# Reset ACARS state
|
||||||
with acars_lock:
|
with acars_lock:
|
||||||
acars_process = None
|
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_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0)
|
||||||
SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.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
|
# Update checking
|
||||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def register_blueprints(app):
|
|||||||
from .morse import morse_bp
|
from .morse import morse_bp
|
||||||
from .offline import offline_bp
|
from .offline import offline_bp
|
||||||
from .pager import pager_bp
|
from .pager import pager_bp
|
||||||
|
from .radiosonde import radiosonde_bp
|
||||||
from .recordings import recordings_bp
|
from .recordings import recordings_bp
|
||||||
from .rtlamr import rtlamr_bp
|
from .rtlamr import rtlamr_bp
|
||||||
from .satellite import satellite_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(signalid_bp) # External signal ID enrichment
|
||||||
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
||||||
app.register_blueprint(morse_bp) # CW/Morse code 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
|
app.register_blueprint(system_bp) # System health monitoring
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ acars_last_message_time = None
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
acars_active_device: int | None = None
|
acars_active_device: int | None = None
|
||||||
|
acars_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def find_acarsdec():
|
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}")
|
logger.error(f"ACARS stream error: {e}")
|
||||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global acars_active_device
|
global acars_active_device, acars_active_sdr_type
|
||||||
# Ensure process is terminated
|
# Ensure process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
@@ -180,8 +181,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
app_module.acars_process = None
|
app_module.acars_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if acars_active_device is not None:
|
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_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/tools')
|
@acars_bp.route('/tools')
|
||||||
@@ -213,7 +215,7 @@ def acars_status() -> Response:
|
|||||||
@acars_bp.route('/start', methods=['POST'])
|
@acars_bp.route('/start', methods=['POST'])
|
||||||
def start_acars() -> Response:
|
def start_acars() -> Response:
|
||||||
"""Start ACARS decoder."""
|
"""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:
|
with app_module.acars_lock:
|
||||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
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:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
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
|
# Check if device is available
|
||||||
device_int = int(device)
|
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:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -251,6 +256,7 @@ def start_acars() -> Response:
|
|||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
acars_active_device = device_int
|
acars_active_device = device_int
|
||||||
|
acars_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Get frequencies - use provided or defaults
|
# Get frequencies - use provided or defaults
|
||||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||||
@@ -268,8 +274,6 @@ def start_acars() -> Response:
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
# Resolve SDR type for device selection
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -356,8 +360,9 @@ def start_acars() -> Response:
|
|||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
# Process died - release device
|
# Process died - release device
|
||||||
if acars_active_device is not None:
|
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_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
stderr = ''
|
stderr = ''
|
||||||
if process.stderr:
|
if process.stderr:
|
||||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
@@ -388,8 +393,9 @@ def start_acars() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if acars_active_device is not None:
|
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_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -397,7 +403,7 @@ def start_acars() -> Response:
|
|||||||
@acars_bp.route('/stop', methods=['POST'])
|
@acars_bp.route('/stop', methods=['POST'])
|
||||||
def stop_acars() -> Response:
|
def stop_acars() -> Response:
|
||||||
"""Stop ACARS decoder."""
|
"""Stop ACARS decoder."""
|
||||||
global acars_active_device
|
global acars_active_device, acars_active_sdr_type
|
||||||
|
|
||||||
with app_module.acars_lock:
|
with app_module.acars_lock:
|
||||||
if not app_module.acars_process:
|
if not app_module.acars_process:
|
||||||
@@ -418,8 +424,9 @@ def stop_acars() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if acars_active_device is not None:
|
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_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -445,6 +452,7 @@ def stream_acars() -> Response:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/messages')
|
@acars_bp.route('/messages')
|
||||||
def get_acars_messages() -> Response:
|
def get_acars_messages() -> Response:
|
||||||
"""Get recent ACARS messages from correlator (for history reload)."""
|
"""Get recent ACARS messages from correlator (for history reload)."""
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ adsb_last_message_time = None
|
|||||||
adsb_bytes_received = 0
|
adsb_bytes_received = 0
|
||||||
adsb_lines_received = 0
|
adsb_lines_received = 0
|
||||||
adsb_active_device = None # Track which device index is being used
|
adsb_active_device = None # Track which device index is being used
|
||||||
|
adsb_active_sdr_type: str | None = None
|
||||||
_sbs_error_logged = False # Suppress repeated connection error logs
|
_sbs_error_logged = False # Suppress repeated connection error logs
|
||||||
|
|
||||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||||
@@ -674,7 +675,7 @@ def adsb_session():
|
|||||||
@adsb_bp.route('/start', methods=['POST'])
|
@adsb_bp.route('/start', methods=['POST'])
|
||||||
def start_adsb():
|
def start_adsb():
|
||||||
"""Start ADS-B tracking."""
|
"""Start ADS-B tracking."""
|
||||||
global adsb_using_service, adsb_active_device
|
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
|
||||||
|
|
||||||
with app_module.adsb_lock:
|
with app_module.adsb_lock:
|
||||||
if adsb_using_service:
|
if adsb_using_service:
|
||||||
@@ -757,6 +758,7 @@ def start_adsb():
|
|||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
sdr_type = SDRType.RTL_SDR
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
sdr_type_str = sdr_type.value
|
||||||
|
|
||||||
# For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR
|
# For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR
|
||||||
if sdr_type == SDRType.RTL_SDR:
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
@@ -787,7 +789,7 @@ def start_adsb():
|
|||||||
|
|
||||||
# Check if device is available before starting local dump1090
|
# Check if device is available before starting local dump1090
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'adsb')
|
error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -795,6 +797,10 @@ def start_adsb():
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 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
|
# Create device object and build command via abstraction layer
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
@@ -821,11 +827,24 @@ def start_adsb():
|
|||||||
)
|
)
|
||||||
write_dump1090_pid(app_module.adsb_process.pid)
|
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:
|
if app_module.adsb_process.poll() is not None:
|
||||||
# Process exited - release device and get error message
|
# Process exited - release device and get error message
|
||||||
app_module.release_sdr_device(device_int)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
|
adsb_active_device = None
|
||||||
|
adsb_active_sdr_type = None
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.adsb_process.stderr:
|
if app_module.adsb_process.stderr:
|
||||||
try:
|
try:
|
||||||
@@ -871,7 +890,6 @@ def start_adsb():
|
|||||||
})
|
})
|
||||||
|
|
||||||
adsb_using_service = True
|
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 = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
@@ -891,14 +909,16 @@ def start_adsb():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
|
adsb_active_device = None
|
||||||
|
adsb_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/stop', methods=['POST'])
|
@adsb_bp.route('/stop', methods=['POST'])
|
||||||
def stop_adsb():
|
def stop_adsb():
|
||||||
"""Stop ADS-B tracking."""
|
"""Stop ADS-B tracking."""
|
||||||
global adsb_using_service, adsb_active_device
|
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
stop_source = data.get('source')
|
stop_source = data.get('source')
|
||||||
stopped_by = request.remote_addr
|
stopped_by = request.remote_addr
|
||||||
@@ -923,10 +943,11 @@ def stop_adsb():
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if adsb_active_device is not None:
|
if adsb_active_device is not None:
|
||||||
app_module.release_sdr_device(adsb_active_device)
|
app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr')
|
||||||
|
|
||||||
adsb_using_service = False
|
adsb_using_service = False
|
||||||
adsb_active_device = None
|
adsb_active_device = None
|
||||||
|
adsb_active_sdr_type = None
|
||||||
|
|
||||||
app_module.adsb_aircraft.clear()
|
app_module.adsb_aircraft.clear()
|
||||||
_looked_up_icaos.clear()
|
_looked_up_icaos.clear()
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ ais_connected = False
|
|||||||
ais_messages_received = 0
|
ais_messages_received = 0
|
||||||
ais_last_message_time = None
|
ais_last_message_time = None
|
||||||
ais_active_device = None
|
ais_active_device = None
|
||||||
|
ais_active_sdr_type: str | None = None
|
||||||
_ais_error_logged = True
|
_ais_error_logged = True
|
||||||
|
|
||||||
# Common installation paths for AIS-catcher
|
# Common installation paths for AIS-catcher
|
||||||
@@ -350,7 +351,7 @@ def ais_status():
|
|||||||
@ais_bp.route('/start', methods=['POST'])
|
@ais_bp.route('/start', methods=['POST'])
|
||||||
def start_ais():
|
def start_ais():
|
||||||
"""Start AIS tracking."""
|
"""Start AIS tracking."""
|
||||||
global ais_running, ais_active_device
|
global ais_running, ais_active_device, ais_active_sdr_type
|
||||||
|
|
||||||
with app_module.ais_lock:
|
with app_module.ais_lock:
|
||||||
if ais_running:
|
if ais_running:
|
||||||
@@ -397,7 +398,7 @@ def start_ais():
|
|||||||
|
|
||||||
# Check if device is available
|
# Check if device is available
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'ais')
|
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -436,7 +437,7 @@ def start_ais():
|
|||||||
|
|
||||||
if app_module.ais_process.poll() is not None:
|
if app_module.ais_process.poll() is not None:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
stderr_output = ''
|
stderr_output = ''
|
||||||
if app_module.ais_process.stderr:
|
if app_module.ais_process.stderr:
|
||||||
try:
|
try:
|
||||||
@@ -450,6 +451,7 @@ def start_ais():
|
|||||||
|
|
||||||
ais_running = True
|
ais_running = True
|
||||||
ais_active_device = device
|
ais_active_device = device
|
||||||
|
ais_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Start TCP parser thread
|
# Start TCP parser thread
|
||||||
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||||
@@ -463,7 +465,7 @@ def start_ais():
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
app_module.release_sdr_device(device_int)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -471,7 +473,7 @@ def start_ais():
|
|||||||
@ais_bp.route('/stop', methods=['POST'])
|
@ais_bp.route('/stop', methods=['POST'])
|
||||||
def stop_ais():
|
def stop_ais():
|
||||||
"""Stop AIS tracking."""
|
"""Stop AIS tracking."""
|
||||||
global ais_running, ais_active_device
|
global ais_running, ais_active_device, ais_active_sdr_type
|
||||||
|
|
||||||
with app_module.ais_lock:
|
with app_module.ais_lock:
|
||||||
if app_module.ais_process:
|
if app_module.ais_process:
|
||||||
@@ -490,10 +492,11 @@ def stop_ais():
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if ais_active_device is not None:
|
if ais_active_device is not None:
|
||||||
app_module.release_sdr_device(ais_active_device)
|
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
|
||||||
|
|
||||||
ais_running = False
|
ais_running = False
|
||||||
ais_active_device = None
|
ais_active_device = None
|
||||||
|
ais_active_sdr_type = None
|
||||||
|
|
||||||
app_module.ais_vessels.clear()
|
app_module.ais_vessels.clear()
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|||||||
259
routes/aprs.py
259
routes/aprs.py
@@ -5,8 +5,10 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pty
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import select
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -35,6 +37,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
|||||||
|
|
||||||
# Track which SDR device is being used
|
# Track which SDR device is being used
|
||||||
aprs_active_device: int | None = None
|
aprs_active_device: int | None = None
|
||||||
|
aprs_active_sdr_type: str | None = None
|
||||||
|
|
||||||
# APRS frequencies by region (MHz)
|
# APRS frequencies by region (MHz)
|
||||||
APRS_FREQUENCIES = {
|
APRS_FREQUENCIES = {
|
||||||
@@ -103,6 +106,9 @@ ADEVICE stdin null
|
|||||||
CHANNEL 0
|
CHANNEL 0
|
||||||
MYCALL N0CALL
|
MYCALL N0CALL
|
||||||
MODEM 1200
|
MODEM 1200
|
||||||
|
FIX_BITS 1
|
||||||
|
AGWPORT 0
|
||||||
|
KISSPORT 0
|
||||||
"""
|
"""
|
||||||
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
|
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
|
||||||
f.write(config)
|
f.write(config)
|
||||||
@@ -1437,19 +1443,19 @@ def should_send_meter_update(level: int) -> bool:
|
|||||||
return False
|
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.
|
"""Stream decoded APRS packets and audio level meter to queue.
|
||||||
|
|
||||||
This function reads from the decoder's stdout (text mode, line-buffered).
|
Reads from a PTY master fd to get line-buffered output from the decoder,
|
||||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
avoiding the 15-minute pipe buffering delay. Uses select() + os.read()
|
||||||
rtl_fm's stderr is captured via PIPE with a monitor thread.
|
to poll the PTY (same pattern as pager.py).
|
||||||
|
|
||||||
Outputs two types of messages to the queue:
|
Outputs two types of messages to the queue:
|
||||||
- type='aprs': Decoded APRS packets
|
- type='aprs': Decoded APRS packets
|
||||||
- type='meter': Audio level meter readings (rate-limited)
|
- type='meter': Audio level meter readings (rate-limited)
|
||||||
"""
|
"""
|
||||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
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
|
# Capture the device claimed by THIS session so the finally block only
|
||||||
# releases our own device, not one claimed by a subsequent start.
|
# 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:
|
try:
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
# Read line-by-line in binary mode. Empty bytes b'' signals EOF.
|
# Read from PTY using select() for non-blocking reads.
|
||||||
# Decode with errors='replace' so corrupted radio bytes (e.g. 0xf7)
|
# PTY forces the decoder to line-buffer, so output arrives immediately
|
||||||
# never crash the stream.
|
# instead of waiting for a full 4-8KB pipe buffer to fill.
|
||||||
for raw in iter(decoder_process.stdout.readline, b''):
|
buffer = ""
|
||||||
line = raw.decode('utf-8', errors='replace').strip()
|
while True:
|
||||||
if not line:
|
try:
|
||||||
continue
|
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
# Check for audio level line first (for signal meter)
|
if ready:
|
||||||
audio_level = parse_audio_level(line)
|
try:
|
||||||
if audio_level is not None:
|
data = os.read(master_fd, 1024)
|
||||||
if should_send_meter_update(audio_level):
|
if not data:
|
||||||
meter_msg = {
|
break
|
||||||
'type': 'meter',
|
buffer += data.decode('utf-8', errors='replace')
|
||||||
'level': audio_level,
|
except OSError:
|
||||||
'ts': datetime.utcnow().isoformat() + 'Z'
|
break
|
||||||
}
|
|
||||||
app_module.aprs_queue.put(meter_msg)
|
|
||||||
continue # Audio level lines are not packets
|
|
||||||
|
|
||||||
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
while '\n' in buffer:
|
||||||
line = normalize_aprs_output_line(line)
|
line, buffer = buffer.split('\n', 1)
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
# Check for audio level line first (for signal meter)
|
||||||
if '>' not in line or ':' not in line:
|
audio_level = parse_audio_level(line)
|
||||||
continue
|
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)
|
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
||||||
if packet:
|
line = normalize_aprs_output_line(line)
|
||||||
aprs_packet_count += 1
|
|
||||||
aprs_last_packet_time = time.time()
|
|
||||||
|
|
||||||
# Track unique stations
|
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||||
callsign = packet.get('callsign')
|
if '>' not in line or ':' not in line:
|
||||||
if callsign and callsign not in aprs_stations:
|
continue
|
||||||
aprs_station_count += 1
|
|
||||||
|
|
||||||
# Update station data, preserving last known coordinates when
|
packet = parse_aprs_packet(line)
|
||||||
# packets do not contain position fields.
|
if packet:
|
||||||
if callsign:
|
aprs_packet_count += 1
|
||||||
existing = aprs_stations.get(callsign, {})
|
aprs_last_packet_time = time.time()
|
||||||
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)
|
# Track unique stations
|
||||||
|
callsign = packet.get('callsign')
|
||||||
|
if callsign and callsign not in aprs_stations:
|
||||||
|
aprs_station_count += 1
|
||||||
|
|
||||||
# Log if enabled
|
# Update station data, preserving last known coordinates when
|
||||||
if app_module.logging_enabled:
|
# packets do not contain position fields.
|
||||||
try:
|
if callsign:
|
||||||
with open(app_module.log_file_path, 'a') as f:
|
existing = aprs_stations.get(callsign, {})
|
||||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
packet_lat = packet.get('lat')
|
||||||
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
packet_lon = packet.get('lon')
|
||||||
except Exception:
|
aprs_stations[callsign] = {
|
||||||
pass
|
'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:
|
except Exception as e:
|
||||||
logger.error(f"APRS stream error: {e}")
|
logger.error(f"APRS stream error: {e}")
|
||||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
# Cleanup processes
|
# Cleanup processes
|
||||||
for proc in [rtl_process, decoder_process]:
|
for proc in [rtl_process, decoder_process]:
|
||||||
@@ -1562,8 +1589,9 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
pass
|
pass
|
||||||
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
# 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:
|
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_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/tools')
|
@aprs_bp.route('/tools')
|
||||||
@@ -1632,7 +1660,7 @@ def aprs_data() -> Response:
|
|||||||
def start_aprs() -> Response:
|
def start_aprs() -> Response:
|
||||||
"""Start APRS decoder."""
|
"""Start APRS decoder."""
|
||||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
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:
|
with app_module.aprs_lock:
|
||||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||||
@@ -1681,7 +1709,7 @@ def start_aprs() -> Response:
|
|||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Reserve SDR device to prevent conflicts with other modes
|
# 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:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1689,6 +1717,7 @@ def start_aprs() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
aprs_active_device = device
|
aprs_active_device = device
|
||||||
|
aprs_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Get frequency for region
|
# Get frequency for region
|
||||||
region = data.get('region', 'north_america')
|
region = data.get('region', 'north_america')
|
||||||
@@ -1730,8 +1759,9 @@ def start_aprs() -> Response:
|
|||||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if aprs_active_device is not None:
|
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_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||||
|
|
||||||
# Build decoder command
|
# 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 = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
||||||
rtl_stderr_thread.start()
|
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.
|
# Start decoder with stdin wired to rtl_fm's stdout.
|
||||||
# Use binary mode to avoid UnicodeDecodeError on raw/corrupted bytes
|
# stdout/stderr go to the PTY slave so output is line-buffered.
|
||||||
# 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.
|
|
||||||
decoder_process = subprocess.Popen(
|
decoder_process = subprocess.Popen(
|
||||||
decoder_cmd,
|
decoder_cmd,
|
||||||
stdin=rtl_process.stdout,
|
stdin=rtl_process.stdout,
|
||||||
stdout=PIPE,
|
stdout=slave_fd,
|
||||||
stderr=STDOUT,
|
stderr=slave_fd,
|
||||||
|
close_fds=True,
|
||||||
start_new_session=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.
|
# Close rtl_fm's stdout in parent so decoder owns it exclusively.
|
||||||
# This ensures proper EOF propagation when rtl_fm terminates.
|
# This ensures proper EOF propagation when rtl_fm terminates.
|
||||||
rtl_process.stdout.close()
|
rtl_process.stdout.close()
|
||||||
@@ -1818,40 +1854,57 @@ def start_aprs() -> Response:
|
|||||||
if stderr_output:
|
if stderr_output:
|
||||||
error_msg += f': {stderr_output[:200]}'
|
error_msg += f': {stderr_output[:200]}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
decoder_process.kill()
|
decoder_process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if aprs_active_device is not None:
|
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_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
if decoder_process.poll() is not None:
|
if decoder_process.poll() is not None:
|
||||||
# Decoder exited early - capture any output
|
# Decoder exited early - capture any output from PTY
|
||||||
raw_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else b''
|
error_output = ''
|
||||||
error_output = raw_output.decode('utf-8', errors='replace') if raw_output else ''
|
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'
|
error_msg = f'{decoder_name} failed to start'
|
||||||
if error_output:
|
if error_output:
|
||||||
error_msg += f': {error_output}'
|
error_msg += f': {error_output}'
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if aprs_active_device is not None:
|
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_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
# Store references for status checks and cleanup
|
# Store references for status checks and cleanup
|
||||||
app_module.aprs_process = decoder_process
|
app_module.aprs_process = decoder_process
|
||||||
app_module.aprs_rtl_process = rtl_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
|
# Start background thread to read decoder output and push to queue
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=stream_aprs_output,
|
target=stream_aprs_output,
|
||||||
args=(rtl_process, decoder_process),
|
args=(master_fd, rtl_process, decoder_process),
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -1868,15 +1921,16 @@ def start_aprs() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start APRS decoder: {e}")
|
logger.error(f"Failed to start APRS decoder: {e}")
|
||||||
if aprs_active_device is not None:
|
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_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/stop', methods=['POST'])
|
@aprs_bp.route('/stop', methods=['POST'])
|
||||||
def stop_aprs() -> Response:
|
def stop_aprs() -> Response:
|
||||||
"""Stop APRS decoder."""
|
"""Stop APRS decoder."""
|
||||||
global aprs_active_device
|
global aprs_active_device, aprs_active_sdr_type
|
||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
processes_to_stop = []
|
processes_to_stop = []
|
||||||
@@ -1902,14 +1956,23 @@ def stop_aprs() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping APRS process: {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
|
app_module.aprs_process = None
|
||||||
if hasattr(app_module, 'aprs_rtl_process'):
|
if hasattr(app_module, 'aprs_rtl_process'):
|
||||||
app_module.aprs_rtl_process = None
|
app_module.aprs_rtl_process = None
|
||||||
|
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if aprs_active_device is not None:
|
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_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ dsc_running = False
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
dsc_active_device: int | None = None
|
dsc_active_device: int | None = None
|
||||||
|
dsc_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _get_dsc_decoder_path() -> str | 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)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
global dsc_active_device
|
global dsc_active_device, dsc_active_sdr_type
|
||||||
try:
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -197,8 +198,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
|||||||
app_module.dsc_rtl_process = None
|
app_module.dsc_rtl_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if dsc_active_device is not None:
|
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_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
def _store_critical_alert(msg: dict) -> None:
|
def _store_critical_alert(msg: dict) -> None:
|
||||||
@@ -331,10 +333,13 @@ def start_decoding() -> Response:
|
|||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
# Get SDR type from request
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
# Check if device is available using centralized registry
|
# Check if device is available using centralized registry
|
||||||
global dsc_active_device
|
global dsc_active_device, dsc_active_sdr_type
|
||||||
device_int = int(device)
|
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:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -343,6 +348,7 @@ def start_decoding() -> Response:
|
|||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
dsc_active_device = device_int
|
dsc_active_device = device_int
|
||||||
|
dsc_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Clear queue
|
# Clear queue
|
||||||
while not app_module.dsc_queue.empty():
|
while not app_module.dsc_queue.empty():
|
||||||
@@ -440,8 +446,9 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
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_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Tool not found: {e.filename}'
|
'message': f'Tool not found: {e.filename}'
|
||||||
@@ -458,8 +465,9 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if dsc_active_device is not None:
|
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_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
logger.error(f"Failed to start DSC decoder: {e}")
|
logger.error(f"Failed to start DSC decoder: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -470,7 +478,7 @@ def start_decoding() -> Response:
|
|||||||
@dsc_bp.route('/stop', methods=['POST'])
|
@dsc_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
"""Stop DSC decoder."""
|
"""Stop DSC decoder."""
|
||||||
global dsc_running, dsc_active_device
|
global dsc_running, dsc_active_device, dsc_active_sdr_type
|
||||||
|
|
||||||
with app_module.dsc_lock:
|
with app_module.dsc_lock:
|
||||||
if not app_module.dsc_process:
|
if not app_module.dsc_process:
|
||||||
@@ -509,8 +517,9 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if dsc_active_device is not None:
|
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_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ scanner_lock = threading.Lock()
|
|||||||
scanner_paused = False
|
scanner_paused = False
|
||||||
scanner_current_freq = 0.0
|
scanner_current_freq = 0.0
|
||||||
scanner_active_device: Optional[int] = None
|
scanner_active_device: Optional[int] = None
|
||||||
|
scanner_active_sdr_type: str = 'rtlsdr'
|
||||||
receiver_active_device: Optional[int] = None
|
receiver_active_device: Optional[int] = None
|
||||||
|
receiver_active_sdr_type: str = 'rtlsdr'
|
||||||
scanner_power_process: Optional[subprocess.Popen] = None
|
scanner_power_process: Optional[subprocess.Popen] = None
|
||||||
scanner_config = {
|
scanner_config = {
|
||||||
'start_freq': 88.0,
|
'start_freq': 88.0,
|
||||||
@@ -996,7 +998,7 @@ def check_tools() -> Response:
|
|||||||
@receiver_bp.route('/scanner/start', methods=['POST'])
|
@receiver_bp.route('/scanner/start', methods=['POST'])
|
||||||
def start_scanner() -> Response:
|
def start_scanner() -> Response:
|
||||||
"""Start the frequency scanner."""
|
"""Start the frequency scanner."""
|
||||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, 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:
|
with scanner_lock:
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
@@ -1063,10 +1065,11 @@ def start_scanner() -> Response:
|
|||||||
}), 503
|
}), 503
|
||||||
# Release listening device if active
|
# Release listening device if active
|
||||||
if receiver_active_device is not None:
|
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_device = None
|
||||||
|
receiver_active_sdr_type = 'rtlsdr'
|
||||||
# Claim device for scanner
|
# Claim device for scanner
|
||||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1074,6 +1077,7 @@ def start_scanner() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
scanner_active_device = scanner_config['device']
|
scanner_active_device = scanner_config['device']
|
||||||
|
scanner_active_sdr_type = scanner_config['sdr_type']
|
||||||
scanner_running = True
|
scanner_running = True
|
||||||
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
||||||
scanner_thread.start()
|
scanner_thread.start()
|
||||||
@@ -1091,9 +1095,10 @@ def start_scanner() -> Response:
|
|||||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||||
}), 503
|
}), 503
|
||||||
if receiver_active_device is not None:
|
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_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:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1101,6 +1106,7 @@ def start_scanner() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
scanner_active_device = scanner_config['device']
|
scanner_active_device = scanner_config['device']
|
||||||
|
scanner_active_sdr_type = scanner_config['sdr_type']
|
||||||
|
|
||||||
scanner_running = True
|
scanner_running = True
|
||||||
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
||||||
@@ -1115,7 +1121,7 @@ def start_scanner() -> Response:
|
|||||||
@receiver_bp.route('/scanner/stop', methods=['POST'])
|
@receiver_bp.route('/scanner/stop', methods=['POST'])
|
||||||
def stop_scanner() -> Response:
|
def stop_scanner() -> Response:
|
||||||
"""Stop the frequency scanner."""
|
"""Stop the frequency scanner."""
|
||||||
global scanner_running, scanner_active_device, scanner_power_process
|
global scanner_running, scanner_active_device, scanner_active_sdr_type, scanner_power_process
|
||||||
|
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
_stop_audio_stream()
|
_stop_audio_stream()
|
||||||
@@ -1130,8 +1136,9 @@ def stop_scanner() -> Response:
|
|||||||
pass
|
pass
|
||||||
scanner_power_process = None
|
scanner_power_process = None
|
||||||
if scanner_active_device is not None:
|
if scanner_active_device is not None:
|
||||||
app_module.release_sdr_device(scanner_active_device)
|
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type)
|
||||||
scanner_active_device = None
|
scanner_active_device = None
|
||||||
|
scanner_active_sdr_type = 'rtlsdr'
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -1296,7 +1303,7 @@ def get_presets() -> Response:
|
|||||||
@receiver_bp.route('/audio/start', methods=['POST'])
|
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||||
def start_audio() -> Response:
|
def start_audio() -> Response:
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
global scanner_running, scanner_active_device, 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
|
global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -1356,8 +1363,9 @@ def start_audio() -> Response:
|
|||||||
if scanner_running:
|
if scanner_running:
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
if scanner_active_device is not None:
|
if scanner_active_device is not None:
|
||||||
app_module.release_sdr_device(scanner_active_device)
|
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type)
|
||||||
scanner_active_device = None
|
scanner_active_device = None
|
||||||
|
scanner_active_sdr_type = 'rtlsdr'
|
||||||
scanner_thread_ref = scanner_thread
|
scanner_thread_ref = scanner_thread
|
||||||
scanner_proc_ref = scanner_power_process
|
scanner_proc_ref = scanner_power_process
|
||||||
scanner_power_process = None
|
scanner_power_process = None
|
||||||
@@ -1419,8 +1427,9 @@ def start_audio() -> Response:
|
|||||||
audio_source = 'waterfall'
|
audio_source = 'waterfall'
|
||||||
# Shared monitor uses the waterfall's existing SDR claim.
|
# Shared monitor uses the waterfall's existing SDR claim.
|
||||||
if receiver_active_device is not None:
|
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_device = None
|
||||||
|
receiver_active_sdr_type = 'rtlsdr'
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
@@ -1443,13 +1452,14 @@ def start_audio() -> Response:
|
|||||||
# to give the USB device time to be fully released.
|
# to give the USB device time to be fully released.
|
||||||
if receiver_active_device is None or receiver_active_device != device:
|
if receiver_active_device is None or receiver_active_device != device:
|
||||||
if receiver_active_device is not None:
|
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_device = None
|
||||||
|
receiver_active_sdr_type = 'rtlsdr'
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
max_claim_attempts = 6
|
max_claim_attempts = 6
|
||||||
for attempt in range(max_claim_attempts):
|
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:
|
if not error:
|
||||||
break
|
break
|
||||||
if attempt < max_claim_attempts - 1:
|
if attempt < max_claim_attempts - 1:
|
||||||
@@ -1466,6 +1476,7 @@ def start_audio() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
receiver_active_device = device
|
receiver_active_device = device
|
||||||
|
receiver_active_sdr_type = sdr_type
|
||||||
|
|
||||||
_start_audio_stream(
|
_start_audio_stream(
|
||||||
frequency,
|
frequency,
|
||||||
@@ -1489,8 +1500,9 @@ def start_audio() -> Response:
|
|||||||
|
|
||||||
# Avoid leaving a stale device claim after startup failure.
|
# Avoid leaving a stale device claim after startup failure.
|
||||||
if receiver_active_device is not None:
|
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_device = None
|
||||||
|
receiver_active_sdr_type = 'rtlsdr'
|
||||||
|
|
||||||
start_error = ''
|
start_error = ''
|
||||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
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'])
|
@receiver_bp.route('/audio/stop', methods=['POST'])
|
||||||
def stop_audio() -> Response:
|
def stop_audio() -> Response:
|
||||||
"""Stop audio."""
|
"""Stop audio."""
|
||||||
global receiver_active_device
|
global receiver_active_device, receiver_active_sdr_type
|
||||||
_stop_audio_stream()
|
_stop_audio_stream()
|
||||||
if receiver_active_device is not None:
|
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_device = None
|
||||||
|
receiver_active_sdr_type = 'rtlsdr'
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -1825,6 +1838,7 @@ waterfall_running = False
|
|||||||
waterfall_lock = threading.Lock()
|
waterfall_lock = threading.Lock()
|
||||||
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||||
waterfall_active_device: Optional[int] = None
|
waterfall_active_device: Optional[int] = None
|
||||||
|
waterfall_active_sdr_type: str = 'rtlsdr'
|
||||||
waterfall_config = {
|
waterfall_config = {
|
||||||
'start_freq': 88.0,
|
'start_freq': 88.0,
|
||||||
'end_freq': 108.0,
|
'end_freq': 108.0,
|
||||||
@@ -2033,7 +2047,7 @@ def _waterfall_loop():
|
|||||||
|
|
||||||
def _stop_waterfall_internal() -> None:
|
def _stop_waterfall_internal() -> None:
|
||||||
"""Stop the waterfall display and release resources."""
|
"""Stop the waterfall display and release resources."""
|
||||||
global waterfall_running, waterfall_process, waterfall_active_device
|
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
|
||||||
|
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
@@ -2048,14 +2062,15 @@ def _stop_waterfall_internal() -> None:
|
|||||||
waterfall_process = None
|
waterfall_process = None
|
||||||
|
|
||||||
if waterfall_active_device is not None:
|
if waterfall_active_device is not None:
|
||||||
app_module.release_sdr_device(waterfall_active_device)
|
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type)
|
||||||
waterfall_active_device = None
|
waterfall_active_device = None
|
||||||
|
waterfall_active_sdr_type = 'rtlsdr'
|
||||||
|
|
||||||
|
|
||||||
@receiver_bp.route('/waterfall/start', methods=['POST'])
|
@receiver_bp.route('/waterfall/start', methods=['POST'])
|
||||||
def start_waterfall() -> Response:
|
def start_waterfall() -> Response:
|
||||||
"""Start the waterfall/spectrogram display."""
|
"""Start the waterfall/spectrogram display."""
|
||||||
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
|
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device, waterfall_active_sdr_type
|
||||||
|
|
||||||
with waterfall_lock:
|
with waterfall_lock:
|
||||||
if waterfall_running:
|
if waterfall_running:
|
||||||
@@ -2101,11 +2116,12 @@ def start_waterfall() -> Response:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Claim SDR device
|
# Claim SDR device
|
||||||
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall')
|
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr')
|
||||||
if error:
|
if error:
|
||||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||||
|
|
||||||
waterfall_active_device = waterfall_config['device']
|
waterfall_active_device = waterfall_config['device']
|
||||||
|
waterfall_active_sdr_type = 'rtlsdr'
|
||||||
waterfall_running = True
|
waterfall_running = True
|
||||||
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
|
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
|
||||||
waterfall_thread.start()
|
waterfall_thread.start()
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class _FilteredQueue:
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
morse_active_device: int | None = None
|
morse_active_device: int | None = None
|
||||||
|
morse_active_sdr_type: str | None = None
|
||||||
|
|
||||||
# Runtime lifecycle state.
|
# Runtime lifecycle state.
|
||||||
MORSE_IDLE = 'idle'
|
MORSE_IDLE = 'idle'
|
||||||
@@ -231,7 +232,7 @@ def _snapshot_live_resources() -> list[str]:
|
|||||||
|
|
||||||
@morse_bp.route('/morse/start', methods=['POST'])
|
@morse_bp.route('/morse/start', methods=['POST'])
|
||||||
def start_morse() -> Response:
|
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_stop_event, morse_control_queue, morse_runtime_config
|
||||||
global morse_last_error, morse_session_id
|
global morse_last_error, morse_session_id
|
||||||
|
|
||||||
@@ -261,6 +262,8 @@ def start_morse() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
with app_module.morse_lock:
|
with app_module.morse_lock:
|
||||||
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
|
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -270,7 +273,7 @@ def start_morse() -> Response:
|
|||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
device_int = int(device)
|
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:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -279,6 +282,7 @@ def start_morse() -> Response:
|
|||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
morse_active_device = device_int
|
morse_active_device = device_int
|
||||||
|
morse_active_sdr_type = sdr_type_str
|
||||||
morse_last_error = ''
|
morse_last_error = ''
|
||||||
morse_session_id += 1
|
morse_session_id += 1
|
||||||
|
|
||||||
@@ -288,7 +292,6 @@ def start_morse() -> Response:
|
|||||||
sample_rate = 22050
|
sample_rate = 22050
|
||||||
bias_t = _bool_value(data.get('bias_t', False), False)
|
bias_t = _bool_value(data.get('bias_t', False), False)
|
||||||
|
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -408,7 +411,7 @@ def start_morse() -> Response:
|
|||||||
for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1):
|
for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1):
|
||||||
if candidate_device_index != active_device_index:
|
if candidate_device_index != active_device_index:
|
||||||
prev_device = 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:
|
if claim_error:
|
||||||
msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}'
|
msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}'
|
||||||
attempt_errors.append(msg)
|
attempt_errors.append(msg)
|
||||||
@@ -417,7 +420,7 @@ def start_morse() -> Response:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if prev_device is not None:
|
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
|
active_device_index = candidate_device_index
|
||||||
with app_module.morse_lock:
|
with app_module.morse_lock:
|
||||||
morse_active_device = active_device_index
|
morse_active_device = active_device_index
|
||||||
@@ -634,8 +637,9 @@ def start_morse() -> Response:
|
|||||||
logger.error('Morse startup failed: %s', msg)
|
logger.error('Morse startup failed: %s', msg)
|
||||||
with app_module.morse_lock:
|
with app_module.morse_lock:
|
||||||
if morse_active_device is not None:
|
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_device = None
|
||||||
|
morse_active_sdr_type = None
|
||||||
morse_last_error = msg
|
morse_last_error = msg
|
||||||
_set_state(MORSE_ERROR, msg)
|
_set_state(MORSE_ERROR, msg)
|
||||||
_set_state(MORSE_IDLE, 'Idle')
|
_set_state(MORSE_IDLE, 'Idle')
|
||||||
@@ -675,8 +679,9 @@ def start_morse() -> Response:
|
|||||||
)
|
)
|
||||||
with app_module.morse_lock:
|
with app_module.morse_lock:
|
||||||
if morse_active_device is not None:
|
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_device = None
|
||||||
|
morse_active_sdr_type = None
|
||||||
morse_last_error = f'Tool not found: {e.filename}'
|
morse_last_error = f'Tool not found: {e.filename}'
|
||||||
_set_state(MORSE_ERROR, morse_last_error)
|
_set_state(MORSE_ERROR, morse_last_error)
|
||||||
_set_state(MORSE_IDLE, 'Idle')
|
_set_state(MORSE_IDLE, 'Idle')
|
||||||
@@ -692,8 +697,9 @@ def start_morse() -> Response:
|
|||||||
)
|
)
|
||||||
with app_module.morse_lock:
|
with app_module.morse_lock:
|
||||||
if morse_active_device is not None:
|
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_device = None
|
||||||
|
morse_active_sdr_type = None
|
||||||
morse_last_error = str(e)
|
morse_last_error = str(e)
|
||||||
_set_state(MORSE_ERROR, morse_last_error)
|
_set_state(MORSE_ERROR, morse_last_error)
|
||||||
_set_state(MORSE_IDLE, 'Idle')
|
_set_state(MORSE_IDLE, 'Idle')
|
||||||
@@ -702,7 +708,7 @@ def start_morse() -> Response:
|
|||||||
|
|
||||||
@morse_bp.route('/morse/stop', methods=['POST'])
|
@morse_bp.route('/morse/stop', methods=['POST'])
|
||||||
def stop_morse() -> Response:
|
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
|
global morse_stop_event, morse_control_queue
|
||||||
|
|
||||||
stop_started = time.perf_counter()
|
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)
|
stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None)
|
||||||
control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None)
|
control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None)
|
||||||
active_device = morse_active_device
|
active_device = morse_active_device
|
||||||
|
active_sdr_type = morse_active_sdr_type
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not rtl_proc
|
not rtl_proc
|
||||||
@@ -768,7 +775,7 @@ def stop_morse() -> Response:
|
|||||||
_mark(f'stderr thread joined={stderr_joined}')
|
_mark(f'stderr thread joined={stderr_joined}')
|
||||||
|
|
||||||
if active_device is not None:
|
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')
|
_mark(f'SDR device {active_device} released')
|
||||||
|
|
||||||
stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1)
|
stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1)
|
||||||
@@ -782,6 +789,7 @@ def stop_morse() -> Response:
|
|||||||
|
|
||||||
with app_module.morse_lock:
|
with app_module.morse_lock:
|
||||||
morse_active_device = None
|
morse_active_device = None
|
||||||
|
morse_active_sdr_type = None
|
||||||
_set_state(MORSE_IDLE, 'Stopped', extra={
|
_set_state(MORSE_IDLE, 'Stopped', extra={
|
||||||
'stop_ms': stop_ms,
|
'stop_ms': stop_ms,
|
||||||
'cleanup_steps': cleanup_steps,
|
'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_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
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.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
@@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__)
|
|||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
pager_active_device: int | None = None
|
pager_active_device: int | None = None
|
||||||
|
pager_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||||
@@ -96,7 +97,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
|||||||
return 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."""
|
"""Log a message to file if logging is enabled."""
|
||||||
if not app_module.logging_enabled:
|
if not app_module.logging_enabled:
|
||||||
return
|
return
|
||||||
@@ -104,39 +105,39 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
with open(app_module.log_file_path, 'a') as f:
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
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")
|
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
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."""
|
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||||
if not samples:
|
if not samples:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||||
waveform: list[int] = []
|
waveform: list[int] = []
|
||||||
for sample in window:
|
for sample in window:
|
||||||
# Convert int16 PCM to int8 range for lightweight transport.
|
# Convert int16 PCM to int8 range for lightweight transport.
|
||||||
packed = int(round(sample / 256))
|
packed = int(round(sample / 256))
|
||||||
waveform.append(max(-127, min(127, packed)))
|
waveform.append(max(-127, min(127, packed)))
|
||||||
return waveform
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
def audio_relay_thread(
|
def audio_relay_thread(
|
||||||
rtl_stdout,
|
rtl_stdout,
|
||||||
multimon_stdin,
|
multimon_stdin,
|
||||||
output_queue: queue.Queue,
|
output_queue: queue.Queue,
|
||||||
stop_event: threading.Event,
|
stop_event: threading.Event,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
"""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
|
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
|
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||||
event plus a compact waveform sample onto *output_queue*.
|
event plus a compact waveform sample onto *output_queue*.
|
||||||
"""
|
"""
|
||||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||||
INTERVAL = 0.1 # seconds between scope updates
|
INTERVAL = 0.1 # seconds between scope updates
|
||||||
last_scope = time.monotonic()
|
last_scope = time.monotonic()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
@@ -160,16 +161,16 @@ def audio_relay_thread(
|
|||||||
if n_samples == 0:
|
if n_samples == 0:
|
||||||
continue
|
continue
|
||||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||||
peak = max(abs(s) for s in samples)
|
peak = max(abs(s) for s in samples)
|
||||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||||
output_queue.put_nowait({
|
output_queue.put_nowait({
|
||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'rms': rms,
|
'rms': rms,
|
||||||
'peak': peak,
|
'peak': peak,
|
||||||
'waveform': _encode_scope_waveform(samples),
|
'waveform': _encode_scope_waveform(samples),
|
||||||
})
|
})
|
||||||
except (struct.error, ValueError, queue.Full):
|
except (struct.error, ValueError, queue.Full):
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Audio relay error: {e}")
|
logger.debug(f"Audio relay error: {e}")
|
||||||
finally:
|
finally:
|
||||||
@@ -220,7 +221,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global pager_active_device
|
global pager_active_device, pager_active_sdr_type
|
||||||
try:
|
try:
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -249,13 +250,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
app_module.current_process = None
|
app_module.current_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/start', methods=['POST'])
|
@pager_bp.route('/start', methods=['POST'])
|
||||||
def start_decoding() -> Response:
|
def start_decoding() -> Response:
|
||||||
global pager_active_device
|
global pager_active_device, pager_active_sdr_type
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
@@ -284,10 +286,13 @@ def start_decoding() -> Response:
|
|||||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
# 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
|
# Claim local device if not using remote rtl_tcp
|
||||||
if not rtl_tcp_host:
|
if not rtl_tcp_host:
|
||||||
device_int = int(device)
|
device_int = int(device)
|
||||||
error = app_module.claim_sdr_device(device_int, 'pager')
|
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -295,14 +300,16 @@ def start_decoding() -> Response:
|
|||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
pager_active_device = device_int
|
pager_active_device = device_int
|
||||||
|
pager_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Validate protocols
|
# Validate protocols
|
||||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||||
protocols = data.get('protocols', valid_protocols)
|
protocols = data.get('protocols', valid_protocols)
|
||||||
if not isinstance(protocols, list):
|
if not isinstance(protocols, list):
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||||
protocols = [p for p in protocols if p in valid_protocols]
|
protocols = [p for p in protocols if p in valid_protocols]
|
||||||
if not protocols:
|
if not protocols:
|
||||||
@@ -327,8 +334,7 @@ def start_decoding() -> Response:
|
|||||||
elif proto == 'FLEX':
|
elif proto == 'FLEX':
|
||||||
decoders.extend(['-a', 'FLEX'])
|
decoders.extend(['-a', 'FLEX'])
|
||||||
|
|
||||||
# Get SDR type and build command via abstraction layer
|
# Build command via SDR abstraction layer
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -443,8 +449,9 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Kill orphaned rtl_fm process if it was started
|
# Kill orphaned rtl_fm process if it was started
|
||||||
@@ -458,14 +465,15 @@ def start_decoding() -> Response:
|
|||||||
pass
|
pass
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stop', methods=['POST'])
|
@pager_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoding() -> Response:
|
def stop_decoding() -> Response:
|
||||||
global pager_active_device
|
global pager_active_device, pager_active_sdr_type
|
||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
@@ -502,8 +510,9 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if pager_active_device is not None:
|
if pager_active_device is not None:
|
||||||
app_module.release_sdr_device(pager_active_device)
|
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||||
pager_active_device = None
|
pager_active_device = None
|
||||||
|
pager_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -553,22 +562,22 @@ def toggle_logging() -> Response:
|
|||||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stream')
|
@pager_bp.route('/stream')
|
||||||
def stream() -> Response:
|
def stream() -> Response:
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
process_event('pager', msg, msg.get('type'))
|
process_event('pager', msg, msg.get('type'))
|
||||||
|
|
||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=app_module.output_queue,
|
source_queue=app_module.output_queue,
|
||||||
channel_key='pager',
|
channel_key='pager',
|
||||||
timeout=1.0,
|
timeout=1.0,
|
||||||
keepalive_interval=30.0,
|
keepalive_interval=30.0,
|
||||||
on_message=_on_msg,
|
on_message=_on_msg,
|
||||||
),
|
),
|
||||||
mimetype='text/event-stream',
|
mimetype='text/event-stream',
|
||||||
)
|
)
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -10,25 +10,26 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Generator
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
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.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
|
||||||
sensor_bp = Blueprint('sensor', __name__)
|
sensor_bp = Blueprint('sensor', __name__)
|
||||||
|
|
||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
sensor_active_device: int | None = None
|
sensor_active_device: int | None = None
|
||||||
|
sensor_active_sdr_type: str | None = None
|
||||||
|
|
||||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||||
_MAX_RSSI_HISTORY = 60
|
_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:
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtl_433 JSON output to queue."""
|
"""Stream rtl_433 JSON output to queue."""
|
||||||
try:
|
try:
|
||||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||||
|
|
||||||
for line in iter(process.stdout.readline, b''):
|
for line in iter(process.stdout.readline, b''):
|
||||||
line = line.decode('utf-8', errors='replace').strip()
|
line = line.decode('utf-8', errors='replace').strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# rtl_433 outputs JSON objects, one per line
|
# rtl_433 outputs JSON objects, one per line
|
||||||
data = json.loads(line)
|
data = json.loads(line)
|
||||||
data['type'] = 'sensor'
|
data['type'] = 'sensor'
|
||||||
app_module.sensor_queue.put(data)
|
app_module.sensor_queue.put(data)
|
||||||
|
|
||||||
# Track RSSI history per device
|
# Track RSSI history per device
|
||||||
_model = data.get('model', '')
|
_model = data.get('model', '')
|
||||||
_dev_id = data.get('id', '')
|
_dev_id = data.get('id', '')
|
||||||
_rssi_val = data.get('rssi')
|
_rssi_val = data.get('rssi')
|
||||||
if _rssi_val is not None and _model:
|
if _rssi_val is not None and _model:
|
||||||
_hist_key = f"{_model}_{_dev_id}"
|
_hist_key = f"{_model}_{_dev_id}"
|
||||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||||
hist.append((time.time(), float(_rssi_val)))
|
hist.append((time.time(), float(_rssi_val)))
|
||||||
if len(hist) > _MAX_RSSI_HISTORY:
|
if len(hist) > _MAX_RSSI_HISTORY:
|
||||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||||
|
|
||||||
# Push scope event when signal level data is present
|
# Push scope event when signal level data is present
|
||||||
rssi = data.get('rssi')
|
rssi = data.get('rssi')
|
||||||
snr = data.get('snr')
|
snr = data.get('snr')
|
||||||
noise = data.get('noise')
|
noise = data.get('noise')
|
||||||
if rssi is not None or snr is not None:
|
if rssi is not None or snr is not None:
|
||||||
try:
|
try:
|
||||||
rssi_value = float(rssi) if rssi is not None else 0.0
|
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):
|
except (TypeError, ValueError, queue.Full):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
with open(app_module.log_file_path, 'a') as f:
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
# Not JSON, send as raw
|
# Not JSON, send as raw
|
||||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global sensor_active_device
|
global sensor_active_device, sensor_active_sdr_type
|
||||||
# Ensure process is terminated
|
# Ensure process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
process.kill()
|
process.kill()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||||
with app_module.sensor_lock:
|
with app_module.sensor_lock:
|
||||||
app_module.sensor_process = None
|
app_module.sensor_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if sensor_active_device is not None:
|
if sensor_active_device is not None:
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
|
sensor_active_sdr_type = None
|
||||||
|
|
||||||
@sensor_bp.route('/sensor/status')
|
|
||||||
def sensor_status() -> Response:
|
@sensor_bp.route('/sensor/status')
|
||||||
"""Check if sensor decoder is currently running."""
|
def sensor_status() -> Response:
|
||||||
with app_module.sensor_lock:
|
"""Check if sensor decoder is currently running."""
|
||||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
with app_module.sensor_lock:
|
||||||
return jsonify({'running': running})
|
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:
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
global sensor_active_device
|
def start_sensor() -> Response:
|
||||||
|
global sensor_active_device, sensor_active_sdr_type
|
||||||
with app_module.sensor_lock:
|
|
||||||
if app_module.sensor_process:
|
with app_module.sensor_lock:
|
||||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
if app_module.sensor_process:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||||
data = request.json or {}
|
|
||||||
|
data = request.json or {}
|
||||||
# Validate inputs
|
|
||||||
try:
|
# Validate inputs
|
||||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
try:
|
||||||
gain = validate_gain(data.get('gain', '0'))
|
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
gain = validate_gain(data.get('gain', '0'))
|
||||||
device = validate_device_index(data.get('device', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
except ValueError as e:
|
device = validate_device_index(data.get('device', '0'))
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
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')
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
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:
|
# Get SDR type early so we can pass it to claim/release
|
||||||
device_int = int(device)
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
error = app_module.claim_sdr_device(device_int, 'sensor')
|
|
||||||
if error:
|
# Claim local device if not using remote rtl_tcp
|
||||||
return jsonify({
|
if not rtl_tcp_host:
|
||||||
'status': 'error',
|
device_int = int(device)
|
||||||
'error_type': 'DEVICE_BUSY',
|
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
||||||
'message': error
|
if error:
|
||||||
}), 409
|
return jsonify({
|
||||||
sensor_active_device = device_int
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
# Clear queue
|
'message': error
|
||||||
while not app_module.sensor_queue.empty():
|
}), 409
|
||||||
try:
|
sensor_active_device = device_int
|
||||||
app_module.sensor_queue.get_nowait()
|
sensor_active_sdr_type = sdr_type_str
|
||||||
except queue.Empty:
|
|
||||||
break
|
# Clear queue
|
||||||
|
while not app_module.sensor_queue.empty():
|
||||||
# Get SDR type and build command via abstraction layer
|
try:
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
app_module.sensor_queue.get_nowait()
|
||||||
try:
|
except queue.Empty:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
break
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
# Build command via SDR abstraction layer
|
||||||
|
try:
|
||||||
if rtl_tcp_host:
|
sdr_type = SDRType(sdr_type_str)
|
||||||
# Validate and create network device
|
except ValueError:
|
||||||
try:
|
sdr_type = SDRType.RTL_SDR
|
||||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
|
||||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
if rtl_tcp_host:
|
||||||
except ValueError as e:
|
# Validate and create network device
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
try:
|
||||||
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
except ValueError as e:
|
||||||
else:
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
# Create local device object
|
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
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}")
|
||||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
else:
|
||||||
|
# Create local device object
|
||||||
# Build ISM band decoder command
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
bias_t = data.get('bias_t', False)
|
|
||||||
cmd = builder.build_ism_command(
|
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||||
device=sdr_device,
|
|
||||||
frequency_mhz=freq,
|
# Build ISM band decoder command
|
||||||
gain=float(gain) if gain and gain != 0 else None,
|
bias_t = data.get('bias_t', False)
|
||||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
cmd = builder.build_ism_command(
|
||||||
bias_t=bias_t
|
device=sdr_device,
|
||||||
)
|
frequency_mhz=freq,
|
||||||
|
gain=float(gain) if gain and gain != 0 else None,
|
||||||
full_cmd = ' '.join(cmd)
|
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||||
logger.info(f"Running: {full_cmd}")
|
bias_t=bias_t
|
||||||
|
)
|
||||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
|
||||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
full_cmd = ' '.join(cmd)
|
||||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
logger.info(f"Running: {full_cmd}")
|
||||||
|
|
||||||
try:
|
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||||
app_module.sensor_process = subprocess.Popen(
|
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||||
cmd,
|
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE
|
try:
|
||||||
)
|
app_module.sensor_process = subprocess.Popen(
|
||||||
register_process(app_module.sensor_process)
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
# Start output thread
|
stderr=subprocess.PIPE
|
||||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
)
|
||||||
thread.daemon = True
|
register_process(app_module.sensor_process)
|
||||||
thread.start()
|
|
||||||
|
# Start output thread
|
||||||
# Monitor stderr
|
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
thread.daemon = True
|
||||||
_stderr_noise = (
|
thread.start()
|
||||||
'bitbuffer_add_bit',
|
|
||||||
'row count limit',
|
# Monitor stderr
|
||||||
)
|
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||||
|
_stderr_noise = (
|
||||||
def monitor_stderr():
|
'bitbuffer_add_bit',
|
||||||
for line in app_module.sensor_process.stderr:
|
'row count limit',
|
||||||
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}")
|
def monitor_stderr():
|
||||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
for line in app_module.sensor_process.stderr:
|
||||||
|
err = line.decode('utf-8', errors='replace').strip()
|
||||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
if err and not any(noise in err for noise in _stderr_noise):
|
||||||
stderr_thread.daemon = True
|
logger.debug(f"[rtl_433] {err}")
|
||||||
stderr_thread.start()
|
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||||
|
|
||||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||||
|
stderr_thread.daemon = True
|
||||||
return jsonify({'status': 'started', 'command': full_cmd})
|
stderr_thread.start()
|
||||||
|
|
||||||
except FileNotFoundError:
|
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||||
# Release device on failure
|
|
||||||
if sensor_active_device is not None:
|
return jsonify({'status': 'started', 'command': full_cmd})
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
|
||||||
sensor_active_device = None
|
except FileNotFoundError:
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
# Release device on failure
|
||||||
except Exception as e:
|
if sensor_active_device is not None:
|
||||||
# Release device on failure
|
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||||
if sensor_active_device is not None:
|
sensor_active_device = None
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
sensor_active_sdr_type = None
|
||||||
sensor_active_device = None
|
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if sensor_active_device is not None:
|
||||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||||
def stop_sensor() -> Response:
|
sensor_active_device = None
|
||||||
global sensor_active_device
|
sensor_active_sdr_type = None
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)})
|
||||||
with app_module.sensor_lock:
|
|
||||||
if app_module.sensor_process:
|
|
||||||
app_module.sensor_process.terminate()
|
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||||
try:
|
def stop_sensor() -> Response:
|
||||||
app_module.sensor_process.wait(timeout=2)
|
global sensor_active_device, sensor_active_sdr_type
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
app_module.sensor_process.kill()
|
with app_module.sensor_lock:
|
||||||
app_module.sensor_process = None
|
if app_module.sensor_process:
|
||||||
|
app_module.sensor_process.terminate()
|
||||||
# Release device from registry
|
try:
|
||||||
if sensor_active_device is not None:
|
app_module.sensor_process.wait(timeout=2)
|
||||||
app_module.release_sdr_device(sensor_active_device)
|
except subprocess.TimeoutExpired:
|
||||||
sensor_active_device = None
|
app_module.sensor_process.kill()
|
||||||
|
app_module.sensor_process = None
|
||||||
return jsonify({'status': 'stopped'})
|
|
||||||
|
# Release device from registry
|
||||||
return jsonify({'status': 'not_running'})
|
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')
|
@sensor_bp.route('/stream_sensor')
|
||||||
def stream_sensor() -> Response:
|
def stream_sensor() -> Response:
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
@@ -330,12 +338,12 @@ def stream_sensor() -> Response:
|
|||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/sensor/rssi_history')
|
@sensor_bp.route('/sensor/rssi_history')
|
||||||
def get_rssi_history() -> Response:
|
def get_rssi_history() -> Response:
|
||||||
"""Return RSSI history for all tracked sensor devices."""
|
"""Return RSSI history for all tracked sensor devices."""
|
||||||
result = {}
|
result = {}
|
||||||
for key, entries in sensor_rssi_history.items():
|
for key, entries in sensor_rssi_history.items():
|
||||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||||
return jsonify({'status': 'success', 'devices': result})
|
return jsonify({'status': 'success', 'devices': result})
|
||||||
|
|||||||
270
routes/system.py
270
routes/system.py
@@ -1,7 +1,8 @@
|
|||||||
"""System Health monitoring blueprint.
|
"""System Health monitoring blueprint.
|
||||||
|
|
||||||
Provides real-time system metrics (CPU, memory, disk, temperatures),
|
Provides real-time system metrics (CPU, memory, disk, temperatures,
|
||||||
active process status, and SDR device enumeration via SSE streaming.
|
network, battery, fans), active process status, SDR device enumeration,
|
||||||
|
location, and weather data via SSE streaming and REST endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,11 +12,13 @@ import os
|
|||||||
import platform
|
import platform
|
||||||
import queue
|
import queue
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
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.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
@@ -29,6 +32,11 @@ except ImportError:
|
|||||||
psutil = None # type: ignore[assignment]
|
psutil = None # type: ignore[assignment]
|
||||||
_HAS_PSUTIL = False
|
_HAS_PSUTIL = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests as _requests
|
||||||
|
except ImportError:
|
||||||
|
_requests = None # type: ignore[assignment]
|
||||||
|
|
||||||
system_bp = Blueprint('system', __name__, url_prefix='/system')
|
system_bp = Blueprint('system', __name__, url_prefix='/system')
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -40,6 +48,11 @@ _collector_started = False
|
|||||||
_collector_lock = threading.Lock()
|
_collector_lock = threading.Lock()
|
||||||
_app_start_time: float | None = None
|
_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:
|
def _get_app_start_time() -> float:
|
||||||
"""Return the application start timestamp from the main app module."""
|
"""Return the application start timestamp from the main app module."""
|
||||||
@@ -138,6 +151,38 @@ def _collect_process_status() -> dict[str, bool]:
|
|||||||
return {}
|
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]:
|
def _collect_metrics() -> dict[str, Any]:
|
||||||
"""Gather a snapshot of system metrics."""
|
"""Gather a snapshot of system metrics."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -159,7 +204,7 @@ def _collect_metrics() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _HAS_PSUTIL:
|
if _HAS_PSUTIL:
|
||||||
# CPU
|
# CPU — overall + per-core + frequency
|
||||||
cpu_percent = psutil.cpu_percent(interval=None)
|
cpu_percent = psutil.cpu_percent(interval=None)
|
||||||
cpu_count = psutil.cpu_count() or 1
|
cpu_count = psutil.cpu_count() or 1
|
||||||
try:
|
try:
|
||||||
@@ -167,12 +212,28 @@ def _collect_metrics() -> dict[str, Any]:
|
|||||||
except (OSError, AttributeError):
|
except (OSError, AttributeError):
|
||||||
load_1 = load_5 = load_15 = 0.0
|
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'] = {
|
metrics['cpu'] = {
|
||||||
'percent': cpu_percent,
|
'percent': cpu_percent,
|
||||||
'count': cpu_count,
|
'count': cpu_count,
|
||||||
'load_1': round(load_1, 2),
|
'load_1': round(load_1, 2),
|
||||||
'load_5': round(load_5, 2),
|
'load_5': round(load_5, 2),
|
||||||
'load_15': round(load_15, 2),
|
'load_15': round(load_15, 2),
|
||||||
|
'per_core': per_core,
|
||||||
|
'freq': freq_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Memory
|
# Memory
|
||||||
@@ -191,7 +252,7 @@ def _collect_metrics() -> dict[str, Any]:
|
|||||||
'percent': swap.percent,
|
'percent': swap.percent,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Disk
|
# Disk — usage + I/O counters
|
||||||
try:
|
try:
|
||||||
disk = psutil.disk_usage('/')
|
disk = psutil.disk_usage('/')
|
||||||
metrics['disk'] = {
|
metrics['disk'] = {
|
||||||
@@ -204,6 +265,18 @@ def _collect_metrics() -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
metrics['disk'] = None
|
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
|
# Temperatures
|
||||||
try:
|
try:
|
||||||
temps = psutil.sensors_temperatures()
|
temps = psutil.sensors_temperatures()
|
||||||
@@ -224,12 +297,102 @@ def _collect_metrics() -> dict[str, Any]:
|
|||||||
metrics['temperatures'] = None
|
metrics['temperatures'] = None
|
||||||
except (AttributeError, Exception):
|
except (AttributeError, Exception):
|
||||||
metrics['temperatures'] = None
|
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:
|
else:
|
||||||
metrics['cpu'] = None
|
metrics['cpu'] = None
|
||||||
metrics['memory'] = None
|
metrics['memory'] = None
|
||||||
metrics['swap'] = None
|
metrics['swap'] = None
|
||||||
metrics['disk'] = None
|
metrics['disk'] = None
|
||||||
|
metrics['disk_io'] = None
|
||||||
metrics['temperatures'] = None
|
metrics['temperatures'] = None
|
||||||
|
metrics['fans'] = None
|
||||||
|
metrics['battery'] = None
|
||||||
|
metrics['network'] = None
|
||||||
|
metrics['boot_time'] = None
|
||||||
|
metrics['power'] = None
|
||||||
|
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
@@ -270,6 +433,47 @@ def _ensure_collector() -> None:
|
|||||||
logger.info('System metrics collector started')
|
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
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -321,3 +525,59 @@ def get_sdr_devices() -> Response:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning('SDR device detection failed: %s', exc)
|
logger.warning('SDR device detection failed: %s', exc)
|
||||||
return jsonify({'devices': [], 'error': str(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
|
# Track which device is being used
|
||||||
vdl2_active_device: int | None = None
|
vdl2_active_device: int | None = None
|
||||||
|
vdl2_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def find_dumpvdl2():
|
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}")
|
logger.error(f"VDL2 stream error: {e}")
|
||||||
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||||
finally:
|
finally:
|
||||||
global vdl2_active_device
|
global vdl2_active_device, vdl2_active_sdr_type
|
||||||
# Ensure process is terminated
|
# Ensure process is terminated
|
||||||
try:
|
try:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
@@ -142,8 +143,9 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
|||||||
app_module.vdl2_process = None
|
app_module.vdl2_process = None
|
||||||
# Release SDR device
|
# Release SDR device
|
||||||
if vdl2_active_device is not None:
|
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_device = None
|
||||||
|
vdl2_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/tools')
|
@vdl2_bp.route('/tools')
|
||||||
@@ -175,7 +177,7 @@ def vdl2_status() -> Response:
|
|||||||
@vdl2_bp.route('/start', methods=['POST'])
|
@vdl2_bp.route('/start', methods=['POST'])
|
||||||
def start_vdl2() -> Response:
|
def start_vdl2() -> Response:
|
||||||
"""Start VDL2 decoder."""
|
"""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:
|
with app_module.vdl2_lock:
|
||||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
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:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
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
|
# Check if device is available
|
||||||
device_int = int(device)
|
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:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -213,6 +222,7 @@ def start_vdl2() -> Response:
|
|||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
vdl2_active_device = device_int
|
vdl2_active_device = device_int
|
||||||
|
vdl2_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Get frequencies - use provided or defaults
|
# Get frequencies - use provided or defaults
|
||||||
# dumpvdl2 expects frequencies in Hz (integers)
|
# dumpvdl2 expects frequencies in Hz (integers)
|
||||||
@@ -231,13 +241,6 @@ def start_vdl2() -> Response:
|
|||||||
vdl2_message_count = 0
|
vdl2_message_count = 0
|
||||||
vdl2_last_message_time = None
|
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,)
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
# Build dumpvdl2 command
|
# Build dumpvdl2 command
|
||||||
@@ -297,8 +300,9 @@ def start_vdl2() -> Response:
|
|||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
# Process died - release device
|
# Process died - release device
|
||||||
if vdl2_active_device is not None:
|
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_device = None
|
||||||
|
vdl2_active_sdr_type = None
|
||||||
stderr = ''
|
stderr = ''
|
||||||
if process.stderr:
|
if process.stderr:
|
||||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
@@ -329,8 +333,9 @@ def start_vdl2() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
if vdl2_active_device is not None:
|
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_device = None
|
||||||
|
vdl2_active_sdr_type = None
|
||||||
logger.error(f"Failed to start VDL2 decoder: {e}")
|
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
@@ -338,7 +343,7 @@ def start_vdl2() -> Response:
|
|||||||
@vdl2_bp.route('/stop', methods=['POST'])
|
@vdl2_bp.route('/stop', methods=['POST'])
|
||||||
def stop_vdl2() -> Response:
|
def stop_vdl2() -> Response:
|
||||||
"""Stop VDL2 decoder."""
|
"""Stop VDL2 decoder."""
|
||||||
global vdl2_active_device
|
global vdl2_active_device, vdl2_active_sdr_type
|
||||||
|
|
||||||
with app_module.vdl2_lock:
|
with app_module.vdl2_lock:
|
||||||
if not app_module.vdl2_process:
|
if not app_module.vdl2_process:
|
||||||
@@ -359,8 +364,9 @@ def stop_vdl2() -> Response:
|
|||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
if vdl2_active_device is not None:
|
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_device = None
|
||||||
|
vdl2_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -386,6 +392,7 @@ def stream_vdl2() -> Response:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/messages')
|
@vdl2_bp.route('/messages')
|
||||||
def get_vdl2_messages() -> Response:
|
def get_vdl2_messages() -> Response:
|
||||||
"""Get recent VDL2 messages from correlator (for history reload)."""
|
"""Get recent VDL2 messages from correlator (for history reload)."""
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
reader_thread = None
|
reader_thread = None
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
my_generation = None # tracks which capture generation this handler owns
|
my_generation = None # tracks which capture generation this handler owns
|
||||||
capture_center_mhz = 0.0
|
capture_center_mhz = 0.0
|
||||||
capture_start_freq = 0.0
|
capture_start_freq = 0.0
|
||||||
@@ -430,8 +431,9 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
iq_process = None
|
iq_process = None
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
_set_shared_capture_state(running=False, generation=my_generation)
|
_set_shared_capture_state(running=False, generation=my_generation)
|
||||||
my_generation = None
|
my_generation = None
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
@@ -513,7 +515,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
max_claim_attempts = 4 if was_restarting else 1
|
max_claim_attempts = 4 if was_restarting else 1
|
||||||
claim_err = None
|
claim_err = None
|
||||||
for _claim_attempt in range(max_claim_attempts):
|
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:
|
if not claim_err:
|
||||||
break
|
break
|
||||||
if _claim_attempt < max_claim_attempts - 1:
|
if _claim_attempt < max_claim_attempts - 1:
|
||||||
@@ -526,6 +528,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
claimed_device = device_index
|
claimed_device = device_index
|
||||||
|
claimed_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Build I/Q capture command
|
# Build I/Q capture command
|
||||||
try:
|
try:
|
||||||
@@ -539,8 +542,9 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
bias_t=bias_t,
|
bias_t=bias_t,
|
||||||
)
|
)
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
app_module.release_sdr_device(device_index)
|
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': str(e),
|
'message': str(e),
|
||||||
@@ -549,8 +553,9 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
|
|
||||||
# Pre-flight: check the capture binary exists
|
# Pre-flight: check the capture binary exists
|
||||||
if not shutil.which(iq_cmd[0]):
|
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_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).',
|
'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)
|
safe_terminate(iq_process)
|
||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
iq_process = None
|
iq_process = None
|
||||||
app_module.release_sdr_device(device_index)
|
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'Failed to start I/Q capture: {e}',
|
'message': f'Failed to start I/Q capture: {e}',
|
||||||
@@ -806,8 +812,9 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
iq_process = None
|
iq_process = None
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
claimed_sdr_type = 'rtlsdr'
|
||||||
_set_shared_capture_state(running=False, generation=my_generation)
|
_set_shared_capture_state(running=False, generation=my_generation)
|
||||||
my_generation = None
|
my_generation = None
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
@@ -825,7 +832,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
safe_terminate(iq_process)
|
safe_terminate(iq_process)
|
||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||||
_set_shared_capture_state(running=False, generation=my_generation)
|
_set_shared_capture_state(running=False, generation=my_generation)
|
||||||
# Complete WebSocket close handshake, then shut down the
|
# Complete WebSocket close handshake, then shut down the
|
||||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ _wefax_queue: queue.Queue = queue.Queue(maxsize=100)
|
|||||||
|
|
||||||
# Track active SDR device
|
# Track active SDR device
|
||||||
wefax_active_device: int | None = None
|
wefax_active_device: int | None = None
|
||||||
|
wefax_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(data: dict) -> None:
|
def _progress_callback(data: dict) -> None:
|
||||||
"""Callback to queue progress updates for SSE stream."""
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
global wefax_active_device
|
global wefax_active_device, wefax_active_sdr_type
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_wefax_queue.put_nowait(data)
|
_wefax_queue.put_nowait(data)
|
||||||
@@ -56,8 +57,9 @@ def _progress_callback(data: dict) -> None:
|
|||||||
and data.get('status') in ('complete', 'error', 'stopped')
|
and data.get('status') in ('complete', 'error', 'stopped')
|
||||||
and wefax_active_device is not None
|
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_device = None
|
||||||
|
wefax_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
@wefax_bp.route('/status')
|
@wefax_bp.route('/status')
|
||||||
@@ -169,9 +171,9 @@ def start_decoder():
|
|||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Claim SDR device
|
# Claim SDR device
|
||||||
global wefax_active_device
|
global wefax_active_device, wefax_active_sdr_type
|
||||||
device_int = int(device_index)
|
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:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -194,6 +196,7 @@ def start_decoder():
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
wefax_active_device = device_int
|
wefax_active_device = device_int
|
||||||
|
wefax_active_sdr_type = sdr_type_str
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency_khz': frequency_khz,
|
'frequency_khz': frequency_khz,
|
||||||
@@ -209,7 +212,7 @@ def start_decoder():
|
|||||||
'device': device_int,
|
'device': device_int,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
app_module.release_sdr_device(device_int)
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start decoder',
|
'message': 'Failed to start decoder',
|
||||||
@@ -219,13 +222,14 @@ def start_decoder():
|
|||||||
@wefax_bp.route('/stop', methods=['POST'])
|
@wefax_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoder():
|
def stop_decoder():
|
||||||
"""Stop WeFax decoder."""
|
"""Stop WeFax decoder."""
|
||||||
global wefax_active_device
|
global wefax_active_device, wefax_active_sdr_type
|
||||||
decoder = get_wefax_decoder()
|
decoder = get_wefax_decoder()
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
if wefax_active_device is not None:
|
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_device = None
|
||||||
|
wefax_active_sdr_type = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|||||||
80
setup.sh
80
setup.sh
@@ -229,6 +229,7 @@ check_tools() {
|
|||||||
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||||
|
check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py
|
||||||
echo
|
echo
|
||||||
info "GPS:"
|
info "GPS:"
|
||||||
check_required "gpsd" "GPS daemon" gpsd
|
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() {
|
install_macos_packages() {
|
||||||
need_sudo
|
need_sudo
|
||||||
|
|
||||||
@@ -825,7 +873,7 @@ install_macos_packages() {
|
|||||||
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=21
|
TOTAL_STEPS=22
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -912,6 +960,20 @@ install_macos_packages() {
|
|||||||
ok "SatDump already installed"
|
ok "SatDump already installed"
|
||||||
fi
|
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"
|
progress "Installing aircrack-ng"
|
||||||
brew_install aircrack-ng
|
brew_install aircrack-ng
|
||||||
|
|
||||||
@@ -1303,7 +1365,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=27
|
TOTAL_STEPS=28
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -1485,6 +1547,20 @@ install_debian_packages() {
|
|||||||
ok "SatDump already installed"
|
ok "SatDump already installed"
|
||||||
fi
|
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"
|
progress "Configuring udev rules"
|
||||||
setup_udev_rules_debian
|
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 {
|
.sys-dashboard {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
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 {
|
.sys-card {
|
||||||
background: var(--bg-card, #1a1a2e);
|
background: var(--bg-card, #1a1a2e);
|
||||||
border: 1px solid var(--border-color, #2a2a4a);
|
border: 1px solid var(--border-color, #2a2a4a);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
min-height: 120px;
|
}
|
||||||
|
|
||||||
|
.sys-card-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-card-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sys-card-header {
|
.sys-card-header {
|
||||||
@@ -99,7 +124,285 @@
|
|||||||
font-size: 11px;
|
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 {
|
.sys-process-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -128,6 +431,12 @@
|
|||||||
background: var(--text-dim, #555);
|
background: var(--text-dim, #555);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sys-process-summary {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #8888aa);
|
||||||
|
}
|
||||||
|
|
||||||
/* SDR Devices */
|
/* SDR Devices */
|
||||||
.sys-sdr-device {
|
.sys-sdr-device {
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
@@ -154,6 +463,39 @@
|
|||||||
background: var(--bg-primary, #0d0d1a);
|
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 */
|
/* Sidebar Quick Grid */
|
||||||
.sys-quick-grid {
|
.sys-quick-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -206,10 +548,32 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
gap: 10px;
|
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) {
|
@media (max-width: 1024px) and (min-width: 769px) {
|
||||||
.sys-dashboard {
|
.sys-dashboard {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
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,
|
handleDetection,
|
||||||
invalidateMap,
|
invalidateMap,
|
||||||
fetchPairedIrks,
|
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;
|
window.BtLocate = BtLocate;
|
||||||
|
|||||||
@@ -117,13 +117,13 @@ const Meshtastic = (function() {
|
|||||||
Settings.createTileLayer().addTo(meshMap);
|
Settings.createTileLayer().addTo(meshMap);
|
||||||
Settings.registerMap(meshMap);
|
Settings.registerMap(meshMap);
|
||||||
} else {
|
} else {
|
||||||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
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>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
subdomains: 'abcd',
|
subdomains: 'abcd',
|
||||||
className: 'tile-layer-cyan'
|
className: 'tile-layer-cyan'
|
||||||
}).addTo(meshMap);
|
}).addTo(meshMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -401,10 +401,10 @@ const Meshtastic = (function() {
|
|||||||
|
|
||||||
// Position is nested in the response
|
// Position is nested in the response
|
||||||
const pos = info.position;
|
const pos = info.position;
|
||||||
if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
|
if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
|
||||||
if (posRow) posRow.style.display = 'flex';
|
if (posRow) posRow.style.display = 'flex';
|
||||||
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
|
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
|
||||||
} else {
|
} else {
|
||||||
if (posRow) posRow.style.display = 'none';
|
if (posRow) posRow.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2295,7 +2295,8 @@ const Meshtastic = (function() {
|
|||||||
// Store & Forward
|
// Store & Forward
|
||||||
showStoreForwardModal,
|
showStoreForwardModal,
|
||||||
requestStoreForward,
|
requestStoreForward,
|
||||||
closeStoreForwardModal
|
closeStoreForwardModal,
|
||||||
|
destroy
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2306,6 +2307,13 @@ const Meshtastic = (function() {
|
|||||||
setTimeout(() => meshMap.invalidateSize(), 100);
|
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)
|
// 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
|
// Public API
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
@@ -524,7 +531,8 @@ const SpyStations = (function() {
|
|||||||
showDetails,
|
showDetails,
|
||||||
closeDetails,
|
closeDetails,
|
||||||
showHelp,
|
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
|
// Public API
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
@@ -869,6 +876,7 @@ const SSTVGeneral = (function() {
|
|||||||
deleteImage,
|
deleteImage,
|
||||||
deleteAllImages,
|
deleteAllImages,
|
||||||
downloadImage,
|
downloadImage,
|
||||||
selectPreset
|
selectPreset,
|
||||||
|
destroy
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ const SSTV = (function() {
|
|||||||
let progress = 0;
|
let progress = 0;
|
||||||
let issMap = null;
|
let issMap = null;
|
||||||
let issMarker = null;
|
let issMarker = null;
|
||||||
let issTrackLine = null;
|
let issTrackLine = null;
|
||||||
let issPosition = null;
|
let issPosition = null;
|
||||||
let issUpdateInterval = null;
|
let issUpdateInterval = null;
|
||||||
let countdownInterval = null;
|
let countdownInterval = null;
|
||||||
let nextPassData = null;
|
let nextPassData = null;
|
||||||
let pendingMapInvalidate = false;
|
let pendingMapInvalidate = false;
|
||||||
|
|
||||||
// ISS frequency
|
// ISS frequency
|
||||||
const ISS_FREQ = 145.800;
|
const ISS_FREQ = 145.800;
|
||||||
@@ -38,31 +38,31 @@ const SSTV = (function() {
|
|||||||
/**
|
/**
|
||||||
* Initialize the SSTV mode
|
* Initialize the SSTV mode
|
||||||
*/
|
*/
|
||||||
function init() {
|
function init() {
|
||||||
checkStatus();
|
checkStatus();
|
||||||
loadImages();
|
loadImages();
|
||||||
loadLocationInputs();
|
loadLocationInputs();
|
||||||
loadIssSchedule();
|
loadIssSchedule();
|
||||||
initMap();
|
initMap();
|
||||||
startIssTracking();
|
startIssTracking();
|
||||||
startCountdown();
|
startCountdown();
|
||||||
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
|
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
|
||||||
setTimeout(() => invalidateMap(), 80);
|
setTimeout(() => invalidateMap(), 80);
|
||||||
setTimeout(() => invalidateMap(), 260);
|
setTimeout(() => invalidateMap(), 260);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMapContainerVisible() {
|
function isMapContainerVisible() {
|
||||||
if (!issMap || typeof issMap.getContainer !== 'function') return false;
|
if (!issMap || typeof issMap.getContainer !== 'function') return false;
|
||||||
const container = issMap.getContainer();
|
const container = issMap.getContainer();
|
||||||
if (!container) return false;
|
if (!container) return false;
|
||||||
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
|
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
|
||||||
if (container.style && container.style.display === 'none') return false;
|
if (container.style && container.style.display === 'none') return false;
|
||||||
if (typeof window.getComputedStyle === 'function') {
|
if (typeof window.getComputedStyle === 'function') {
|
||||||
const style = window.getComputedStyle(container);
|
const style = window.getComputedStyle(container);
|
||||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load location into input fields
|
* Load location into input fields
|
||||||
@@ -189,9 +189,9 @@ const SSTV = (function() {
|
|||||||
/**
|
/**
|
||||||
* Initialize Leaflet map for ISS tracking
|
* Initialize Leaflet map for ISS tracking
|
||||||
*/
|
*/
|
||||||
async function initMap() {
|
async function initMap() {
|
||||||
const mapContainer = document.getElementById('sstvIssMap');
|
const mapContainer = document.getElementById('sstvIssMap');
|
||||||
if (!mapContainer || issMap) return;
|
if (!mapContainer || issMap) return;
|
||||||
|
|
||||||
// Create map
|
// Create map
|
||||||
issMap = L.map('sstvIssMap', {
|
issMap = L.map('sstvIssMap', {
|
||||||
@@ -231,21 +231,21 @@ const SSTV = (function() {
|
|||||||
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
|
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
|
||||||
|
|
||||||
// Create ground track line
|
// Create ground track line
|
||||||
issTrackLine = L.polyline([], {
|
issTrackLine = L.polyline([], {
|
||||||
color: '#00d4ff',
|
color: '#00d4ff',
|
||||||
weight: 2,
|
weight: 2,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
dashArray: '5, 5'
|
dashArray: '5, 5'
|
||||||
}).addTo(issMap);
|
}).addTo(issMap);
|
||||||
|
|
||||||
issMap.on('resize moveend zoomend', () => {
|
issMap.on('resize moveend zoomend', () => {
|
||||||
if (pendingMapInvalidate) invalidateMap();
|
if (pendingMapInvalidate) invalidateMap();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial layout passes for first-time mode load.
|
// Initial layout passes for first-time mode load.
|
||||||
setTimeout(() => invalidateMap(), 40);
|
setTimeout(() => invalidateMap(), 40);
|
||||||
setTimeout(() => invalidateMap(), 180);
|
setTimeout(() => invalidateMap(), 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start ISS position tracking
|
* Start ISS position tracking
|
||||||
@@ -454,9 +454,9 @@ const SSTV = (function() {
|
|||||||
/**
|
/**
|
||||||
* Update map with ISS position
|
* Update map with ISS position
|
||||||
*/
|
*/
|
||||||
function updateMap() {
|
function updateMap() {
|
||||||
if (!issMap || !issPosition) return;
|
if (!issMap || !issPosition) return;
|
||||||
if (pendingMapInvalidate) invalidateMap();
|
if (pendingMapInvalidate) invalidateMap();
|
||||||
|
|
||||||
const lat = issPosition.lat;
|
const lat = issPosition.lat;
|
||||||
const lon = issPosition.lon;
|
const lon = issPosition.lon;
|
||||||
@@ -516,13 +516,13 @@ const SSTV = (function() {
|
|||||||
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pan map to follow ISS only when the map pane is currently renderable.
|
// Pan map to follow ISS only when the map pane is currently renderable.
|
||||||
if (isMapContainerVisible()) {
|
if (isMapContainerVisible()) {
|
||||||
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
|
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
|
||||||
} else {
|
} else {
|
||||||
pendingMapInvalidate = true;
|
pendingMapInvalidate = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check current decoder status
|
* Check current decoder status
|
||||||
@@ -1335,27 +1335,27 @@ const SSTV = (function() {
|
|||||||
/**
|
/**
|
||||||
* Show status message
|
* Show status message
|
||||||
*/
|
*/
|
||||||
function showStatusMessage(message, type) {
|
function showStatusMessage(message, type) {
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('SSTV', message);
|
showNotification('SSTV', message);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[SSTV ${type}] ${message}`);
|
console.log(`[SSTV ${type}] ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate ISS map size after pane/layout changes.
|
* Invalidate ISS map size after pane/layout changes.
|
||||||
*/
|
*/
|
||||||
function invalidateMap() {
|
function invalidateMap() {
|
||||||
if (!issMap) return false;
|
if (!issMap) return false;
|
||||||
if (!isMapContainerVisible()) {
|
if (!isMapContainerVisible()) {
|
||||||
pendingMapInvalidate = true;
|
pendingMapInvalidate = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
issMap.invalidateSize({ pan: false, animate: false });
|
issMap.invalidateSize({ pan: false, animate: false });
|
||||||
pendingMapInvalidate = false;
|
pendingMapInvalidate = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
@@ -1370,12 +1370,25 @@ const SSTV = (function() {
|
|||||||
deleteAllImages,
|
deleteAllImages,
|
||||||
downloadImage,
|
downloadImage,
|
||||||
useGPS,
|
useGPS,
|
||||||
updateTLE,
|
updateTLE,
|
||||||
stopIssTracking,
|
stopIssTracking,
|
||||||
stopCountdown,
|
stopCountdown,
|
||||||
invalidateMap
|
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)
|
// Initialize when DOM is ready (will be called by selectMode)
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
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 with rich visualizations:
|
||||||
* Streams real-time system metrics via SSE and provides SDR device enumeration.
|
* SVG arc gauge, per-core bars, temperature sparkline, network bandwidth,
|
||||||
|
* disk I/O, 3D globe, weather, and process grid.
|
||||||
*/
|
*/
|
||||||
const SystemHealth = (function () {
|
const SystemHealth = (function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -11,19 +12,46 @@ const SystemHealth = (function () {
|
|||||||
let connected = false;
|
let connected = false;
|
||||||
let lastMetrics = null;
|
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
|
// Helpers
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (bytes == null) return '--';
|
if (bytes == null) return '--';
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
let i = 0;
|
var i = 0;
|
||||||
let val = bytes;
|
var val = bytes;
|
||||||
while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
|
while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
|
||||||
return val.toFixed(1) + ' ' + units[i];
|
return val.toFixed(1) + ' ' + units[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRate(bytesPerSec) {
|
||||||
|
if (bytesPerSec == null) return '--';
|
||||||
|
return formatBytes(bytesPerSec) + '/s';
|
||||||
|
}
|
||||||
|
|
||||||
function barClass(pct) {
|
function barClass(pct) {
|
||||||
if (pct >= 85) return 'crit';
|
if (pct >= 85) return 'crit';
|
||||||
if (pct >= 60) return 'warn';
|
if (pct >= 60) return 'warn';
|
||||||
@@ -32,8 +60,8 @@ const SystemHealth = (function () {
|
|||||||
|
|
||||||
function barHtml(pct, label) {
|
function barHtml(pct, label) {
|
||||||
if (pct == null) return '<span class="sys-metric-na">N/A</span>';
|
if (pct == null) return '<span class="sys-metric-na">N/A</span>';
|
||||||
const cls = barClass(pct);
|
var cls = barClass(pct);
|
||||||
const rounded = Math.round(pct);
|
var rounded = Math.round(pct);
|
||||||
return '<div class="sys-metric-bar-wrap">' +
|
return '<div class="sys-metric-bar-wrap">' +
|
||||||
(label ? '<span class="sys-metric-bar-label">' + label + '</span>' : '') +
|
(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>' +
|
'<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>';
|
'</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) {
|
function renderCpuCard(m) {
|
||||||
const el = document.getElementById('sysCardCpu');
|
var el = document.getElementById('sysCardCpu');
|
||||||
if (!el) return;
|
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; }
|
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 =
|
el.innerHTML =
|
||||||
'<div class="sys-card-header">CPU</div>' +
|
'<div class="sys-card-header">CPU</div>' +
|
||||||
'<div class="sys-card-body">' +
|
'<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">Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '</div>' +
|
||||||
'<div class="sys-card-detail">Cores: ' + cpu.count + '</div>' +
|
'<div class="sys-card-detail">Cores: ' + cpu.count + '</div>' +
|
||||||
|
freqHtml +
|
||||||
|
'</div></div>' +
|
||||||
|
coreHtml +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Memory Card
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
function renderMemoryCard(m) {
|
function renderMemoryCard(m) {
|
||||||
const el = document.getElementById('sysCardMemory');
|
var el = document.getElementById('sysCardMemory');
|
||||||
if (!el) return;
|
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; }
|
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 =
|
el.innerHTML =
|
||||||
'<div class="sys-card-header">Memory</div>' +
|
'<div class="sys-card-header">Memory</div>' +
|
||||||
'<div class="sys-card-body">' +
|
'<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">' + 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>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDiskCard(m) {
|
// -----------------------------------------------------------------------
|
||||||
const el = document.getElementById('sysCardDisk');
|
// Temperature & Power Card
|
||||||
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>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _extractPrimaryTemp(temps) {
|
function _extractPrimaryTemp(temps) {
|
||||||
if (!temps) return null;
|
if (!temps) return null;
|
||||||
// Prefer common chip names
|
var preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal'];
|
||||||
const preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal'];
|
for (var i = 0; i < preferred.length; i++) {
|
||||||
for (const name of preferred) {
|
if (temps[preferred[i]] && temps[preferred[i]].length) return temps[preferred[i]][0];
|
||||||
if (temps[name] && temps[name].length) return temps[name][0];
|
|
||||||
}
|
}
|
||||||
// Fall back to first available
|
for (var key in temps) {
|
||||||
for (const key of Object.keys(temps)) {
|
|
||||||
if (temps[key] && temps[key].length) return temps[key][0];
|
if (temps[key] && temps[key].length) return temps[key][0];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSdrCard(devices) {
|
function renderTempCard(m) {
|
||||||
const el = document.getElementById('sysCardSdr');
|
var el = document.getElementById('sysCardTemp');
|
||||||
if (!el) return;
|
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">';
|
html += '<div class="sys-card-body">';
|
||||||
if (!devices || !devices.length) {
|
if (!devices || !devices.length) {
|
||||||
html += '<span class="sys-metric-na">No devices found</span>';
|
html += '<span class="sys-metric-na">No devices found</span>';
|
||||||
@@ -113,9 +601,9 @@ const SystemHealth = (function () {
|
|||||||
devices.forEach(function (d) {
|
devices.forEach(function (d) {
|
||||||
html += '<div class="sys-sdr-device">' +
|
html += '<div class="sys-sdr-device">' +
|
||||||
'<span class="sys-process-dot running"></span> ' +
|
'<span class="sys-process-dot running"></span> ' +
|
||||||
'<strong>' + d.type + ' #' + d.index + '</strong>' +
|
'<strong>' + escHtml(d.type) + ' #' + d.index + '</strong>' +
|
||||||
'<div class="sys-card-detail">' + (d.name || 'Unknown') + '</div>' +
|
'<div class="sys-card-detail">' + escHtml(d.name || 'Unknown') + '</div>' +
|
||||||
(d.serial ? '<div class="sys-card-detail">S/N: ' + d.serial + '</div>' : '') +
|
(d.serial ? '<div class="sys-card-detail">S/N: ' + escHtml(d.serial) + '</div>' : '') +
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -123,93 +611,197 @@ const SystemHealth = (function () {
|
|||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Process Card
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
function renderProcessCard(m) {
|
function renderProcessCard(m) {
|
||||||
const el = document.getElementById('sysCardProcesses');
|
var el = document.getElementById('sysCardProcesses');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const procs = m.processes || {};
|
var procs = m.processes || {};
|
||||||
const keys = Object.keys(procs).sort();
|
var keys = Object.keys(procs).sort();
|
||||||
let html = '<div class="sys-card-header">Processes</div><div class="sys-card-body">';
|
var html = '<div class="sys-card-header">Active Processes</div><div class="sys-card-body">';
|
||||||
if (!keys.length) {
|
if (!keys.length) {
|
||||||
html += '<span class="sys-metric-na">No data</span>';
|
html += '<span class="sys-metric-na">No data</span>';
|
||||||
} else {
|
} else {
|
||||||
|
var running = 0, stopped = 0;
|
||||||
|
html += '<div class="sys-process-grid">';
|
||||||
keys.forEach(function (k) {
|
keys.forEach(function (k) {
|
||||||
const running = procs[k];
|
var isRunning = procs[k];
|
||||||
const dotCls = running ? 'running' : 'stopped';
|
if (isRunning) running++; else stopped++;
|
||||||
const label = k.charAt(0).toUpperCase() + k.slice(1);
|
var dotCls = isRunning ? 'running' : 'stopped';
|
||||||
|
var label = k.charAt(0).toUpperCase() + k.slice(1);
|
||||||
html += '<div class="sys-process-item">' +
|
html += '<div class="sys-process-item">' +
|
||||||
'<span class="sys-process-dot ' + dotCls + '"></span> ' +
|
'<span class="sys-process-dot ' + dotCls + '"></span> ' +
|
||||||
'<span class="sys-process-name">' + label + '</span>' +
|
'<span class="sys-process-name">' + escHtml(label) + '</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div class="sys-process-summary">' + running + ' running / ' + stopped + ' idle</div>';
|
||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// System Info Card
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
function renderSystemInfoCard(m) {
|
function renderSystemInfoCard(m) {
|
||||||
const el = document.getElementById('sysCardInfo');
|
var el = document.getElementById('sysCardInfo');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const sys = m.system || {};
|
var sys = m.system || {};
|
||||||
const temp = _extractPrimaryTemp(m.temperatures);
|
var html = '<div class="sys-card-header">System Info</div><div class="sys-card-body"><div class="sys-info-grid">';
|
||||||
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-info-item"><strong>Host</strong><span>' + escHtml(sys.hostname || '--') + '</span></div>';
|
||||||
html += '<div class="sys-card-detail">OS: ' + (sys.platform || '--') + '</div>';
|
html += '<div class="sys-info-item"><strong>OS</strong><span>' + escHtml((sys.platform || '--').replace(/-with-glibc[\d.]+/, '')) + '</span></div>';
|
||||||
html += '<div class="sys-card-detail">Python: ' + (sys.python || '--') + '</div>';
|
html += '<div class="sys-info-item"><strong>Python</strong><span>' + escHtml(sys.python || '--') + '</span></div>';
|
||||||
html += '<div class="sys-card-detail">App: v' + (sys.version || '--') + '</div>';
|
html += '<div class="sys-info-item"><strong>App</strong><span>v' + escHtml(sys.version || '--') + '</span></div>';
|
||||||
html += '<div class="sys-card-detail">Uptime: ' + (sys.uptime_human || '--') + '</div>';
|
html += '<div class="sys-info-item"><strong>Uptime</strong><span>' + escHtml(sys.uptime_human || '--') + '</span></div>';
|
||||||
if (temp) {
|
|
||||||
html += '<div class="sys-card-detail">Temp: ' + Math.round(temp.current) + '°C';
|
if (m.boot_time) {
|
||||||
if (temp.high) html += ' / ' + Math.round(temp.high) + '°C max';
|
var bootDate = new Date(m.boot_time * 1000);
|
||||||
html += '</div>';
|
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;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Sidebar Updates
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
function updateSidebarQuickStats(m) {
|
function updateSidebarQuickStats(m) {
|
||||||
const cpuEl = document.getElementById('sysQuickCpu');
|
var cpuEl = document.getElementById('sysQuickCpu');
|
||||||
const tempEl = document.getElementById('sysQuickTemp');
|
var tempEl = document.getElementById('sysQuickTemp');
|
||||||
const ramEl = document.getElementById('sysQuickRam');
|
var ramEl = document.getElementById('sysQuickRam');
|
||||||
const diskEl = document.getElementById('sysQuickDisk');
|
var diskEl = document.getElementById('sysQuickDisk');
|
||||||
|
|
||||||
if (cpuEl) cpuEl.textContent = m.cpu ? Math.round(m.cpu.percent) + '%' : '--';
|
if (cpuEl) cpuEl.textContent = m.cpu ? Math.round(m.cpu.percent) + '%' : '--';
|
||||||
if (ramEl) ramEl.textContent = m.memory ? Math.round(m.memory.percent) + '%' : '--';
|
if (ramEl) ramEl.textContent = m.memory ? Math.round(m.memory.percent) + '%' : '--';
|
||||||
if (diskEl) diskEl.textContent = m.disk ? Math.round(m.disk.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' : '--';
|
if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '°C' : '--';
|
||||||
|
|
||||||
// Color-code values
|
// Color-code values
|
||||||
[cpuEl, ramEl, diskEl].forEach(function (el) {
|
[cpuEl, ramEl, diskEl].forEach(function (el) {
|
||||||
if (!el) return;
|
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');
|
el.classList.remove('sys-val-ok', 'sys-val-warn', 'sys-val-crit');
|
||||||
if (!isNaN(val)) el.classList.add('sys-val-' + barClass(val));
|
if (!isNaN(val)) el.classList.add('sys-val-' + barClass(val));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSidebarProcesses(m) {
|
function updateSidebarProcesses(m) {
|
||||||
const el = document.getElementById('sysProcessList');
|
var el = document.getElementById('sysProcessList');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const procs = m.processes || {};
|
var procs = m.processes || {};
|
||||||
const keys = Object.keys(procs).sort();
|
var keys = Object.keys(procs).sort();
|
||||||
if (!keys.length) { el.textContent = 'No data'; return; }
|
if (!keys.length) { el.textContent = 'No data'; return; }
|
||||||
const running = keys.filter(function (k) { return procs[k]; });
|
var running = keys.filter(function (k) { return procs[k]; });
|
||||||
const stopped = keys.filter(function (k) { return !procs[k]; });
|
var stopped = keys.filter(function (k) { return !procs[k]; });
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
(running.length ? '<span style="color: var(--accent-green, #00ff88);">' + running.length + ' running</span>' : '') +
|
(running.length ? '<span style="color: var(--accent-green, #00ff88);">' + running.length + ' running</span>' : '') +
|
||||||
(running.length && stopped.length ? ' · ' : '') +
|
(running.length && stopped.length ? ' · ' : '') +
|
||||||
(stopped.length ? '<span style="color: var(--text-dim);">' + stopped.length + ' stopped</span>' : '');
|
(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) {
|
function renderAll(m) {
|
||||||
renderCpuCard(m);
|
renderCpuCard(m);
|
||||||
renderMemoryCard(m);
|
renderMemoryCard(m);
|
||||||
|
renderTempCard(m);
|
||||||
renderDiskCard(m);
|
renderDiskCard(m);
|
||||||
|
renderNetworkCard(m);
|
||||||
renderProcessCard(m);
|
renderProcessCard(m);
|
||||||
renderSystemInfoCard(m);
|
renderSystemInfoCard(m);
|
||||||
updateSidebarQuickStats(m);
|
updateSidebarQuickStats(m);
|
||||||
updateSidebarProcesses(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 = '';
|
var html = '';
|
||||||
devices.forEach(function (d) {
|
devices.forEach(function (d) {
|
||||||
html += '<div style="margin-bottom: 4px;"><span class="sys-process-dot running"></span> ' +
|
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;
|
sidebarEl.innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -284,12 +876,24 @@ const SystemHealth = (function () {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
globeDestroyed = false;
|
||||||
connect();
|
connect();
|
||||||
refreshSdr();
|
refreshSdr();
|
||||||
|
fetchLocation();
|
||||||
|
|
||||||
|
// Refresh weather every 10 minutes
|
||||||
|
weatherTimer = setInterval(function () {
|
||||||
|
fetchWeather();
|
||||||
|
}, 600000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
disconnect();
|
disconnect();
|
||||||
|
destroyGlobe();
|
||||||
|
if (weatherTimer) {
|
||||||
|
clearInterval(weatherTimer);
|
||||||
|
weatherTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1005,6 +1005,15 @@ function escapeHtmlWebsdr(str) {
|
|||||||
|
|
||||||
// ============== EXPORTS ==============
|
// ============== EXPORTS ==============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy — disconnect audio and clear S-meter timer for clean mode switching.
|
||||||
|
*/
|
||||||
|
function destroyWebSDR() {
|
||||||
|
disconnectFromReceiver();
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebSDR = { destroy: destroyWebSDR };
|
||||||
|
|
||||||
window.initWebSDR = initWebSDR;
|
window.initWebSDR = initWebSDR;
|
||||||
window.searchReceivers = searchReceivers;
|
window.searchReceivers = searchReceivers;
|
||||||
window.selectReceiver = selectReceiver;
|
window.selectReceiver = selectReceiver;
|
||||||
@@ -1015,3 +1024,4 @@ window.disconnectFromReceiver = disconnectFromReceiver;
|
|||||||
window.tuneKiwi = tuneKiwi;
|
window.tuneKiwi = tuneKiwi;
|
||||||
window.tuneFromBar = tuneFromBar;
|
window.tuneFromBar = tuneFromBar;
|
||||||
window.setKiwiVolume = setKiwiVolume;
|
window.setKiwiVolume = setKiwiVolume;
|
||||||
|
window.WebSDR = WebSDR;
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
|
|||||||
maxProbes: 1000,
|
maxProbes: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Agent Support
|
// Agent Support
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the API base URL, routing through agent proxy if agent is selected.
|
* 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.
|
* Check for agent mode conflicts before starting WiFi scan.
|
||||||
*/
|
*/
|
||||||
function checkAgentConflicts() {
|
function checkAgentConflicts() {
|
||||||
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (typeof checkAgentModeConflict === 'function') {
|
if (typeof checkAgentModeConflict === 'function') {
|
||||||
return checkAgentModeConflict('wifi');
|
return checkAgentModeConflict('wifi');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChannelPresetList(preset) {
|
function getChannelPresetList(preset) {
|
||||||
switch (preset) {
|
switch (preset) {
|
||||||
case '2.4-common':
|
case '2.4-common':
|
||||||
return '1,6,11';
|
return '1,6,11';
|
||||||
case '2.4-all':
|
case '2.4-all':
|
||||||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||||||
case '5-low':
|
case '5-low':
|
||||||
return '36,40,44,48';
|
return '36,40,44,48';
|
||||||
case '5-mid':
|
case '5-mid':
|
||||||
return '52,56,60,64';
|
return '52,56,60,64';
|
||||||
case '5-high':
|
case '5-high':
|
||||||
return '149,153,157,161,165';
|
return '149,153,157,161,165';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelConfig() {
|
function buildChannelConfig() {
|
||||||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||||||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||||||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||||||
|
|
||||||
const listValue = listInput.trim();
|
const listValue = listInput.trim();
|
||||||
const presetValue = getChannelPresetList(preset);
|
const presetValue = getChannelPresetList(preset);
|
||||||
|
|
||||||
const channels = listValue || presetValue || '';
|
const channels = listValue || presetValue || '';
|
||||||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channels: channels || null,
|
channels: channels || null,
|
||||||
channel: Number.isFinite(channel) ? channel : null,
|
channel: Number.isFinite(channel) ? channel : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// State
|
// State
|
||||||
@@ -120,23 +120,23 @@ const WiFiMode = (function() {
|
|||||||
let channelStats = [];
|
let channelStats = [];
|
||||||
let recommendations = [];
|
let recommendations = [];
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let selectedNetwork = null;
|
let selectedNetwork = null;
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let currentSort = { field: 'rssi', order: 'desc' };
|
let currentSort = { field: 'rssi', order: 'desc' };
|
||||||
let renderFramePending = false;
|
let renderFramePending = false;
|
||||||
const pendingRender = {
|
const pendingRender = {
|
||||||
table: false,
|
table: false,
|
||||||
stats: false,
|
stats: false,
|
||||||
radar: false,
|
radar: false,
|
||||||
chart: false,
|
chart: false,
|
||||||
detail: false,
|
detail: false,
|
||||||
};
|
};
|
||||||
const listenersBound = {
|
const listenersBound = {
|
||||||
scanTabs: false,
|
scanTabs: false,
|
||||||
filters: false,
|
filters: false,
|
||||||
sort: false,
|
sort: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Agent state
|
// Agent state
|
||||||
let showAllAgentsMode = false; // Show combined results from all agents
|
let showAllAgentsMode = false; // Show combined results from all agents
|
||||||
@@ -165,11 +165,11 @@ const WiFiMode = (function() {
|
|||||||
|
|
||||||
// Initialize components
|
// Initialize components
|
||||||
initScanModeTabs();
|
initScanModeTabs();
|
||||||
initNetworkFilters();
|
initNetworkFilters();
|
||||||
initSortControls();
|
initSortControls();
|
||||||
initProximityRadar();
|
initProximityRadar();
|
||||||
initChannelChart();
|
initChannelChart();
|
||||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||||
|
|
||||||
// Check if already scanning
|
// Check if already scanning
|
||||||
checkScanStatus();
|
checkScanStatus();
|
||||||
@@ -378,16 +378,16 @@ const WiFiMode = (function() {
|
|||||||
// Scan Mode Tabs
|
// Scan Mode Tabs
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
function initScanModeTabs() {
|
function initScanModeTabs() {
|
||||||
if (listenersBound.scanTabs) return;
|
if (listenersBound.scanTabs) return;
|
||||||
if (elements.scanModeQuick) {
|
if (elements.scanModeQuick) {
|
||||||
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
|
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
|
||||||
}
|
}
|
||||||
if (elements.scanModeDeep) {
|
if (elements.scanModeDeep) {
|
||||||
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
|
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
|
||||||
}
|
}
|
||||||
listenersBound.scanTabs = true;
|
listenersBound.scanTabs = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setScanMode(mode) {
|
function setScanMode(mode) {
|
||||||
scanMode = mode;
|
scanMode = mode;
|
||||||
@@ -511,10 +511,10 @@ const WiFiMode = (function() {
|
|||||||
setScanning(true, 'deep');
|
setScanning(true, 'deep');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const iface = elements.interfaceSelect?.value || null;
|
const iface = elements.interfaceSelect?.value || null;
|
||||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||||
const channelConfig = buildChannelConfig();
|
const channelConfig = buildChannelConfig();
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
@@ -523,25 +523,25 @@ const WiFiMode = (function() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
interface: iface,
|
interface: iface,
|
||||||
scan_type: 'deep',
|
scan_type: 'deep',
|
||||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
channel: channelConfig.channel,
|
channel: channelConfig.channel,
|
||||||
channels: channelConfig.channels,
|
channels: channelConfig.channels,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
response = await fetch(`${CONFIG.apiBase}/scan/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
interface: iface,
|
interface: iface,
|
||||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
channel: channelConfig.channel,
|
channel: channelConfig.channel,
|
||||||
channels: channelConfig.channels,
|
channels: channelConfig.channels,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
@@ -572,8 +572,8 @@ const WiFiMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopScan() {
|
async function stopScan() {
|
||||||
console.log('[WiFiMode] Stopping scan...');
|
console.log('[WiFiMode] Stopping scan...');
|
||||||
|
|
||||||
// Stop polling
|
// Stop polling
|
||||||
if (pollTimer) {
|
if (pollTimer) {
|
||||||
@@ -585,41 +585,41 @@ const WiFiMode = (function() {
|
|||||||
stopAgentDeepScanPolling();
|
stopAgentDeepScanPolling();
|
||||||
|
|
||||||
// Close event stream
|
// Close event stream
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI immediately so mode transitions are responsive even if the
|
// Update UI immediately so mode transitions are responsive even if the
|
||||||
// backend needs extra time to terminate subprocesses.
|
// backend needs extra time to terminate subprocesses.
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
|
|
||||||
// Stop scan on server (local or agent)
|
// Stop scan on server (local or agent)
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
const timeoutMs = isAgentMode ? 8000 : 2200;
|
const timeoutMs = isAgentMode ? 8000 : 2200;
|
||||||
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||||
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
|
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
...(controller ? { signal: controller.signal } : {}),
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
});
|
});
|
||||||
} else if (scanMode === 'deep') {
|
} else if (scanMode === 'deep') {
|
||||||
await fetch(`${CONFIG.apiBase}/scan/stop`, {
|
await fetch(`${CONFIG.apiBase}/scan/stop`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
...(controller ? { signal: controller.signal } : {}),
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[WiFiMode] Error stopping scan:', error);
|
console.warn('[WiFiMode] Error stopping scan:', error);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setScanning(scanning, mode = null) {
|
function setScanning(scanning, mode = null) {
|
||||||
isScanning = scanning;
|
isScanning = scanning;
|
||||||
@@ -713,10 +713,10 @@ const WiFiMode = (function() {
|
|||||||
}, CONFIG.pollInterval);
|
}, CONFIG.pollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function processQuickScanResult(result) {
|
function processQuickScanResult(result) {
|
||||||
// Update networks
|
// Update networks
|
||||||
result.access_points.forEach(ap => {
|
result.access_points.forEach(ap => {
|
||||||
networks.set(ap.bssid, ap);
|
networks.set(ap.bssid, ap);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update channel stats (calculate from networks if not provided by API)
|
// Update channel stats (calculate from networks if not provided by API)
|
||||||
@@ -724,12 +724,12 @@ const WiFiMode = (function() {
|
|||||||
recommendations = result.recommendations || [];
|
recommendations = result.recommendations || [];
|
||||||
|
|
||||||
// If no channel stats from API, calculate from networks
|
// If no channel stats from API, calculate from networks
|
||||||
if (channelStats.length === 0 && networks.size > 0) {
|
if (channelStats.length === 0 && networks.size > 0) {
|
||||||
channelStats = calculateChannelStats();
|
channelStats = calculateChannelStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
result.access_points.forEach(ap => {
|
result.access_points.forEach(ap => {
|
||||||
@@ -938,25 +938,25 @@ const WiFiMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNetworkUpdate(network) {
|
function handleNetworkUpdate(network) {
|
||||||
networks.set(network.bssid, network);
|
networks.set(network.bssid, network);
|
||||||
scheduleRender({
|
scheduleRender({
|
||||||
table: true,
|
table: true,
|
||||||
stats: true,
|
stats: true,
|
||||||
radar: true,
|
radar: true,
|
||||||
chart: true,
|
chart: true,
|
||||||
detail: selectedNetwork === network.bssid,
|
detail: selectedNetwork === network.bssid,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onNetworkUpdate) onNetworkUpdate(network);
|
if (onNetworkUpdate) onNetworkUpdate(network);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClientUpdate(client) {
|
function handleClientUpdate(client) {
|
||||||
clients.set(client.mac, client);
|
clients.set(client.mac, client);
|
||||||
scheduleRender({ stats: true });
|
scheduleRender({ stats: true });
|
||||||
|
|
||||||
// Update client display if this client belongs to the selected network
|
// Update client display if this client belongs to the selected network
|
||||||
updateClientInList(client);
|
updateClientInList(client);
|
||||||
|
|
||||||
if (onClientUpdate) onClientUpdate(client);
|
if (onClientUpdate) onClientUpdate(client);
|
||||||
}
|
}
|
||||||
@@ -970,37 +970,37 @@ const WiFiMode = (function() {
|
|||||||
if (onProbeRequest) onProbeRequest(probe);
|
if (onProbeRequest) onProbeRequest(probe);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHiddenRevealed(bssid, revealedSsid) {
|
function handleHiddenRevealed(bssid, revealedSsid) {
|
||||||
const network = networks.get(bssid);
|
const network = networks.get(bssid);
|
||||||
if (network) {
|
if (network) {
|
||||||
network.revealed_essid = revealedSsid;
|
network.revealed_essid = revealedSsid;
|
||||||
network.display_name = `${revealedSsid} (revealed)`;
|
network.display_name = `${revealedSsid} (revealed)`;
|
||||||
scheduleRender({
|
scheduleRender({
|
||||||
table: true,
|
table: true,
|
||||||
detail: selectedNetwork === bssid,
|
detail: selectedNetwork === bssid,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
showInfo(`Hidden SSID revealed: ${revealedSsid}`);
|
showInfo(`Hidden SSID revealed: ${revealedSsid}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Network Table
|
// Network Table
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
function initNetworkFilters() {
|
function initNetworkFilters() {
|
||||||
if (listenersBound.filters) return;
|
if (listenersBound.filters) return;
|
||||||
if (!elements.networkFilters) return;
|
if (!elements.networkFilters) return;
|
||||||
|
|
||||||
elements.networkFilters.addEventListener('click', (e) => {
|
elements.networkFilters.addEventListener('click', (e) => {
|
||||||
if (e.target.matches('.wifi-filter-btn')) {
|
if (e.target.matches('.wifi-filter-btn')) {
|
||||||
const filter = e.target.dataset.filter;
|
const filter = e.target.dataset.filter;
|
||||||
setNetworkFilter(filter);
|
setNetworkFilter(filter);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
listenersBound.filters = true;
|
listenersBound.filters = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNetworkFilter(filter) {
|
function setNetworkFilter(filter) {
|
||||||
currentFilter = filter;
|
currentFilter = filter;
|
||||||
@@ -1015,11 +1015,11 @@ const WiFiMode = (function() {
|
|||||||
updateNetworkTable();
|
updateNetworkTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSortControls() {
|
function initSortControls() {
|
||||||
if (listenersBound.sort) return;
|
if (listenersBound.sort) return;
|
||||||
if (!elements.networkTable) return;
|
if (!elements.networkTable) return;
|
||||||
|
|
||||||
elements.networkTable.addEventListener('click', (e) => {
|
elements.networkTable.addEventListener('click', (e) => {
|
||||||
const th = e.target.closest('th[data-sort]');
|
const th = e.target.closest('th[data-sort]');
|
||||||
if (th) {
|
if (th) {
|
||||||
const field = th.dataset.sort;
|
const field = th.dataset.sort;
|
||||||
@@ -1029,54 +1029,54 @@ const WiFiMode = (function() {
|
|||||||
currentSort.field = field;
|
currentSort.field = field;
|
||||||
currentSort.order = 'desc';
|
currentSort.order = 'desc';
|
||||||
}
|
}
|
||||||
updateNetworkTable();
|
updateNetworkTable();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (elements.networkTableBody) {
|
if (elements.networkTableBody) {
|
||||||
elements.networkTableBody.addEventListener('click', (e) => {
|
elements.networkTableBody.addEventListener('click', (e) => {
|
||||||
const row = e.target.closest('tr[data-bssid]');
|
const row = e.target.closest('tr[data-bssid]');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
selectNetwork(row.dataset.bssid);
|
selectNetwork(row.dataset.bssid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
listenersBound.sort = true;
|
listenersBound.sort = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleRender(flags = {}) {
|
function scheduleRender(flags = {}) {
|
||||||
pendingRender.table = pendingRender.table || Boolean(flags.table);
|
pendingRender.table = pendingRender.table || Boolean(flags.table);
|
||||||
pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
|
pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
|
||||||
pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
|
pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
|
||||||
pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
|
pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
|
||||||
pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
|
pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
|
||||||
|
|
||||||
if (renderFramePending) return;
|
if (renderFramePending) return;
|
||||||
renderFramePending = true;
|
renderFramePending = true;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
renderFramePending = false;
|
renderFramePending = false;
|
||||||
|
|
||||||
if (pendingRender.table) updateNetworkTable();
|
if (pendingRender.table) updateNetworkTable();
|
||||||
if (pendingRender.stats) updateStats();
|
if (pendingRender.stats) updateStats();
|
||||||
if (pendingRender.radar) updateProximityRadar();
|
if (pendingRender.radar) updateProximityRadar();
|
||||||
if (pendingRender.chart) updateChannelChart();
|
if (pendingRender.chart) updateChannelChart();
|
||||||
if (pendingRender.detail && selectedNetwork) {
|
if (pendingRender.detail && selectedNetwork) {
|
||||||
updateDetailPanel(selectedNetwork, { refreshClients: false });
|
updateDetailPanel(selectedNetwork, { refreshClients: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingRender.table = false;
|
pendingRender.table = false;
|
||||||
pendingRender.stats = false;
|
pendingRender.stats = false;
|
||||||
pendingRender.radar = false;
|
pendingRender.radar = false;
|
||||||
pendingRender.chart = false;
|
pendingRender.chart = false;
|
||||||
pendingRender.detail = false;
|
pendingRender.detail = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNetworkTable() {
|
function updateNetworkTable() {
|
||||||
if (!elements.networkTableBody) return;
|
if (!elements.networkTableBody) return;
|
||||||
|
|
||||||
// Filter networks
|
// Filter networks
|
||||||
let filtered = Array.from(networks.values());
|
let filtered = Array.from(networks.values());
|
||||||
|
|
||||||
switch (currentFilter) {
|
switch (currentFilter) {
|
||||||
case 'hidden':
|
case 'hidden':
|
||||||
@@ -1126,44 +1126,44 @@ const WiFiMode = (function() {
|
|||||||
return bVal > aVal ? 1 : bVal < aVal ? -1 : 0;
|
return bVal > aVal ? 1 : bVal < aVal ? -1 : 0;
|
||||||
} else {
|
} else {
|
||||||
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
|
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) {
|
if (filtered.length === 0) {
|
||||||
const rssi = network.rssi_current;
|
let message = 'Start scanning to discover networks';
|
||||||
const security = network.security || 'Unknown';
|
let type = 'empty';
|
||||||
const signalClass = rssi >= -50 ? 'signal-strong' :
|
if (isScanning) {
|
||||||
rssi >= -70 ? 'signal-medium' :
|
message = 'Scanning for networks...';
|
||||||
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
|
type = 'loading';
|
||||||
|
} else if (networks.size > 0) {
|
||||||
const securityClass = security === 'Open' ? 'security-open' :
|
message = 'No networks match current filters';
|
||||||
security === 'WEP' ? 'security-wep' :
|
}
|
||||||
security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
|
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 hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : '';
|
||||||
const newBadge = network.is_new ? '<span class="badge badge-new">New</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 agentName = network._agent || 'Local';
|
||||||
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
|
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
|
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
|
||||||
data-bssid="${escapeHtml(network.bssid)}"
|
data-bssid="${escapeHtml(network.bssid)}"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
data-keyboard-activate="true"
|
data-keyboard-activate="true"
|
||||||
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
|
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
|
||||||
<td class="col-essid">
|
<td class="col-essid">
|
||||||
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
|
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
|
||||||
${hiddenBadge}${newBadge}
|
${hiddenBadge}${newBadge}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td>
|
<td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td>
|
||||||
<td class="col-channel">${network.channel || '-'}</td>
|
<td class="col-channel">${network.channel || '-'}</td>
|
||||||
<td class="col-rssi">
|
<td class="col-rssi">
|
||||||
<span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
|
<span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-security">
|
<td class="col-security">
|
||||||
<span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
|
<span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-clients">${network.client_count || 0}</td>
|
<td class="col-clients">${network.client_count || 0}</td>
|
||||||
<td class="col-agent">
|
<td class="col-agent">
|
||||||
<span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span>
|
<span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span>
|
||||||
@@ -1199,12 +1199,12 @@ const WiFiMode = (function() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNetworkRow(network) {
|
function updateNetworkRow(network) {
|
||||||
scheduleRender({
|
scheduleRender({
|
||||||
table: true,
|
table: true,
|
||||||
detail: selectedNetwork === network.bssid,
|
detail: selectedNetwork === network.bssid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectNetwork(bssid) {
|
function selectNetwork(bssid) {
|
||||||
selectedNetwork = bssid;
|
selectedNetwork = bssid;
|
||||||
@@ -1227,9 +1227,9 @@ const WiFiMode = (function() {
|
|||||||
// Detail Panel
|
// Detail Panel
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
function updateDetailPanel(bssid, options = {}) {
|
function updateDetailPanel(bssid, options = {}) {
|
||||||
const { refreshClients = true } = options;
|
const { refreshClients = true } = options;
|
||||||
if (!elements.detailDrawer) return;
|
if (!elements.detailDrawer) return;
|
||||||
|
|
||||||
const network = networks.get(bssid);
|
const network = networks.get(bssid);
|
||||||
if (!network) {
|
if (!network) {
|
||||||
@@ -1274,11 +1274,11 @@ const WiFiMode = (function() {
|
|||||||
// Show the drawer
|
// Show the drawer
|
||||||
elements.detailDrawer.classList.add('open');
|
elements.detailDrawer.classList.add('open');
|
||||||
|
|
||||||
// Fetch and display clients for this network
|
// Fetch and display clients for this network
|
||||||
if (refreshClients) {
|
if (refreshClients) {
|
||||||
fetchClientsForNetwork(network.bssid);
|
fetchClientsForNetwork(network.bssid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDetail() {
|
function closeDetail() {
|
||||||
selectedNetwork = null;
|
selectedNetwork = null;
|
||||||
@@ -1294,18 +1294,18 @@ const WiFiMode = (function() {
|
|||||||
// Client Display
|
// Client Display
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
async function fetchClientsForNetwork(bssid) {
|
async function fetchClientsForNetwork(bssid) {
|
||||||
if (!elements.detailClientList) return;
|
if (!elements.detailClientList) return;
|
||||||
const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
|
const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
|
||||||
|
|
||||||
if (listContainer && typeof renderCollectionState === 'function') {
|
if (listContainer && typeof renderCollectionState === 'function') {
|
||||||
renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
|
renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
|
||||||
elements.detailClientList.style.display = 'block';
|
elements.detailClientList.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
let response;
|
let response;
|
||||||
|
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
// Route through agent proxy
|
// Route through agent proxy
|
||||||
@@ -1314,44 +1314,44 @@ const WiFiMode = (function() {
|
|||||||
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (listContainer && typeof renderCollectionState === 'function') {
|
if (listContainer && typeof renderCollectionState === 'function') {
|
||||||
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
||||||
elements.detailClientList.style.display = 'block';
|
elements.detailClientList.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
elements.detailClientList.style.display = 'none';
|
elements.detailClientList.style.display = 'none';
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// Handle agent response format (may be nested in 'result')
|
// Handle agent response format (may be nested in 'result')
|
||||||
const result = isAgentMode && data.result ? data.result : data;
|
const result = isAgentMode && data.result ? data.result : data;
|
||||||
const clientList = result.clients || [];
|
const clientList = result.clients || [];
|
||||||
|
|
||||||
if (clientList.length > 0) {
|
if (clientList.length > 0) {
|
||||||
renderClientList(clientList, bssid);
|
renderClientList(clientList, bssid);
|
||||||
elements.detailClientList.style.display = 'block';
|
elements.detailClientList.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
const countBadge = document.getElementById('wifiClientCountBadge');
|
const countBadge = document.getElementById('wifiClientCountBadge');
|
||||||
if (countBadge) countBadge.textContent = '0';
|
if (countBadge) countBadge.textContent = '0';
|
||||||
if (listContainer && typeof renderCollectionState === 'function') {
|
if (listContainer && typeof renderCollectionState === 'function') {
|
||||||
renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
|
renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
|
||||||
elements.detailClientList.style.display = 'block';
|
elements.detailClientList.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
elements.detailClientList.style.display = 'none';
|
elements.detailClientList.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.debug('[WiFiMode] Error fetching clients:', error);
|
console.debug('[WiFiMode] Error fetching clients:', error);
|
||||||
if (listContainer && typeof renderCollectionState === 'function') {
|
if (listContainer && typeof renderCollectionState === 'function') {
|
||||||
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
||||||
elements.detailClientList.style.display = 'block';
|
elements.detailClientList.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
elements.detailClientList.style.display = 'none';
|
elements.detailClientList.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderClientList(clientList, bssid) {
|
function renderClientList(clientList, bssid) {
|
||||||
const container = elements.detailClientList?.querySelector('.wifi-client-list');
|
const container = elements.detailClientList?.querySelector('.wifi-client-list');
|
||||||
@@ -1708,16 +1708,16 @@ const WiFiMode = (function() {
|
|||||||
/**
|
/**
|
||||||
* Clear all collected data.
|
* Clear all collected data.
|
||||||
*/
|
*/
|
||||||
function clearData() {
|
function clearData() {
|
||||||
networks.clear();
|
networks.clear();
|
||||||
clients.clear();
|
clients.clear();
|
||||||
probeRequests = [];
|
probeRequests = [];
|
||||||
channelStats = [];
|
channelStats = [];
|
||||||
recommendations = [];
|
recommendations = [];
|
||||||
if (selectedNetwork) {
|
if (selectedNetwork) {
|
||||||
closeDetail();
|
closeDetail();
|
||||||
}
|
}
|
||||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1763,12 +1763,12 @@ const WiFiMode = (function() {
|
|||||||
clientsToRemove.push(mac);
|
clientsToRemove.push(mac);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
clientsToRemove.forEach(mac => clients.delete(mac));
|
clientsToRemove.forEach(mac => clients.delete(mac));
|
||||||
if (selectedNetwork && !networks.has(selectedNetwork)) {
|
if (selectedNetwork && !networks.has(selectedNetwork)) {
|
||||||
closeDetail();
|
closeDetail();
|
||||||
}
|
}
|
||||||
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh WiFi interfaces from current agent.
|
* Refresh WiFi interfaces from current agent.
|
||||||
@@ -1811,7 +1811,28 @@ const WiFiMode = (function() {
|
|||||||
onNetworkUpdate: (cb) => { onNetworkUpdate = cb; },
|
onNetworkUpdate: (cb) => { onNetworkUpdate = cb; },
|
||||||
onClientUpdate: (cb) => { onClientUpdate = cb; },
|
onClientUpdate: (cb) => { onClientUpdate = cb; },
|
||||||
onProbeRequest: (cb) => { onProbeRequest = 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
|
// Auto-initialize when DOM is ready
|
||||||
|
|||||||
@@ -1762,31 +1762,37 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
airbandSelect.innerHTML = '';
|
airbandSelect.innerHTML = '';
|
||||||
|
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
adsbSelect.innerHTML = '<option value="0">No SDR found</option>';
|
adsbSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
||||||
airbandSelect.innerHTML = '<option value="0">No SDR found</option>';
|
airbandSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
||||||
airbandSelect.disabled = true;
|
airbandSelect.disabled = true;
|
||||||
} else {
|
} else {
|
||||||
devices.forEach((dev, i) => {
|
devices.forEach((dev, i) => {
|
||||||
const idx = dev.index !== undefined ? dev.index : 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}`;
|
const displayName = `SDR ${idx}: ${dev.name}`;
|
||||||
|
|
||||||
// Add to ADS-B selector
|
// Add to ADS-B selector
|
||||||
const adsbOpt = document.createElement('option');
|
const adsbOpt = document.createElement('option');
|
||||||
adsbOpt.value = idx;
|
adsbOpt.value = compositeVal;
|
||||||
|
adsbOpt.dataset.sdrType = sdrType;
|
||||||
|
adsbOpt.dataset.index = idx;
|
||||||
adsbOpt.textContent = displayName;
|
adsbOpt.textContent = displayName;
|
||||||
adsbSelect.appendChild(adsbOpt);
|
adsbSelect.appendChild(adsbOpt);
|
||||||
|
|
||||||
// Add to Airband selector
|
// Add to Airband selector
|
||||||
const airbandOpt = document.createElement('option');
|
const airbandOpt = document.createElement('option');
|
||||||
airbandOpt.value = idx;
|
airbandOpt.value = compositeVal;
|
||||||
|
airbandOpt.dataset.sdrType = sdrType;
|
||||||
|
airbandOpt.dataset.index = idx;
|
||||||
airbandOpt.textContent = displayName;
|
airbandOpt.textContent = displayName;
|
||||||
airbandSelect.appendChild(airbandOpt);
|
airbandSelect.appendChild(airbandOpt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default: ADS-B uses first device, Airband uses second (if available)
|
// 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) {
|
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
|
// Show warning if only one device
|
||||||
@@ -1797,8 +1803,8 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="0">Error</option>';
|
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
||||||
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="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
|
// Get selected ADS-B device (composite value "sdr_type:index")
|
||||||
const adsbDevice = parseInt(document.getElementById('adsbDeviceSelect').value) || 0;
|
const adsbSelectVal = document.getElementById('adsbDeviceSelect').value || 'rtlsdr:0';
|
||||||
|
const [adsbSdrType, adsbDeviceIdx] = adsbSelectVal.includes(':') ? adsbSelectVal.split(':') : ['rtlsdr', adsbSelectVal];
|
||||||
|
const adsbDevice = parseInt(adsbDeviceIdx) || 0;
|
||||||
|
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
device: adsbDevice,
|
device: adsbDevice,
|
||||||
|
sdr_type: adsbSdrType,
|
||||||
bias_t: getBiasTEnabled()
|
bias_t: getBiasTEnabled()
|
||||||
};
|
};
|
||||||
if (remoteConfig) {
|
if (remoteConfig) {
|
||||||
@@ -2316,11 +2325,13 @@ sudo make install</code>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionDevice = session.device_index;
|
const sessionDevice = session.device_index;
|
||||||
|
const sessionSdrType = session.sdr_type || 'rtlsdr';
|
||||||
if (sessionDevice !== null && sessionDevice !== undefined) {
|
if (sessionDevice !== null && sessionDevice !== undefined) {
|
||||||
adsbActiveDevice = sessionDevice;
|
adsbActiveDevice = sessionDevice;
|
||||||
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||||||
if (adsbSelect) {
|
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() {
|
function startAcars() {
|
||||||
const acarsSelect = document.getElementById('acarsDeviceSelect');
|
const acarsSelect = document.getElementById('acarsDeviceSelect');
|
||||||
const device = acarsSelect.value;
|
const compositeVal = acarsSelect.value || 'rtlsdr:0';
|
||||||
const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
|
const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
|
||||||
|
const device = deviceIdx;
|
||||||
const frequencies = getAcarsRegionFreqs();
|
const frequencies = getAcarsRegionFreqs();
|
||||||
|
|
||||||
// Check if using agent mode
|
// Check if using agent mode
|
||||||
@@ -4179,13 +4191,16 @@ sudo make install</code>
|
|||||||
const select = document.getElementById('acarsDeviceSelect');
|
const select = document.getElementById('acarsDeviceSelect');
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
select.innerHTML = '<option value="0">No SDR detected</option>';
|
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
|
||||||
} else {
|
} else {
|
||||||
devices.forEach((d, i) => {
|
devices.forEach((d, i) => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = d.index || i;
|
const sdrType = d.sdr_type || 'rtlsdr';
|
||||||
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
|
const idx = d.index !== undefined ? d.index : i;
|
||||||
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
|
opt.value = `${sdrType}:${idx}`;
|
||||||
|
opt.dataset.sdrType = sdrType;
|
||||||
|
opt.dataset.index = idx;
|
||||||
|
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4277,8 +4292,9 @@ sudo make install</code>
|
|||||||
|
|
||||||
function startVdl2() {
|
function startVdl2() {
|
||||||
const vdl2Select = document.getElementById('vdl2DeviceSelect');
|
const vdl2Select = document.getElementById('vdl2DeviceSelect');
|
||||||
const device = vdl2Select.value;
|
const compositeVal = vdl2Select.value || 'rtlsdr:0';
|
||||||
const sdr_type = vdl2Select.selectedOptions[0]?.dataset.sdrType || 'rtlsdr';
|
const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
|
||||||
|
const device = deviceIdx;
|
||||||
const frequencies = getVdl2RegionFreqs();
|
const frequencies = getVdl2RegionFreqs();
|
||||||
|
|
||||||
// Check if using agent mode
|
// Check if using agent mode
|
||||||
@@ -4723,13 +4739,16 @@ sudo make install</code>
|
|||||||
const select = document.getElementById('vdl2DeviceSelect');
|
const select = document.getElementById('vdl2DeviceSelect');
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
select.innerHTML = '<option value="0">No SDR detected</option>';
|
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
|
||||||
} else {
|
} else {
|
||||||
devices.forEach((d, i) => {
|
devices.forEach((d, i) => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = d.index || i;
|
const sdrType = d.sdr_type || 'rtlsdr';
|
||||||
opt.dataset.sdrType = d.sdr_type || 'rtlsdr';
|
const idx = d.index !== undefined ? d.index : i;
|
||||||
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
|
opt.value = `${sdrType}:${idx}`;
|
||||||
|
opt.dataset.sdrType = sdrType;
|
||||||
|
opt.dataset.index = idx;
|
||||||
|
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5715,13 +5734,16 @@ sudo make install</code>
|
|||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
select.innerHTML = '<option value="0">No SDR found</option>';
|
select.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
||||||
} else {
|
} else {
|
||||||
devices.forEach(device => {
|
devices.forEach(device => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = device.index;
|
const sdrType = device.sdr_type || 'rtlsdr';
|
||||||
opt.dataset.sdrType = device.sdr_type || 'rtlsdr';
|
const idx = device.index !== undefined ? device.index : 0;
|
||||||
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
|
opt.value = `${sdrType}:${idx}`;
|
||||||
|
opt.dataset.sdrType = sdrType;
|
||||||
|
opt.dataset.index = idx;
|
||||||
|
opt.textContent = `SDR ${idx}: ${device.name || device.type || 'SDR'}`;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
||||||
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
||||||
morse: "{{ url_for('static', filename='css/modes/morse.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') }}"
|
system: "{{ url_for('static', filename='css/modes/system.css') }}"
|
||||||
};
|
};
|
||||||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
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-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>
|
<span class="mode-name">GPS</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -696,6 +701,8 @@
|
|||||||
|
|
||||||
{% include 'partials/modes/ais.html' %}
|
{% include 'partials/modes/ais.html' %}
|
||||||
|
|
||||||
|
{% include 'partials/modes/radiosonde.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/spy-stations.html' %}
|
{% include 'partials/modes/spy-stations.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/meshtastic.html' %}
|
{% include 'partials/modes/meshtastic.html' %}
|
||||||
@@ -3127,9 +3134,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- System Health Visuals -->
|
||||||
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
|
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
|
||||||
<div class="sys-dashboard">
|
<div class="sys-dashboard">
|
||||||
|
<!-- Row 1: COMPUTE -->
|
||||||
|
<div class="sys-group-header">Compute</div>
|
||||||
<div class="sys-card" id="sysCardCpu">
|
<div class="sys-card" id="sysCardCpu">
|
||||||
<div class="sys-card-header">CPU</div>
|
<div class="sys-card-header">CPU</div>
|
||||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></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-header">Memory</div>
|
||||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||||
</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" 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 class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sys-card" id="sysCardSdr">
|
<div class="sys-card" id="sysCardSdr">
|
||||||
@@ -3147,11 +3184,7 @@
|
|||||||
<div class="sys-card-body"><span class="sys-metric-na">Scanning…</span></div>
|
<div class="sys-card-body"><span class="sys-metric-na">Scanning…</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sys-card" id="sysCardProcesses">
|
<div class="sys-card" id="sysCardProcesses">
|
||||||
<div class="sys-card-header">Processes</div>
|
<div class="sys-card-header">Active 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-body"><span class="sys-metric-na">Connecting…</span></div>
|
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3367,6 +3400,7 @@
|
|||||||
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
||||||
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
||||||
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', 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' },
|
satellite: { label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space' },
|
||||||
sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', 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' },
|
weathersat: { label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space' },
|
||||||
@@ -4106,12 +4140,27 @@
|
|||||||
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
||||||
await styleReadyPromise;
|
await styleReadyPromise;
|
||||||
|
|
||||||
// Clean up SubGHz SSE connection when leaving the mode
|
// Generic module cleanup — destroy previous mode's timers, SSE, etc.
|
||||||
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
|
const moduleDestroyMap = {
|
||||||
SubGhz.destroy();
|
subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(),
|
||||||
}
|
morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(),
|
||||||
if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') {
|
spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(),
|
||||||
MorseMode.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;
|
currentMode = mode;
|
||||||
@@ -4155,6 +4204,7 @@
|
|||||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
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('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||||
@@ -4204,6 +4254,7 @@
|
|||||||
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
||||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||||||
|
const radiosondeVisuals = document.getElementById('radiosondeVisuals');
|
||||||
const systemVisuals = document.getElementById('systemVisuals');
|
const systemVisuals = document.getElementById('systemVisuals');
|
||||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? '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 (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
|
||||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? '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';
|
if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
|
||||||
|
|
||||||
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||||||
@@ -4264,25 +4316,7 @@
|
|||||||
refreshTscmDevices();
|
refreshTscmDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize/destroy Space Weather mode
|
// Module destroy is now handled by moduleDestroyMap above.
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
@@ -4309,7 +4343,7 @@
|
|||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||||
if (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)
|
// Save original sidebar position of SDR device section (once)
|
||||||
if (!rtlDeviceSection._origParent) {
|
if (!rtlDeviceSection._origParent) {
|
||||||
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
|
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
|
||||||
@@ -4414,14 +4448,16 @@
|
|||||||
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||||||
} else if (mode === 'morse') {
|
} else if (mode === 'morse') {
|
||||||
MorseMode.init();
|
MorseMode.init();
|
||||||
|
} else if (mode === 'radiosonde') {
|
||||||
|
initRadiosondeMap();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (radiosondeMap) radiosondeMap.invalidateSize();
|
||||||
|
}, 100);
|
||||||
} else if (mode === 'system') {
|
} else if (mode === 'system') {
|
||||||
SystemHealth.init();
|
SystemHealth.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destroy Waterfall WebSocket when leaving SDR receiver modes
|
// Waterfall destroy is now handled by moduleDestroyMap above.
|
||||||
if (mode !== 'waterfall' && typeof Waterfall !== 'undefined' && Waterfall.destroy) {
|
|
||||||
Promise.resolve(Waterfall.destroy()).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMs = Math.round(performance.now() - switchStartMs);
|
const totalMs = Math.round(performance.now() - switchStartMs);
|
||||||
console.info(
|
console.info(
|
||||||
@@ -5626,37 +5662,41 @@
|
|||||||
let currentDeviceList = [];
|
let currentDeviceList = [];
|
||||||
|
|
||||||
// SDR Device Usage Tracking
|
// 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 = {
|
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) {
|
function getDeviceInUseBy(deviceIndex, sdrType) {
|
||||||
return sdrDeviceUsage[deviceIndex] || null;
|
const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
|
||||||
|
return sdrDeviceUsage[key] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDeviceInUse(deviceIndex) {
|
function isDeviceInUse(deviceIndex, sdrType) {
|
||||||
return sdrDeviceUsage[deviceIndex] !== undefined;
|
const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
|
||||||
|
return sdrDeviceUsage[key] !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reserveDevice(deviceIndex, modeName) {
|
function reserveDevice(deviceIndex, modeName, sdrType) {
|
||||||
sdrDeviceUsage[deviceIndex] = modeName;
|
const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
|
||||||
|
sdrDeviceUsage[key] = modeName;
|
||||||
updateDeviceSelectStatus();
|
updateDeviceSelectStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseDevice(modeName) {
|
function releaseDevice(modeName) {
|
||||||
for (const [idx, mode] of Object.entries(sdrDeviceUsage)) {
|
for (const [key, mode] of Object.entries(sdrDeviceUsage)) {
|
||||||
if (mode === modeName) {
|
if (mode === modeName) {
|
||||||
delete sdrDeviceUsage[idx];
|
delete sdrDeviceUsage[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateDeviceSelectStatus();
|
updateDeviceSelectStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvailableDevice() {
|
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) {
|
for (const device of currentDeviceList) {
|
||||||
if (!isDeviceInUse(device.index)) {
|
if ((device.sdr_type || 'rtlsdr') === sdrType && !isDeviceInUse(device.index, sdrType)) {
|
||||||
return device.index;
|
return device.index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5668,10 +5708,11 @@
|
|||||||
const select = document.getElementById('deviceSelect');
|
const select = document.getElementById('deviceSelect');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
|
|
||||||
|
const sdrType = getSelectedSDRType();
|
||||||
const options = select.querySelectorAll('option');
|
const options = select.querySelectorAll('option');
|
||||||
options.forEach(opt => {
|
options.forEach(opt => {
|
||||||
const idx = parseInt(opt.value);
|
const idx = parseInt(opt.value);
|
||||||
const usedBy = getDeviceInUseBy(idx);
|
const usedBy = getDeviceInUseBy(idx, sdrType);
|
||||||
const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status
|
const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status
|
||||||
if (usedBy) {
|
if (usedBy) {
|
||||||
opt.textContent = `${baseName} [${usedBy.toUpperCase()}]`;
|
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>
|
||||||
</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 -->
|
<!-- SDR Devices -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>SDR Devices</h3>
|
<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('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('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('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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -257,8 +257,8 @@ class TestMorseLifecycleRoutes:
|
|||||||
|
|
||||||
released_devices = []
|
released_devices = []
|
||||||
|
|
||||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
|
||||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
|
||||||
|
|
||||||
class DummyDevice:
|
class DummyDevice:
|
||||||
sdr_type = morse_routes.SDRType.RTL_SDR
|
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||||
@@ -337,8 +337,8 @@ class TestMorseLifecycleRoutes:
|
|||||||
|
|
||||||
released_devices = []
|
released_devices = []
|
||||||
|
|
||||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
|
||||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
|
||||||
|
|
||||||
class DummyDevice:
|
class DummyDevice:
|
||||||
sdr_type = morse_routes.SDRType.RTL_SDR
|
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||||
@@ -421,8 +421,8 @@ class TestMorseLifecycleRoutes:
|
|||||||
|
|
||||||
released_devices = []
|
released_devices = []
|
||||||
|
|
||||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None)
|
||||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx))
|
||||||
|
|
||||||
class DummyDevice:
|
class DummyDevice:
|
||||||
def __init__(self, index: int):
|
def __init__(self, index: int):
|
||||||
|
|||||||
@@ -30,6 +30,32 @@ def test_metrics_returns_expected_keys(client):
|
|||||||
assert 'uptime_human' in data['system']
|
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):
|
def test_metrics_without_psutil(client):
|
||||||
"""Metrics degrade gracefully when psutil is unavailable."""
|
"""Metrics degrade gracefully when psutil is unavailable."""
|
||||||
_login(client)
|
_login(client)
|
||||||
@@ -45,6 +71,11 @@ def test_metrics_without_psutil(client):
|
|||||||
assert data['cpu'] is None
|
assert data['cpu'] is None
|
||||||
assert data['memory'] is None
|
assert data['memory'] is None
|
||||||
assert data['disk'] 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:
|
finally:
|
||||||
mod._HAS_PSUTIL = orig
|
mod._HAS_PSUTIL = orig
|
||||||
|
|
||||||
@@ -87,3 +118,113 @@ def test_stream_returns_sse_content_type(client):
|
|||||||
resp = client.get('/system/stream')
|
resp = client.get('/system/stream')
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert 'text/event-stream' in resp.content_type
|
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
|
from utils.wefax_stations import get_station
|
||||||
assert get_station('noj') is not None
|
assert get_station('noj') is not None
|
||||||
|
|
||||||
def test_get_station_not_found(self):
|
def test_get_station_not_found(self):
|
||||||
"""get_station() should return None for unknown callsign."""
|
"""get_station() should return None for unknown callsign."""
|
||||||
from utils.wefax_stations import get_station
|
from utils.wefax_stations import get_station
|
||||||
assert get_station('XXXXX') is None
|
assert get_station('XXXXX') is None
|
||||||
|
|
||||||
def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self):
|
def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self):
|
||||||
"""Known station frequencies default to carrier-list behavior in auto mode."""
|
"""Known station frequencies default to carrier-list behavior in auto mode."""
|
||||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||||
|
|
||||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||||
listed_frequency_khz=4298.0,
|
listed_frequency_khz=4298.0,
|
||||||
station_callsign='NOJ',
|
station_callsign='NOJ',
|
||||||
frequency_reference='auto',
|
frequency_reference='auto',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert math.isclose(tuned, 4296.1, abs_tol=1e-6)
|
assert math.isclose(tuned, 4296.1, abs_tol=1e-6)
|
||||||
assert reference == 'carrier'
|
assert reference == 'carrier'
|
||||||
assert offset_applied is True
|
assert offset_applied is True
|
||||||
|
|
||||||
def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self):
|
def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self):
|
||||||
"""Ad-hoc frequencies (no station metadata) should be treated as dial."""
|
"""Ad-hoc frequencies (no station metadata) should be treated as dial."""
|
||||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||||
|
|
||||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||||
listed_frequency_khz=4298.0,
|
listed_frequency_khz=4298.0,
|
||||||
station_callsign='',
|
station_callsign='',
|
||||||
frequency_reference='auto',
|
frequency_reference='auto',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||||
assert reference == 'dial'
|
assert reference == 'dial'
|
||||||
assert offset_applied is False
|
assert offset_applied is False
|
||||||
|
|
||||||
def test_resolve_tuning_frequency_dial_override(self):
|
def test_resolve_tuning_frequency_dial_override(self):
|
||||||
"""Explicit dial reference must bypass USB alignment."""
|
"""Explicit dial reference must bypass USB alignment."""
|
||||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||||
|
|
||||||
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
tuned, reference, offset_applied = resolve_tuning_frequency_khz(
|
||||||
listed_frequency_khz=4298.0,
|
listed_frequency_khz=4298.0,
|
||||||
station_callsign='NOJ',
|
station_callsign='NOJ',
|
||||||
frequency_reference='dial',
|
frequency_reference='dial',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
assert math.isclose(tuned, 4298.0, abs_tol=1e-6)
|
||||||
assert reference == 'dial'
|
assert reference == 'dial'
|
||||||
assert offset_applied is False
|
assert offset_applied is False
|
||||||
|
|
||||||
def test_resolve_tuning_frequency_rejects_invalid_reference(self):
|
def test_resolve_tuning_frequency_rejects_invalid_reference(self):
|
||||||
"""Invalid frequency reference values should raise a validation error."""
|
"""Invalid frequency reference values should raise a validation error."""
|
||||||
from utils.wefax_stations import resolve_tuning_frequency_khz
|
from utils.wefax_stations import resolve_tuning_frequency_khz
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolve_tuning_frequency_khz(
|
resolve_tuning_frequency_khz(
|
||||||
listed_frequency_khz=4298.0,
|
listed_frequency_khz=4298.0,
|
||||||
station_callsign='NOJ',
|
station_callsign='NOJ',
|
||||||
frequency_reference='invalid',
|
frequency_reference='invalid',
|
||||||
)
|
)
|
||||||
assert False, "Expected ValueError for invalid frequency_reference"
|
assert False, "Expected ValueError for invalid frequency_reference"
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
assert 'frequency_reference' in str(exc)
|
assert 'frequency_reference' in str(exc)
|
||||||
|
|
||||||
def test_station_frequencies_have_khz(self):
|
def test_station_frequencies_have_khz(self):
|
||||||
"""Each frequency entry must have 'khz' and 'description'."""
|
"""Each frequency entry must have 'khz' and 'description'."""
|
||||||
from utils.wefax_stations import load_stations
|
from utils.wefax_stations import load_stations
|
||||||
for station in load_stations():
|
for station in load_stations():
|
||||||
for freq in station['frequencies']:
|
for freq in station['frequencies']:
|
||||||
assert 'khz' in freq, f"{station['callsign']} missing khz"
|
assert 'khz' in freq, f"{station['callsign']} missing khz"
|
||||||
assert 'description' in freq, f"{station['callsign']} missing description"
|
assert 'description' in freq, f"{station['callsign']} missing description"
|
||||||
assert isinstance(freq['khz'], (int, float))
|
assert isinstance(freq['khz'], (int, float))
|
||||||
@@ -281,7 +281,7 @@ class TestWeFaxDecoder:
|
|||||||
# Route tests
|
# Route tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestWeFaxRoutes:
|
class TestWeFaxRoutes:
|
||||||
"""WeFax route endpoint tests."""
|
"""WeFax route endpoint tests."""
|
||||||
|
|
||||||
def test_status(self, client):
|
def test_status(self, client):
|
||||||
@@ -390,11 +390,11 @@ class TestWeFaxRoutes:
|
|||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert 'LPM' in data['message']
|
assert 'LPM' in data['message']
|
||||||
|
|
||||||
def test_start_success(self, client):
|
def test_start_success(self, client):
|
||||||
"""POST /wefax/start with valid params should succeed."""
|
"""POST /wefax/start with valid params should succeed."""
|
||||||
_login_session(client)
|
_login_session(client)
|
||||||
mock_decoder = MagicMock()
|
mock_decoder = MagicMock()
|
||||||
mock_decoder.is_running = False
|
mock_decoder.is_running = False
|
||||||
mock_decoder.start.return_value = True
|
mock_decoder.start.return_value = True
|
||||||
|
|
||||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||||
@@ -411,46 +411,46 @@ class TestWeFaxRoutes:
|
|||||||
content_type='application/json',
|
content_type='application/json',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert data['status'] == 'started'
|
assert data['status'] == 'started'
|
||||||
assert data['frequency_khz'] == 4298
|
assert data['frequency_khz'] == 4298
|
||||||
assert data['usb_offset_applied'] is True
|
assert data['usb_offset_applied'] is True
|
||||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||||
assert data['frequency_reference'] == 'carrier'
|
assert data['frequency_reference'] == 'carrier'
|
||||||
assert data['station'] == 'NOJ'
|
assert data['station'] == 'NOJ'
|
||||||
mock_decoder.start.assert_called_once()
|
mock_decoder.start.assert_called_once()
|
||||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||||
assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||||
|
|
||||||
def test_start_respects_dial_reference_override(self, client):
|
def test_start_respects_dial_reference_override(self, client):
|
||||||
"""POST /wefax/start with dial reference should not apply USB offset."""
|
"""POST /wefax/start with dial reference should not apply USB offset."""
|
||||||
_login_session(client)
|
_login_session(client)
|
||||||
mock_decoder = MagicMock()
|
mock_decoder = MagicMock()
|
||||||
mock_decoder.is_running = False
|
mock_decoder.is_running = False
|
||||||
mock_decoder.start.return_value = True
|
mock_decoder.start.return_value = True
|
||||||
|
|
||||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \
|
||||||
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
|
patch('routes.wefax.app_module.claim_sdr_device', return_value=None):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/wefax/start',
|
'/wefax/start',
|
||||||
data=json.dumps({
|
data=json.dumps({
|
||||||
'frequency_khz': 4298,
|
'frequency_khz': 4298,
|
||||||
'station': 'NOJ',
|
'station': 'NOJ',
|
||||||
'device': 0,
|
'device': 0,
|
||||||
'frequency_reference': 'dial',
|
'frequency_reference': 'dial',
|
||||||
}),
|
}),
|
||||||
content_type='application/json',
|
content_type='application/json',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert data['status'] == 'started'
|
assert data['status'] == 'started'
|
||||||
assert data['usb_offset_applied'] is False
|
assert data['usb_offset_applied'] is False
|
||||||
assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6)
|
assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||||
assert data['frequency_reference'] == 'dial'
|
assert data['frequency_reference'] == 'dial'
|
||||||
start_kwargs = mock_decoder.start.call_args.kwargs
|
start_kwargs = mock_decoder.start.call_args.kwargs
|
||||||
assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6)
|
assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6)
|
||||||
|
|
||||||
def test_start_device_busy(self, client):
|
def test_start_device_busy(self, client):
|
||||||
"""POST /wefax/start should return 409 when device is busy."""
|
"""POST /wefax/start should return 409 when device is busy."""
|
||||||
@@ -509,83 +509,83 @@ class TestWeFaxRoutes:
|
|||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
def test_delete_image_wrong_extension(self, client):
|
def test_delete_image_wrong_extension(self, client):
|
||||||
"""DELETE /wefax/images/<filename> should reject non-PNG."""
|
"""DELETE /wefax/images/<filename> should reject non-PNG."""
|
||||||
_login_session(client)
|
_login_session(client)
|
||||||
mock_decoder = MagicMock()
|
mock_decoder = MagicMock()
|
||||||
|
|
||||||
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder):
|
||||||
response = client.delete('/wefax/images/test.jpg')
|
response = client.delete('/wefax/images/test.jpg')
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
def test_schedule_enable_applies_usb_alignment(self, client):
|
def test_schedule_enable_applies_usb_alignment(self, client):
|
||||||
"""Scheduler should receive tuned USB dial frequency in auto mode."""
|
"""Scheduler should receive tuned USB dial frequency in auto mode."""
|
||||||
_login_session(client)
|
_login_session(client)
|
||||||
mock_scheduler = MagicMock()
|
mock_scheduler = MagicMock()
|
||||||
mock_scheduler.enable.return_value = {
|
mock_scheduler.enable.return_value = {
|
||||||
'enabled': True,
|
'enabled': True,
|
||||||
'scheduled_count': 2,
|
'scheduled_count': 2,
|
||||||
'total_broadcasts': 2,
|
'total_broadcasts': 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler):
|
with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/wefax/schedule/enable',
|
'/wefax/schedule/enable',
|
||||||
data=json.dumps({
|
data=json.dumps({
|
||||||
'station': 'NOJ',
|
'station': 'NOJ',
|
||||||
'frequency_khz': 4298,
|
'frequency_khz': 4298,
|
||||||
'device': 0,
|
'device': 0,
|
||||||
}),
|
}),
|
||||||
content_type='application/json',
|
content_type='application/json',
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert data['status'] == 'ok'
|
assert data['status'] == 'ok'
|
||||||
assert data['usb_offset_applied'] is True
|
assert data['usb_offset_applied'] is True
|
||||||
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||||
enable_kwargs = mock_scheduler.enable.call_args.kwargs
|
enable_kwargs = mock_scheduler.enable.call_args.kwargs
|
||||||
assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6)
|
||||||
|
|
||||||
|
|
||||||
class TestWeFaxProgressCallback:
|
class TestWeFaxProgressCallback:
|
||||||
"""Regression tests for WeFax route-level progress callback behavior."""
|
"""Regression tests for WeFax route-level progress callback behavior."""
|
||||||
|
|
||||||
def test_terminal_progress_releases_active_device(self):
|
def test_terminal_progress_releases_active_device(self):
|
||||||
"""Terminal decoder events must release any manually claimed SDR."""
|
"""Terminal decoder events must release any manually claimed SDR."""
|
||||||
import routes.wefax as wefax_routes
|
import routes.wefax as wefax_routes
|
||||||
|
|
||||||
original_device = wefax_routes.wefax_active_device
|
original_device = wefax_routes.wefax_active_device
|
||||||
try:
|
try:
|
||||||
wefax_routes.wefax_active_device = 3
|
wefax_routes.wefax_active_device = 3
|
||||||
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||||
wefax_routes._progress_callback({
|
wefax_routes._progress_callback({
|
||||||
'type': 'wefax_progress',
|
'type': 'wefax_progress',
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'decode failed',
|
'message': 'decode failed',
|
||||||
})
|
})
|
||||||
|
|
||||||
mock_release.assert_called_once_with(3)
|
mock_release.assert_called_once_with(3, 'rtlsdr')
|
||||||
assert wefax_routes.wefax_active_device is None
|
assert wefax_routes.wefax_active_device is None
|
||||||
finally:
|
finally:
|
||||||
wefax_routes.wefax_active_device = original_device
|
wefax_routes.wefax_active_device = original_device
|
||||||
|
|
||||||
def test_non_terminal_progress_does_not_release_active_device(self):
|
def test_non_terminal_progress_does_not_release_active_device(self):
|
||||||
"""Non-terminal progress updates must not release SDR ownership."""
|
"""Non-terminal progress updates must not release SDR ownership."""
|
||||||
import routes.wefax as wefax_routes
|
import routes.wefax as wefax_routes
|
||||||
|
|
||||||
original_device = wefax_routes.wefax_active_device
|
original_device = wefax_routes.wefax_active_device
|
||||||
try:
|
try:
|
||||||
wefax_routes.wefax_active_device = 4
|
wefax_routes.wefax_active_device = 4
|
||||||
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
with patch('routes.wefax.app_module.release_sdr_device') as mock_release:
|
||||||
wefax_routes._progress_callback({
|
wefax_routes._progress_callback({
|
||||||
'type': 'wefax_progress',
|
'type': 'wefax_progress',
|
||||||
'status': 'receiving',
|
'status': 'receiving',
|
||||||
'line_count': 120,
|
'line_count': 120,
|
||||||
})
|
})
|
||||||
|
|
||||||
mock_release.assert_not_called()
|
mock_release.assert_not_called()
|
||||||
assert wefax_routes.wefax_active_device == 4
|
assert wefax_routes.wefax_active_device == 4
|
||||||
finally:
|
finally:
|
||||||
wefax_routes.wefax_active_device = original_device
|
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
|
# DEAUTH ATTACK DETECTION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,49 +1,57 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.dependencies')
|
logger = logging.getLogger('intercept.dependencies')
|
||||||
|
|
||||||
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
|
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
|
||||||
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
|
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:
|
def check_tool(name: str) -> bool:
|
||||||
"""Check if a tool is installed."""
|
"""Check if a tool is installed."""
|
||||||
return get_tool_path(name) is not None
|
return get_tool_path(name) is not None
|
||||||
|
|
||||||
|
|
||||||
def get_tool_path(name: str) -> str | None:
|
def get_tool_path(name: str) -> str | None:
|
||||||
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
"""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
|
# Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
|
||||||
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
|
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
|
||||||
env_path = os.environ.get(env_key)
|
env_path = os.environ.get(env_key)
|
||||||
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
||||||
return env_path
|
return env_path
|
||||||
|
|
||||||
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
|
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
|
||||||
# /usr/local tools with arm64 Python/runtime.
|
# /usr/local tools with arm64 Python/runtime.
|
||||||
if platform.system() == 'Darwin':
|
if platform.system() == 'Darwin':
|
||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
preferred_paths: list[str] = []
|
preferred_paths: list[str] = []
|
||||||
if machine in {'arm64', 'aarch64'}:
|
if machine in {'arm64', 'aarch64'}:
|
||||||
preferred_paths.append('/opt/homebrew/bin')
|
preferred_paths.append('/opt/homebrew/bin')
|
||||||
preferred_paths.append('/usr/local/bin')
|
preferred_paths.append('/usr/local/bin')
|
||||||
|
|
||||||
for base in preferred_paths:
|
for base in preferred_paths:
|
||||||
full_path = os.path.join(base, name)
|
full_path = os.path.join(base, name)
|
||||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||||
return full_path
|
return full_path
|
||||||
|
|
||||||
# First check standard PATH
|
# First check standard PATH
|
||||||
path = shutil.which(name)
|
path = shutil.which(name)
|
||||||
if path:
|
if path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
||||||
for extra_path in EXTRA_TOOL_PATHS:
|
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):
|
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||||
return full_path
|
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
|
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': {
|
'tscm': {
|
||||||
'name': 'TSCM Counter-Surveillance',
|
'name': 'TSCM Counter-Surveillance',
|
||||||
'tools': {
|
'tools': {
|
||||||
|
|||||||
@@ -6,31 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .base import SDRCapabilities, SDRDevice, SDRType
|
from .base import SDRCapabilities, SDRDevice, SDRType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Cache HackRF detection results so polling endpoints don't repeatedly run
|
# Cache HackRF detection results so polling endpoints don't repeatedly run
|
||||||
# hackrf_info while the device is actively streaming in SubGHz mode.
|
# hackrf_info while the device is actively streaming in SubGHz mode.
|
||||||
_hackrf_cache: list[SDRDevice] = []
|
_hackrf_cache: list[SDRDevice] = []
|
||||||
_hackrf_cache_ts: float = 0.0
|
_hackrf_cache_ts: float = 0.0
|
||||||
_HACKRF_CACHE_TTL_SECONDS = 3.0
|
_HACKRF_CACHE_TTL_SECONDS = 3.0
|
||||||
|
|
||||||
|
|
||||||
def _hackrf_probe_blocked() -> bool:
|
def _hackrf_probe_blocked() -> bool:
|
||||||
"""Return True when probing HackRF would interfere with an active stream."""
|
"""Return True when probing HackRF would interfere with an active stream."""
|
||||||
try:
|
try:
|
||||||
from utils.subghz import get_subghz_manager
|
from utils.subghz import get_subghz_manager
|
||||||
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
|
return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'}
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _check_tool(name: str) -> bool:
|
def _check_tool(name: str) -> bool:
|
||||||
@@ -112,21 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
||||||
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
||||||
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
|
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['rtl_test', '-t'],
|
['rtl_test', '-t'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
encoding='utf-8',
|
encoding='utf-8',
|
||||||
errors='replace',
|
errors='replace',
|
||||||
timeout=5,
|
timeout=5,
|
||||||
env=env
|
env=env
|
||||||
)
|
)
|
||||||
output = result.stderr + result.stdout
|
output = result.stderr + result.stdout
|
||||||
|
|
||||||
# Parse device info from rtl_test output
|
# Parse device info from rtl_test output
|
||||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||||
# Require a non-empty serial to avoid matching malformed lines like "SN:".
|
# Require a non-empty serial to avoid matching malformed lines like "SN:".
|
||||||
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
|
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
|
||||||
|
|
||||||
from .rtlsdr import RTLSDRCommandBuilder
|
from .rtlsdr import RTLSDRCommandBuilder
|
||||||
|
|
||||||
@@ -134,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
line = line.strip()
|
line = line.strip()
|
||||||
match = re.match(device_pattern, line)
|
match = re.match(device_pattern, line)
|
||||||
if match:
|
if match:
|
||||||
devices.append(SDRDevice(
|
devices.append(SDRDevice(
|
||||||
sdr_type=SDRType.RTL_SDR,
|
sdr_type=SDRType.RTL_SDR,
|
||||||
index=int(match.group(1)),
|
index=int(match.group(1)),
|
||||||
name=match.group(2).strip().rstrip(','),
|
name=match.group(2).strip().rstrip(','),
|
||||||
serial=match.group(3),
|
serial=match.group(3),
|
||||||
driver='rtlsdr',
|
driver='rtlsdr',
|
||||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||||
))
|
))
|
||||||
|
|
||||||
# Fallback: if we found devices but couldn't parse details
|
# Fallback: if we found devices but couldn't parse details
|
||||||
if not devices:
|
if not devices:
|
||||||
@@ -314,29 +314,29 @@ def _add_soapy_device(
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
def detect_hackrf_devices() -> list[SDRDevice]:
|
def detect_hackrf_devices() -> list[SDRDevice]:
|
||||||
"""
|
"""
|
||||||
Detect HackRF devices using native hackrf_info tool.
|
Detect HackRF devices using native hackrf_info tool.
|
||||||
|
|
||||||
Fallback for when SoapySDR is not available.
|
Fallback for when SoapySDR is not available.
|
||||||
"""
|
"""
|
||||||
global _hackrf_cache, _hackrf_cache_ts
|
global _hackrf_cache, _hackrf_cache_ts
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
|
# While HackRF is actively streaming in SubGHz mode, skip probe calls.
|
||||||
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
|
# Re-running hackrf_info during active RX/TX can disrupt the USB stream.
|
||||||
if _hackrf_probe_blocked():
|
if _hackrf_probe_blocked():
|
||||||
return list(_hackrf_cache)
|
return list(_hackrf_cache)
|
||||||
|
|
||||||
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
|
if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS:
|
||||||
return list(_hackrf_cache)
|
return list(_hackrf_cache)
|
||||||
|
|
||||||
devices: list[SDRDevice] = []
|
devices: list[SDRDevice] = []
|
||||||
|
|
||||||
if not _check_tool('hackrf_info'):
|
if not _check_tool('hackrf_info'):
|
||||||
_hackrf_cache = devices
|
_hackrf_cache = devices
|
||||||
_hackrf_cache_ts = now
|
_hackrf_cache_ts = now
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -374,12 +374,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
|||||||
capabilities=HackRFCommandBuilder.CAPABILITIES
|
capabilities=HackRFCommandBuilder.CAPABILITIES
|
||||||
))
|
))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"HackRF detection error: {e}")
|
logger.debug(f"HackRF detection error: {e}")
|
||||||
|
|
||||||
_hackrf_cache = list(devices)
|
_hackrf_cache = list(devices)
|
||||||
_hackrf_cache_ts = now
|
_hackrf_cache_ts = now
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
def probe_rtlsdr_device(device_index: int) -> str | None:
|
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
|
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'],
|
['rtl_test', '-d', str(device_index), '-t'],
|
||||||
capture_output=True,
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=3,
|
|
||||||
env=env,
|
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(
|
logger.warning(
|
||||||
f"RTL-SDR device {device_index} USB probe failed: "
|
f"RTL-SDR device {device_index} USB probe failed: "
|
||||||
f"device busy or unavailable"
|
f"device busy or unavailable"
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
f'SDR device {device_index} is busy at the USB level — '
|
f'SDR device {device_index} is not available — '
|
||||||
f'another process outside INTERCEPT may be using it. '
|
f'check that the RTL-SDR is connected and not in use by another process.'
|
||||||
f'Check for stale rtl_fm/rtl_433/dump1090 processes, '
|
|
||||||
f'or try a different device.'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")
|
logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user