mirror of
https://github.com/smittix/intercept.git
synced 2026-07-28 18:48:10 -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:
@@ -19,6 +19,7 @@ def register_blueprints(app):
|
||||
from .morse import morse_bp
|
||||
from .offline import offline_bp
|
||||
from .pager import pager_bp
|
||||
from .radiosonde import radiosonde_bp
|
||||
from .recordings import recordings_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .satellite import satellite_bp
|
||||
@@ -76,6 +77,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
||||
app.register_blueprint(morse_bp) # CW/Morse code decoder
|
||||
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
||||
app.register_blueprint(system_bp) # System health monitoring
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
|
||||
+18
-10
@@ -48,6 +48,7 @@ acars_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
acars_active_device: int | None = None
|
||||
acars_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def find_acarsdec():
|
||||
@@ -164,7 +165,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
logger.error(f"ACARS stream error: {e}")
|
||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global acars_active_device
|
||||
global acars_active_device, acars_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
@@ -180,8 +181,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
app_module.acars_process = None
|
||||
# Release SDR device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
|
||||
|
||||
@acars_bp.route('/tools')
|
||||
@@ -213,7 +215,7 @@ def acars_status() -> Response:
|
||||
@acars_bp.route('/start', methods=['POST'])
|
||||
def start_acars() -> Response:
|
||||
"""Start ACARS decoder."""
|
||||
global acars_message_count, acars_last_message_time, acars_active_device
|
||||
global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
@@ -240,9 +242,12 @@ def start_acars() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'acars')
|
||||
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -251,6 +256,7 @@ def start_acars() -> Response:
|
||||
}), 409
|
||||
|
||||
acars_active_device = device_int
|
||||
acars_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||
@@ -268,8 +274,6 @@ def start_acars() -> Response:
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -356,8 +360,9 @@ def start_acars() -> Response:
|
||||
if process.poll() is not None:
|
||||
# Process died - release device
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
@@ -388,8 +393,9 @@ def start_acars() -> Response:
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -397,7 +403,7 @@ def start_acars() -> Response:
|
||||
@acars_bp.route('/stop', methods=['POST'])
|
||||
def stop_acars() -> Response:
|
||||
"""Stop ACARS decoder."""
|
||||
global acars_active_device
|
||||
global acars_active_device, acars_active_sdr_type
|
||||
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
@@ -418,8 +424,9 @@ def stop_acars() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if acars_active_device is not None:
|
||||
app_module.release_sdr_device(acars_active_device)
|
||||
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||
acars_active_device = None
|
||||
acars_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -445,6 +452,7 @@ def stream_acars() -> Response:
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@acars_bp.route('/messages')
|
||||
def get_acars_messages() -> Response:
|
||||
"""Get recent ACARS messages from correlator (for history reload)."""
|
||||
|
||||
+29
-8
@@ -72,6 +72,7 @@ adsb_last_message_time = None
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
adsb_active_device = None # Track which device index is being used
|
||||
adsb_active_sdr_type: str | None = None
|
||||
_sbs_error_logged = False # Suppress repeated connection error logs
|
||||
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
@@ -674,7 +675,7 @@ def adsb_session():
|
||||
@adsb_bp.route('/start', methods=['POST'])
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if adsb_using_service:
|
||||
@@ -757,6 +758,7 @@ def start_adsb():
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
sdr_type_str = sdr_type.value
|
||||
|
||||
# For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
@@ -787,7 +789,7 @@ def start_adsb():
|
||||
|
||||
# Check if device is available before starting local dump1090
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'adsb')
|
||||
error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -795,6 +797,10 @@ def start_adsb():
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
# Track claimed device immediately so stop_adsb() can always release it
|
||||
adsb_active_device = device
|
||||
adsb_active_sdr_type = sdr_type_str
|
||||
|
||||
# Create device object and build command via abstraction layer
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
@@ -821,11 +827,24 @@ def start_adsb():
|
||||
)
|
||||
write_dump1090_pid(app_module.adsb_process.pid)
|
||||
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
# Poll for dump1090 readiness instead of blind sleep
|
||||
dump1090_ready = False
|
||||
poll_interval = 0.1
|
||||
elapsed = 0.0
|
||||
while elapsed < DUMP1090_START_WAIT:
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
break # Process exited early — handle below
|
||||
if check_dump1090_service():
|
||||
dump1090_ready = True
|
||||
break
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
# Process exited - release device and get error message
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
adsb_active_device = None
|
||||
adsb_active_sdr_type = None
|
||||
stderr_output = ''
|
||||
if app_module.adsb_process.stderr:
|
||||
try:
|
||||
@@ -871,7 +890,6 @@ def start_adsb():
|
||||
})
|
||||
|
||||
adsb_using_service = True
|
||||
adsb_active_device = device # Track which device is being used
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
@@ -891,14 +909,16 @@ def start_adsb():
|
||||
})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
adsb_active_device = None
|
||||
adsb_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
global adsb_using_service, adsb_active_device, adsb_active_sdr_type
|
||||
data = request.get_json(silent=True) or {}
|
||||
stop_source = data.get('source')
|
||||
stopped_by = request.remote_addr
|
||||
@@ -923,10 +943,11 @@ def stop_adsb():
|
||||
|
||||
# Release device from registry
|
||||
if adsb_active_device is not None:
|
||||
app_module.release_sdr_device(adsb_active_device)
|
||||
app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr')
|
||||
|
||||
adsb_using_service = False
|
||||
adsb_active_device = None
|
||||
adsb_active_sdr_type = None
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
|
||||
+9
-6
@@ -44,6 +44,7 @@ ais_connected = False
|
||||
ais_messages_received = 0
|
||||
ais_last_message_time = None
|
||||
ais_active_device = None
|
||||
ais_active_sdr_type: str | None = None
|
||||
_ais_error_logged = True
|
||||
|
||||
# Common installation paths for AIS-catcher
|
||||
@@ -350,7 +351,7 @@ def ais_status():
|
||||
@ais_bp.route('/start', methods=['POST'])
|
||||
def start_ais():
|
||||
"""Start AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
global ais_running, ais_active_device, ais_active_sdr_type
|
||||
|
||||
with app_module.ais_lock:
|
||||
if ais_running:
|
||||
@@ -397,7 +398,7 @@ def start_ais():
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'ais')
|
||||
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -436,7 +437,7 @@ def start_ais():
|
||||
|
||||
if app_module.ais_process.poll() is not None:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
stderr_output = ''
|
||||
if app_module.ais_process.stderr:
|
||||
try:
|
||||
@@ -450,6 +451,7 @@ def start_ais():
|
||||
|
||||
ais_running = True
|
||||
ais_active_device = device
|
||||
ais_active_sdr_type = sdr_type_str
|
||||
|
||||
# Start TCP parser thread
|
||||
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||
@@ -463,7 +465,7 @@ def start_ais():
|
||||
})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -471,7 +473,7 @@ def start_ais():
|
||||
@ais_bp.route('/stop', methods=['POST'])
|
||||
def stop_ais():
|
||||
"""Stop AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
global ais_running, ais_active_device, ais_active_sdr_type
|
||||
|
||||
with app_module.ais_lock:
|
||||
if app_module.ais_process:
|
||||
@@ -490,10 +492,11 @@ def stop_ais():
|
||||
|
||||
# Release device from registry
|
||||
if ais_active_device is not None:
|
||||
app_module.release_sdr_device(ais_active_device)
|
||||
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
|
||||
|
||||
ais_running = False
|
||||
ais_active_device = None
|
||||
ais_active_sdr_type = None
|
||||
|
||||
app_module.ais_vessels.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
+161
-98
@@ -5,8 +5,10 @@ from __future__ import annotations
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -35,6 +37,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
|
||||
# Track which SDR device is being used
|
||||
aprs_active_device: int | None = None
|
||||
aprs_active_sdr_type: str | None = None
|
||||
|
||||
# APRS frequencies by region (MHz)
|
||||
APRS_FREQUENCIES = {
|
||||
@@ -103,6 +106,9 @@ ADEVICE stdin null
|
||||
CHANNEL 0
|
||||
MYCALL N0CALL
|
||||
MODEM 1200
|
||||
FIX_BITS 1
|
||||
AGWPORT 0
|
||||
KISSPORT 0
|
||||
"""
|
||||
with open(DIREWOLF_CONFIG_PATH, 'w') as f:
|
||||
f.write(config)
|
||||
@@ -1437,19 +1443,19 @@ def should_send_meter_update(level: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||
def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||
"""Stream decoded APRS packets and audio level meter to queue.
|
||||
|
||||
This function reads from the decoder's stdout (text mode, line-buffered).
|
||||
The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks.
|
||||
rtl_fm's stderr is captured via PIPE with a monitor thread.
|
||||
Reads from a PTY master fd to get line-buffered output from the decoder,
|
||||
avoiding the 15-minute pipe buffering delay. Uses select() + os.read()
|
||||
to poll the PTY (same pattern as pager.py).
|
||||
|
||||
Outputs two types of messages to the queue:
|
||||
- type='aprs': Decoded APRS packets
|
||||
- type='meter': Audio level meter readings (rate-limited)
|
||||
"""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global _last_meter_time, _last_meter_level, aprs_active_device
|
||||
global _last_meter_time, _last_meter_level, aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
# Capture the device claimed by THIS session so the finally block only
|
||||
# releases our own device, not one claimed by a subsequent start.
|
||||
@@ -1462,93 +1468,114 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
try:
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Read line-by-line in binary mode. Empty bytes b'' signals EOF.
|
||||
# Decode with errors='replace' so corrupted radio bytes (e.g. 0xf7)
|
||||
# never crash the stream.
|
||||
for raw in iter(decoder_process.stdout.readline, b''):
|
||||
line = raw.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
# Read from PTY using select() for non-blocking reads.
|
||||
# PTY forces the decoder to line-buffer, so output arrives immediately
|
||||
# instead of waiting for a full 4-8KB pipe buffer to fill.
|
||||
buffer = ""
|
||||
while True:
|
||||
try:
|
||||
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
# Check for audio level line first (for signal meter)
|
||||
audio_level = parse_audio_level(line)
|
||||
if audio_level is not None:
|
||||
if should_send_meter_update(audio_level):
|
||||
meter_msg = {
|
||||
'type': 'meter',
|
||||
'level': audio_level,
|
||||
'ts': datetime.utcnow().isoformat() + 'Z'
|
||||
}
|
||||
app_module.aprs_queue.put(meter_msg)
|
||||
continue # Audio level lines are not packets
|
||||
if ready:
|
||||
try:
|
||||
data = os.read(master_fd, 1024)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode('utf-8', errors='replace')
|
||||
except OSError:
|
||||
break
|
||||
|
||||
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
||||
line = normalize_aprs_output_line(line)
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||
if '>' not in line or ':' not in line:
|
||||
continue
|
||||
# Check for audio level line first (for signal meter)
|
||||
audio_level = parse_audio_level(line)
|
||||
if audio_level is not None:
|
||||
if should_send_meter_update(audio_level):
|
||||
meter_msg = {
|
||||
'type': 'meter',
|
||||
'level': audio_level,
|
||||
'ts': datetime.utcnow().isoformat() + 'Z'
|
||||
}
|
||||
app_module.aprs_queue.put(meter_msg)
|
||||
continue # Audio level lines are not packets
|
||||
|
||||
packet = parse_aprs_packet(line)
|
||||
if packet:
|
||||
aprs_packet_count += 1
|
||||
aprs_last_packet_time = time.time()
|
||||
# Normalize decoder prefixes (multimon/direwolf) before parsing.
|
||||
line = normalize_aprs_output_line(line)
|
||||
|
||||
# Track unique stations
|
||||
callsign = packet.get('callsign')
|
||||
if callsign and callsign not in aprs_stations:
|
||||
aprs_station_count += 1
|
||||
# Skip non-packet lines (APRS format: CALL>PATH:DATA)
|
||||
if '>' not in line or ':' not in line:
|
||||
continue
|
||||
|
||||
# Update station data, preserving last known coordinates when
|
||||
# packets do not contain position fields.
|
||||
if callsign:
|
||||
existing = aprs_stations.get(callsign, {})
|
||||
packet_lat = packet.get('lat')
|
||||
packet_lon = packet.get('lon')
|
||||
aprs_stations[callsign] = {
|
||||
'callsign': callsign,
|
||||
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
|
||||
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
|
||||
'symbol': packet.get('symbol') or existing.get('symbol'),
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Geofence check
|
||||
_aprs_lat = packet_lat
|
||||
_aprs_lon = packet_lon
|
||||
if _aprs_lat is not None and _aprs_lon is not None:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||
{'callsign': callsign}
|
||||
):
|
||||
process_event('aprs', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
aprs_stations,
|
||||
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||
)
|
||||
del aprs_stations[oldest]
|
||||
packet = parse_aprs_packet(line)
|
||||
if packet:
|
||||
aprs_packet_count += 1
|
||||
aprs_last_packet_time = time.time()
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
# Track unique stations
|
||||
callsign = packet.get('callsign')
|
||||
if callsign and callsign not in aprs_stations:
|
||||
aprs_station_count += 1
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
# Update station data, preserving last known coordinates when
|
||||
# packets do not contain position fields.
|
||||
if callsign:
|
||||
existing = aprs_stations.get(callsign, {})
|
||||
packet_lat = packet.get('lat')
|
||||
packet_lon = packet.get('lon')
|
||||
aprs_stations[callsign] = {
|
||||
'callsign': callsign,
|
||||
'lat': packet_lat if packet_lat is not None else existing.get('lat'),
|
||||
'lon': packet_lon if packet_lon is not None else existing.get('lon'),
|
||||
'symbol': packet.get('symbol') or existing.get('symbol'),
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Geofence check
|
||||
_aprs_lat = packet_lat
|
||||
_aprs_lon = packet_lon
|
||||
if _aprs_lat is not None and _aprs_lon is not None:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||
{'callsign': callsign}
|
||||
):
|
||||
process_event('aprs', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
aprs_stations,
|
||||
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||
)
|
||||
del aprs_stations[oldest]
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
@@ -1562,8 +1589,9 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
pass
|
||||
# Release SDR device — only if it's still ours (not reclaimed by a new start)
|
||||
if my_device is not None and aprs_active_device == my_device:
|
||||
app_module.release_sdr_device(my_device)
|
||||
app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
@@ -1632,7 +1660,7 @@ def aprs_data() -> Response:
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
global aprs_active_device
|
||||
global aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
with app_module.aprs_lock:
|
||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||
@@ -1681,7 +1709,7 @@ def start_aprs() -> Response:
|
||||
}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs')
|
||||
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -1689,6 +1717,7 @@ def start_aprs() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
aprs_active_device = device
|
||||
aprs_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequency for region
|
||||
region = data.get('region', 'north_america')
|
||||
@@ -1730,8 +1759,9 @@ def start_aprs() -> Response:
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build decoder command
|
||||
@@ -1785,19 +1815,25 @@ def start_aprs() -> Response:
|
||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True)
|
||||
rtl_stderr_thread.start()
|
||||
|
||||
# Create a pseudo-terminal for decoder output. PTY forces the
|
||||
# decoder to line-buffer its stdout, avoiding the 15-minute delay
|
||||
# caused by full pipe buffering (~4-8KB) on small APRS packets.
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
# Start decoder with stdin wired to rtl_fm's stdout.
|
||||
# Use binary mode to avoid UnicodeDecodeError on raw/corrupted bytes
|
||||
# from the radio decoder (e.g. 0xf7). Lines are decoded manually
|
||||
# in stream_aprs_output with errors='replace'.
|
||||
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||
# stdout/stderr go to the PTY slave so output is line-buffered.
|
||||
decoder_process = subprocess.Popen(
|
||||
decoder_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=PIPE,
|
||||
stderr=STDOUT,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Close slave fd in parent — decoder owns it now.
|
||||
os.close(slave_fd)
|
||||
|
||||
# Close rtl_fm's stdout in parent so decoder owns it exclusively.
|
||||
# This ensures proper EOF propagation when rtl_fm terminates.
|
||||
rtl_process.stdout.close()
|
||||
@@ -1818,40 +1854,57 @@ def start_aprs() -> Response:
|
||||
if stderr_output:
|
||||
error_msg += f': {stderr_output[:200]}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
decoder_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
if decoder_process.poll() is not None:
|
||||
# Decoder exited early - capture any output
|
||||
raw_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else b''
|
||||
error_output = raw_output.decode('utf-8', errors='replace') if raw_output else ''
|
||||
# Decoder exited early - capture any output from PTY
|
||||
error_output = ''
|
||||
try:
|
||||
ready, _, _ = select.select([master_fd], [], [], 0.5)
|
||||
if ready:
|
||||
raw = os.read(master_fd, 500)
|
||||
error_output = raw.decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = f'{decoder_name} failed to start'
|
||||
if error_output:
|
||||
error_msg += f': {error_output}'
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
# Store references for status checks and cleanup
|
||||
app_module.aprs_process = decoder_process
|
||||
app_module.aprs_rtl_process = rtl_process
|
||||
app_module.aprs_master_fd = master_fd
|
||||
|
||||
# Start background thread to read decoder output and push to queue
|
||||
thread = threading.Thread(
|
||||
target=stream_aprs_output,
|
||||
args=(rtl_process, decoder_process),
|
||||
args=(master_fd, rtl_process, decoder_process),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
@@ -1868,15 +1921,16 @@ def start_aprs() -> Response:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
global aprs_active_device
|
||||
global aprs_active_device, aprs_active_sdr_type
|
||||
|
||||
with app_module.aprs_lock:
|
||||
processes_to_stop = []
|
||||
@@ -1902,14 +1956,23 @@ def stop_aprs() -> Response:
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping APRS process: {e}")
|
||||
|
||||
# Close PTY master fd
|
||||
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
|
||||
try:
|
||||
os.close(app_module.aprs_master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
app_module.aprs_master_fd = None
|
||||
|
||||
app_module.aprs_process = None
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
|
||||
# Release SDR device
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||
aprs_active_device = None
|
||||
aprs_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
+17
-8
@@ -51,6 +51,7 @@ dsc_running = False
|
||||
|
||||
# Track which device is being used
|
||||
dsc_active_device: int | None = None
|
||||
dsc_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def _get_dsc_decoder_path() -> str | None:
|
||||
@@ -171,7 +172,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
'error': str(e)
|
||||
})
|
||||
finally:
|
||||
global dsc_active_device
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
@@ -197,8 +198,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non
|
||||
app_module.dsc_rtl_process = None
|
||||
# Release SDR device
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
|
||||
|
||||
def _store_critical_alert(msg: dict) -> None:
|
||||
@@ -331,10 +333,13 @@ def start_decoding() -> Response:
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Check if device is available using centralized registry
|
||||
global dsc_active_device
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -343,6 +348,7 @@ def start_decoding() -> Response:
|
||||
}), 409
|
||||
|
||||
dsc_active_device = device_int
|
||||
dsc_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.dsc_queue.empty():
|
||||
@@ -440,8 +446,9 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Tool not found: {e.filename}'
|
||||
@@ -458,8 +465,9 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
logger.error(f"Failed to start DSC decoder: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -470,7 +478,7 @@ def start_decoding() -> Response:
|
||||
@dsc_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
"""Stop DSC decoder."""
|
||||
global dsc_running, dsc_active_device
|
||||
global dsc_running, dsc_active_device, dsc_active_sdr_type
|
||||
|
||||
with app_module.dsc_lock:
|
||||
if not app_module.dsc_process:
|
||||
@@ -509,8 +517,9 @@ def stop_decoding() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||
dsc_active_device = None
|
||||
dsc_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
+35
-19
@@ -55,7 +55,9 @@ scanner_lock = threading.Lock()
|
||||
scanner_paused = False
|
||||
scanner_current_freq = 0.0
|
||||
scanner_active_device: Optional[int] = None
|
||||
scanner_active_sdr_type: str = 'rtlsdr'
|
||||
receiver_active_device: Optional[int] = None
|
||||
receiver_active_sdr_type: str = 'rtlsdr'
|
||||
scanner_power_process: Optional[subprocess.Popen] = None
|
||||
scanner_config = {
|
||||
'start_freq': 88.0,
|
||||
@@ -996,7 +998,7 @@ def check_tools() -> Response:
|
||||
@receiver_bp.route('/scanner/start', methods=['POST'])
|
||||
def start_scanner() -> Response:
|
||||
"""Start the frequency scanner."""
|
||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, receiver_active_device
|
||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type
|
||||
|
||||
with scanner_lock:
|
||||
if scanner_running:
|
||||
@@ -1063,10 +1065,11 @@ def start_scanner() -> Response:
|
||||
}), 503
|
||||
# Release listening device if active
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
# Claim device for scanner
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -1074,6 +1077,7 @@ def start_scanner() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
scanner_active_device = scanner_config['device']
|
||||
scanner_active_sdr_type = scanner_config['sdr_type']
|
||||
scanner_running = True
|
||||
scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
||||
scanner_thread.start()
|
||||
@@ -1091,9 +1095,10 @@ def start_scanner() -> Response:
|
||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||
}), 503
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -1101,6 +1106,7 @@ def start_scanner() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
scanner_active_device = scanner_config['device']
|
||||
scanner_active_sdr_type = scanner_config['sdr_type']
|
||||
|
||||
scanner_running = True
|
||||
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
||||
@@ -1115,7 +1121,7 @@ def start_scanner() -> Response:
|
||||
@receiver_bp.route('/scanner/stop', methods=['POST'])
|
||||
def stop_scanner() -> Response:
|
||||
"""Stop the frequency scanner."""
|
||||
global scanner_running, scanner_active_device, scanner_power_process
|
||||
global scanner_running, scanner_active_device, scanner_active_sdr_type, scanner_power_process
|
||||
|
||||
scanner_running = False
|
||||
_stop_audio_stream()
|
||||
@@ -1130,8 +1136,9 @@ def stop_scanner() -> Response:
|
||||
pass
|
||||
scanner_power_process = None
|
||||
if scanner_active_device is not None:
|
||||
app_module.release_sdr_device(scanner_active_device)
|
||||
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type)
|
||||
scanner_active_device = None
|
||||
scanner_active_sdr_type = 'rtlsdr'
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -1296,7 +1303,7 @@ def get_presets() -> Response:
|
||||
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||
def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
|
||||
global scanner_running, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type, scanner_power_process, scanner_thread
|
||||
global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token
|
||||
|
||||
data = request.json or {}
|
||||
@@ -1356,8 +1363,9 @@ def start_audio() -> Response:
|
||||
if scanner_running:
|
||||
scanner_running = False
|
||||
if scanner_active_device is not None:
|
||||
app_module.release_sdr_device(scanner_active_device)
|
||||
app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type)
|
||||
scanner_active_device = None
|
||||
scanner_active_sdr_type = 'rtlsdr'
|
||||
scanner_thread_ref = scanner_thread
|
||||
scanner_proc_ref = scanner_power_process
|
||||
scanner_power_process = None
|
||||
@@ -1419,8 +1427,9 @@ def start_audio() -> Response:
|
||||
audio_source = 'waterfall'
|
||||
# Shared monitor uses the waterfall's existing SDR claim.
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
@@ -1443,13 +1452,14 @@ def start_audio() -> Response:
|
||||
# to give the USB device time to be fully released.
|
||||
if receiver_active_device is None or receiver_active_device != device:
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
|
||||
error = None
|
||||
max_claim_attempts = 6
|
||||
for attempt in range(max_claim_attempts):
|
||||
error = app_module.claim_sdr_device(device, 'receiver')
|
||||
error = app_module.claim_sdr_device(device, 'receiver', sdr_type)
|
||||
if not error:
|
||||
break
|
||||
if attempt < max_claim_attempts - 1:
|
||||
@@ -1466,6 +1476,7 @@ def start_audio() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
receiver_active_device = device
|
||||
receiver_active_sdr_type = sdr_type
|
||||
|
||||
_start_audio_stream(
|
||||
frequency,
|
||||
@@ -1489,8 +1500,9 @@ def start_audio() -> Response:
|
||||
|
||||
# Avoid leaving a stale device claim after startup failure.
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
|
||||
start_error = ''
|
||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||
@@ -1515,11 +1527,12 @@ def start_audio() -> Response:
|
||||
@receiver_bp.route('/audio/stop', methods=['POST'])
|
||||
def stop_audio() -> Response:
|
||||
"""Stop audio."""
|
||||
global receiver_active_device
|
||||
global receiver_active_device, receiver_active_sdr_type
|
||||
_stop_audio_stream()
|
||||
if receiver_active_device is not None:
|
||||
app_module.release_sdr_device(receiver_active_device)
|
||||
app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type)
|
||||
receiver_active_device = None
|
||||
receiver_active_sdr_type = 'rtlsdr'
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -1825,6 +1838,7 @@ waterfall_running = False
|
||||
waterfall_lock = threading.Lock()
|
||||
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
waterfall_active_device: Optional[int] = None
|
||||
waterfall_active_sdr_type: str = 'rtlsdr'
|
||||
waterfall_config = {
|
||||
'start_freq': 88.0,
|
||||
'end_freq': 108.0,
|
||||
@@ -2033,7 +2047,7 @@ def _waterfall_loop():
|
||||
|
||||
def _stop_waterfall_internal() -> None:
|
||||
"""Stop the waterfall display and release resources."""
|
||||
global waterfall_running, waterfall_process, waterfall_active_device
|
||||
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
|
||||
|
||||
waterfall_running = False
|
||||
if waterfall_process and waterfall_process.poll() is None:
|
||||
@@ -2048,14 +2062,15 @@ def _stop_waterfall_internal() -> None:
|
||||
waterfall_process = None
|
||||
|
||||
if waterfall_active_device is not None:
|
||||
app_module.release_sdr_device(waterfall_active_device)
|
||||
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type)
|
||||
waterfall_active_device = None
|
||||
waterfall_active_sdr_type = 'rtlsdr'
|
||||
|
||||
|
||||
@receiver_bp.route('/waterfall/start', methods=['POST'])
|
||||
def start_waterfall() -> Response:
|
||||
"""Start the waterfall/spectrogram display."""
|
||||
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
|
||||
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device, waterfall_active_sdr_type
|
||||
|
||||
with waterfall_lock:
|
||||
if waterfall_running:
|
||||
@@ -2101,11 +2116,12 @@ def start_waterfall() -> Response:
|
||||
pass
|
||||
|
||||
# Claim SDR device
|
||||
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall')
|
||||
error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr')
|
||||
if error:
|
||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||
|
||||
waterfall_active_device = waterfall_config['device']
|
||||
waterfall_active_sdr_type = 'rtlsdr'
|
||||
waterfall_running = True
|
||||
waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
|
||||
waterfall_thread.start()
|
||||
|
||||
+18
-10
@@ -51,6 +51,7 @@ class _FilteredQueue:
|
||||
|
||||
# Track which device is being used
|
||||
morse_active_device: int | None = None
|
||||
morse_active_sdr_type: str | None = None
|
||||
|
||||
# Runtime lifecycle state.
|
||||
MORSE_IDLE = 'idle'
|
||||
@@ -231,7 +232,7 @@ def _snapshot_live_resources() -> list[str]:
|
||||
|
||||
@morse_bp.route('/morse/start', methods=['POST'])
|
||||
def start_morse() -> Response:
|
||||
global morse_active_device, morse_decoder_worker, morse_stderr_worker
|
||||
global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker
|
||||
global morse_stop_event, morse_control_queue, morse_runtime_config
|
||||
global morse_last_error, morse_session_id
|
||||
|
||||
@@ -261,6 +262,8 @@ def start_morse() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
with app_module.morse_lock:
|
||||
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
|
||||
return jsonify({
|
||||
@@ -270,7 +273,7 @@ def start_morse() -> Response:
|
||||
}), 409
|
||||
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'morse')
|
||||
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -279,6 +282,7 @@ def start_morse() -> Response:
|
||||
}), 409
|
||||
|
||||
morse_active_device = device_int
|
||||
morse_active_sdr_type = sdr_type_str
|
||||
morse_last_error = ''
|
||||
morse_session_id += 1
|
||||
|
||||
@@ -288,7 +292,6 @@ def start_morse() -> Response:
|
||||
sample_rate = 22050
|
||||
bias_t = _bool_value(data.get('bias_t', False), False)
|
||||
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -408,7 +411,7 @@ def start_morse() -> Response:
|
||||
for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1):
|
||||
if candidate_device_index != active_device_index:
|
||||
prev_device = active_device_index
|
||||
claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse')
|
||||
claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse', sdr_type_str)
|
||||
if claim_error:
|
||||
msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}'
|
||||
attempt_errors.append(msg)
|
||||
@@ -417,7 +420,7 @@ def start_morse() -> Response:
|
||||
continue
|
||||
|
||||
if prev_device is not None:
|
||||
app_module.release_sdr_device(prev_device)
|
||||
app_module.release_sdr_device(prev_device, morse_active_sdr_type or 'rtlsdr')
|
||||
active_device_index = candidate_device_index
|
||||
with app_module.morse_lock:
|
||||
morse_active_device = active_device_index
|
||||
@@ -634,8 +637,9 @@ def start_morse() -> Response:
|
||||
logger.error('Morse startup failed: %s', msg)
|
||||
with app_module.morse_lock:
|
||||
if morse_active_device is not None:
|
||||
app_module.release_sdr_device(morse_active_device)
|
||||
app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
|
||||
morse_active_device = None
|
||||
morse_active_sdr_type = None
|
||||
morse_last_error = msg
|
||||
_set_state(MORSE_ERROR, msg)
|
||||
_set_state(MORSE_IDLE, 'Idle')
|
||||
@@ -675,8 +679,9 @@ def start_morse() -> Response:
|
||||
)
|
||||
with app_module.morse_lock:
|
||||
if morse_active_device is not None:
|
||||
app_module.release_sdr_device(morse_active_device)
|
||||
app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
|
||||
morse_active_device = None
|
||||
morse_active_sdr_type = None
|
||||
morse_last_error = f'Tool not found: {e.filename}'
|
||||
_set_state(MORSE_ERROR, morse_last_error)
|
||||
_set_state(MORSE_IDLE, 'Idle')
|
||||
@@ -692,8 +697,9 @@ def start_morse() -> Response:
|
||||
)
|
||||
with app_module.morse_lock:
|
||||
if morse_active_device is not None:
|
||||
app_module.release_sdr_device(morse_active_device)
|
||||
app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr')
|
||||
morse_active_device = None
|
||||
morse_active_sdr_type = None
|
||||
morse_last_error = str(e)
|
||||
_set_state(MORSE_ERROR, morse_last_error)
|
||||
_set_state(MORSE_IDLE, 'Idle')
|
||||
@@ -702,7 +708,7 @@ def start_morse() -> Response:
|
||||
|
||||
@morse_bp.route('/morse/stop', methods=['POST'])
|
||||
def stop_morse() -> Response:
|
||||
global morse_active_device, morse_decoder_worker, morse_stderr_worker
|
||||
global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker
|
||||
global morse_stop_event, morse_control_queue
|
||||
|
||||
stop_started = time.perf_counter()
|
||||
@@ -717,6 +723,7 @@ def stop_morse() -> Response:
|
||||
stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None)
|
||||
control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None)
|
||||
active_device = morse_active_device
|
||||
active_sdr_type = morse_active_sdr_type
|
||||
|
||||
if (
|
||||
not rtl_proc
|
||||
@@ -768,7 +775,7 @@ def stop_morse() -> Response:
|
||||
_mark(f'stderr thread joined={stderr_joined}')
|
||||
|
||||
if active_device is not None:
|
||||
app_module.release_sdr_device(active_device)
|
||||
app_module.release_sdr_device(active_device, active_sdr_type or 'rtlsdr')
|
||||
_mark(f'SDR device {active_device} released')
|
||||
|
||||
stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1)
|
||||
@@ -782,6 +789,7 @@ def stop_morse() -> Response:
|
||||
|
||||
with app_module.morse_lock:
|
||||
morse_active_device = None
|
||||
morse_active_sdr_type = None
|
||||
_set_state(MORSE_IDLE, 'Stopped', extra={
|
||||
'stop_ms': stop_ms,
|
||||
'cleanup_steps': cleanup_steps,
|
||||
|
||||
+84
-75
@@ -24,7 +24,7 @@ from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
@@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
pager_active_device: int | None = None
|
||||
pager_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
@@ -96,7 +97,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
"""Log a message to file if logging is enabled."""
|
||||
if not app_module.logging_enabled:
|
||||
return
|
||||
@@ -104,39 +105,39 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||
waveform: list[int] = []
|
||||
for sample in window:
|
||||
# Convert int16 PCM to int8 range for lightweight transport.
|
||||
packed = int(round(sample / 256))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
return waveform
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event plus a compact waveform sample onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||
waveform: list[int] = []
|
||||
for sample in window:
|
||||
# Convert int16 PCM to int8 range for lightweight transport.
|
||||
packed = int(round(sample / 256))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
return waveform
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event plus a compact waveform sample onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
@@ -160,16 +161,16 @@ def audio_relay_thread(
|
||||
if n_samples == 0:
|
||||
continue
|
||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Audio relay error: {e}")
|
||||
finally:
|
||||
@@ -220,7 +221,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
@@ -249,13 +250,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
app_module.current_process = None
|
||||
# Release SDR device
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
@@ -284,10 +286,13 @@ def start_decoding() -> Response:
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Get SDR type early so we can pass it to claim/release
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'pager')
|
||||
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -295,14 +300,16 @@ def start_decoding() -> Response:
|
||||
'message': error
|
||||
}), 409
|
||||
pager_active_device = device_int
|
||||
pager_active_sdr_type = sdr_type_str
|
||||
|
||||
# Validate protocols
|
||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||
protocols = data.get('protocols', valid_protocols)
|
||||
if not isinstance(protocols, list):
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||
protocols = [p for p in protocols if p in valid_protocols]
|
||||
if not protocols:
|
||||
@@ -327,8 +334,7 @@ def start_decoding() -> Response:
|
||||
elif proto == 'FLEX':
|
||||
decoders.extend(['-a', 'FLEX'])
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
@@ -443,8 +449,9 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
@@ -458,14 +465,15 @@ def start_decoding() -> Response:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
global pager_active_device
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
@@ -502,8 +510,9 @@ def stop_decoding() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -553,22 +562,22 @@ def toggle_logging() -> Response:
|
||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
+265
-257
@@ -1,5 +1,5 @@
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -10,25 +10,26 @@ import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
sensor_active_sdr_type: str | None = None
|
||||
|
||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||
_MAX_RSSI_HISTORY = 60
|
||||
@@ -65,36 +66,36 @@ def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 2
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
if rssi is not None or snr is not None:
|
||||
try:
|
||||
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||
@@ -113,204 +114,211 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
})
|
||||
except (TypeError, ValueError, queue.Full):
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global sensor_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
sensor_active_device = device_int
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device)
|
||||
sensor_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Get SDR type early so we can pass it to claim/release
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
sensor_active_device = device_int
|
||||
sensor_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
@@ -330,12 +338,12 @@ def stream_sensor() -> Response:
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return jsonify({'status': 'success', 'devices': result})
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return jsonify({'status': 'success', 'devices': result})
|
||||
|
||||
+265
-5
@@ -1,7 +1,8 @@
|
||||
"""System Health monitoring blueprint.
|
||||
|
||||
Provides real-time system metrics (CPU, memory, disk, temperatures),
|
||||
active process status, and SDR device enumeration via SSE streaming.
|
||||
Provides real-time system metrics (CPU, memory, disk, temperatures,
|
||||
network, battery, fans), active process status, SDR device enumeration,
|
||||
location, and weather data via SSE streaming and REST endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,11 +12,13 @@ import os
|
||||
import platform
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||
from utils.logging import sensor_logger as logger
|
||||
@@ -29,6 +32,11 @@ except ImportError:
|
||||
psutil = None # type: ignore[assignment]
|
||||
_HAS_PSUTIL = False
|
||||
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
_requests = None # type: ignore[assignment]
|
||||
|
||||
system_bp = Blueprint('system', __name__, url_prefix='/system')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -40,6 +48,11 @@ _collector_started = False
|
||||
_collector_lock = threading.Lock()
|
||||
_app_start_time: float | None = None
|
||||
|
||||
# Weather cache
|
||||
_weather_cache: dict[str, Any] = {}
|
||||
_weather_cache_time: float = 0.0
|
||||
_WEATHER_CACHE_TTL = 600 # 10 minutes
|
||||
|
||||
|
||||
def _get_app_start_time() -> float:
|
||||
"""Return the application start timestamp from the main app module."""
|
||||
@@ -138,6 +151,38 @@ def _collect_process_status() -> dict[str, bool]:
|
||||
return {}
|
||||
|
||||
|
||||
def _collect_throttle_flags() -> str | None:
|
||||
"""Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['vcgencmd', 'get_throttled'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if result.returncode == 0 and 'throttled=' in result.stdout:
|
||||
return result.stdout.strip().split('=', 1)[1]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _collect_power_draw() -> float | None:
|
||||
"""Read power draw in watts from sysfs (Linux only)."""
|
||||
try:
|
||||
power_supply = Path('/sys/class/power_supply')
|
||||
if not power_supply.exists():
|
||||
return None
|
||||
for supply_dir in power_supply.iterdir():
|
||||
power_file = supply_dir / 'power_now'
|
||||
if power_file.exists():
|
||||
val = int(power_file.read_text().strip())
|
||||
return round(val / 1_000_000, 2) # microwatts to watts
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _collect_metrics() -> dict[str, Any]:
|
||||
"""Gather a snapshot of system metrics."""
|
||||
now = time.time()
|
||||
@@ -159,7 +204,7 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
if _HAS_PSUTIL:
|
||||
# CPU
|
||||
# CPU — overall + per-core + frequency
|
||||
cpu_percent = psutil.cpu_percent(interval=None)
|
||||
cpu_count = psutil.cpu_count() or 1
|
||||
try:
|
||||
@@ -167,12 +212,28 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
except (OSError, AttributeError):
|
||||
load_1 = load_5 = load_15 = 0.0
|
||||
|
||||
per_core = []
|
||||
with contextlib.suppress(Exception):
|
||||
per_core = psutil.cpu_percent(interval=None, percpu=True)
|
||||
|
||||
freq_data = None
|
||||
with contextlib.suppress(Exception):
|
||||
freq = psutil.cpu_freq()
|
||||
if freq:
|
||||
freq_data = {
|
||||
'current': round(freq.current, 0),
|
||||
'min': round(freq.min, 0),
|
||||
'max': round(freq.max, 0),
|
||||
}
|
||||
|
||||
metrics['cpu'] = {
|
||||
'percent': cpu_percent,
|
||||
'count': cpu_count,
|
||||
'load_1': round(load_1, 2),
|
||||
'load_5': round(load_5, 2),
|
||||
'load_15': round(load_15, 2),
|
||||
'per_core': per_core,
|
||||
'freq': freq_data,
|
||||
}
|
||||
|
||||
# Memory
|
||||
@@ -191,7 +252,7 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
'percent': swap.percent,
|
||||
}
|
||||
|
||||
# Disk
|
||||
# Disk — usage + I/O counters
|
||||
try:
|
||||
disk = psutil.disk_usage('/')
|
||||
metrics['disk'] = {
|
||||
@@ -204,6 +265,18 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
except Exception:
|
||||
metrics['disk'] = None
|
||||
|
||||
disk_io = None
|
||||
with contextlib.suppress(Exception):
|
||||
dio = psutil.disk_io_counters()
|
||||
if dio:
|
||||
disk_io = {
|
||||
'read_bytes': dio.read_bytes,
|
||||
'write_bytes': dio.write_bytes,
|
||||
'read_count': dio.read_count,
|
||||
'write_count': dio.write_count,
|
||||
}
|
||||
metrics['disk_io'] = disk_io
|
||||
|
||||
# Temperatures
|
||||
try:
|
||||
temps = psutil.sensors_temperatures()
|
||||
@@ -224,12 +297,102 @@ def _collect_metrics() -> dict[str, Any]:
|
||||
metrics['temperatures'] = None
|
||||
except (AttributeError, Exception):
|
||||
metrics['temperatures'] = None
|
||||
|
||||
# Fans
|
||||
fans_data = None
|
||||
with contextlib.suppress(Exception):
|
||||
fans = psutil.sensors_fans()
|
||||
if fans:
|
||||
fans_data = {}
|
||||
for chip, entries in fans.items():
|
||||
fans_data[chip] = [
|
||||
{'label': e.label or chip, 'current': e.current}
|
||||
for e in entries
|
||||
]
|
||||
metrics['fans'] = fans_data
|
||||
|
||||
# Battery
|
||||
battery_data = None
|
||||
with contextlib.suppress(Exception):
|
||||
bat = psutil.sensors_battery()
|
||||
if bat:
|
||||
battery_data = {
|
||||
'percent': bat.percent,
|
||||
'plugged': bat.power_plugged,
|
||||
'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None,
|
||||
}
|
||||
metrics['battery'] = battery_data
|
||||
|
||||
# Network interfaces
|
||||
net_ifaces: list[dict[str, Any]] = []
|
||||
with contextlib.suppress(Exception):
|
||||
addrs = psutil.net_if_addrs()
|
||||
stats = psutil.net_if_stats()
|
||||
for iface_name in sorted(addrs.keys()):
|
||||
if iface_name == 'lo':
|
||||
continue
|
||||
iface_info: dict[str, Any] = {'name': iface_name}
|
||||
# Get addresses
|
||||
for addr in addrs[iface_name]:
|
||||
if addr.family == socket.AF_INET:
|
||||
iface_info['ipv4'] = addr.address
|
||||
elif addr.family == socket.AF_INET6:
|
||||
iface_info.setdefault('ipv6', addr.address)
|
||||
elif addr.family == psutil.AF_LINK:
|
||||
iface_info['mac'] = addr.address
|
||||
# Get stats
|
||||
if iface_name in stats:
|
||||
st = stats[iface_name]
|
||||
iface_info['is_up'] = st.isup
|
||||
iface_info['speed'] = st.speed # Mbps
|
||||
iface_info['mtu'] = st.mtu
|
||||
net_ifaces.append(iface_info)
|
||||
metrics['network'] = {'interfaces': net_ifaces}
|
||||
|
||||
# Network I/O counters (raw — JS computes deltas)
|
||||
net_io = None
|
||||
with contextlib.suppress(Exception):
|
||||
counters = psutil.net_io_counters(pernic=True)
|
||||
if counters:
|
||||
net_io = {}
|
||||
for nic, c in counters.items():
|
||||
if nic == 'lo':
|
||||
continue
|
||||
net_io[nic] = {
|
||||
'bytes_sent': c.bytes_sent,
|
||||
'bytes_recv': c.bytes_recv,
|
||||
}
|
||||
metrics['network']['io'] = net_io
|
||||
|
||||
# Connection count
|
||||
conn_count = 0
|
||||
with contextlib.suppress(Exception):
|
||||
conn_count = len(psutil.net_connections())
|
||||
metrics['network']['connections'] = conn_count
|
||||
|
||||
# Boot time
|
||||
boot_ts = None
|
||||
with contextlib.suppress(Exception):
|
||||
boot_ts = psutil.boot_time()
|
||||
metrics['boot_time'] = boot_ts
|
||||
|
||||
# Power / throttle (Pi-specific)
|
||||
metrics['power'] = {
|
||||
'throttled': _collect_throttle_flags(),
|
||||
'draw_watts': _collect_power_draw(),
|
||||
}
|
||||
else:
|
||||
metrics['cpu'] = None
|
||||
metrics['memory'] = None
|
||||
metrics['swap'] = None
|
||||
metrics['disk'] = None
|
||||
metrics['disk_io'] = None
|
||||
metrics['temperatures'] = None
|
||||
metrics['fans'] = None
|
||||
metrics['battery'] = None
|
||||
metrics['network'] = None
|
||||
metrics['boot_time'] = None
|
||||
metrics['power'] = None
|
||||
|
||||
return metrics
|
||||
|
||||
@@ -270,6 +433,47 @@ def _ensure_collector() -> None:
|
||||
logger.info('System metrics collector started')
|
||||
|
||||
|
||||
def _get_observer_location() -> dict[str, Any]:
|
||||
"""Get observer location from GPS state or config defaults."""
|
||||
lat, lon, source = None, None, 'none'
|
||||
gps_meta: dict[str, Any] = {}
|
||||
|
||||
# Try GPS via utils.gps
|
||||
with contextlib.suppress(Exception):
|
||||
from utils.gps import get_current_position
|
||||
|
||||
pos = get_current_position()
|
||||
if pos and pos.fix_quality >= 2:
|
||||
lat, lon, source = pos.latitude, pos.longitude, 'gps'
|
||||
gps_meta['fix_quality'] = pos.fix_quality
|
||||
gps_meta['satellites'] = pos.satellites
|
||||
if pos.epx is not None and pos.epy is not None:
|
||||
gps_meta['accuracy'] = round(max(pos.epx, pos.epy), 1)
|
||||
if pos.altitude is not None:
|
||||
gps_meta['altitude'] = round(pos.altitude, 1)
|
||||
|
||||
# Fall back to config env vars
|
||||
if lat is None:
|
||||
with contextlib.suppress(Exception):
|
||||
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
|
||||
if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0:
|
||||
lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config'
|
||||
|
||||
# Fall back to hardcoded constants (London)
|
||||
if lat is None:
|
||||
with contextlib.suppress(Exception):
|
||||
from utils.constants import DEFAULT_LATITUDE as CONST_LAT
|
||||
from utils.constants import DEFAULT_LONGITUDE as CONST_LON
|
||||
|
||||
lat, lon, source = CONST_LAT, CONST_LON, 'default'
|
||||
|
||||
result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source}
|
||||
if gps_meta:
|
||||
result['gps'] = gps_meta
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -321,3 +525,59 @@ def get_sdr_devices() -> Response:
|
||||
except Exception as exc:
|
||||
logger.warning('SDR device detection failed: %s', exc)
|
||||
return jsonify({'devices': [], 'error': str(exc)})
|
||||
|
||||
|
||||
@system_bp.route('/location')
|
||||
def get_location() -> Response:
|
||||
"""Return observer location from GPS or config."""
|
||||
return jsonify(_get_observer_location())
|
||||
|
||||
|
||||
@system_bp.route('/weather')
|
||||
def get_weather() -> Response:
|
||||
"""Proxy weather from wttr.in, cached for 10 minutes."""
|
||||
global _weather_cache, _weather_cache_time
|
||||
|
||||
now = time.time()
|
||||
if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL:
|
||||
return jsonify(_weather_cache)
|
||||
|
||||
lat = request.args.get('lat', type=float)
|
||||
lon = request.args.get('lon', type=float)
|
||||
if lat is None or lon is None:
|
||||
loc = _get_observer_location()
|
||||
lat, lon = loc.get('lat'), loc.get('lon')
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({'error': 'No location available'})
|
||||
|
||||
if _requests is None:
|
||||
return jsonify({'error': 'requests library not available'})
|
||||
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f'https://wttr.in/{lat},{lon}?format=j1',
|
||||
timeout=5,
|
||||
headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
current = data.get('current_condition', [{}])[0]
|
||||
weather = {
|
||||
'temp_c': current.get('temp_C'),
|
||||
'temp_f': current.get('temp_F'),
|
||||
'condition': current.get('weatherDesc', [{}])[0].get('value', ''),
|
||||
'humidity': current.get('humidity'),
|
||||
'wind_mph': current.get('windspeedMiles'),
|
||||
'wind_dir': current.get('winddir16Point'),
|
||||
'feels_like_c': current.get('FeelsLikeC'),
|
||||
'visibility': current.get('visibility'),
|
||||
'pressure': current.get('pressure'),
|
||||
}
|
||||
_weather_cache = weather
|
||||
_weather_cache_time = now
|
||||
return jsonify(weather)
|
||||
except Exception as exc:
|
||||
logger.debug('Weather fetch failed: %s', exc)
|
||||
return jsonify({'error': str(exc)})
|
||||
|
||||
+22
-15
@@ -48,6 +48,7 @@ vdl2_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
vdl2_active_device: int | None = None
|
||||
vdl2_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def find_dumpvdl2():
|
||||
@@ -126,7 +127,7 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
logger.error(f"VDL2 stream error: {e}")
|
||||
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global vdl2_active_device
|
||||
global vdl2_active_device, vdl2_active_sdr_type
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
@@ -142,8 +143,9 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
||||
app_module.vdl2_process = None
|
||||
# Release SDR device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
|
||||
|
||||
@vdl2_bp.route('/tools')
|
||||
@@ -175,7 +177,7 @@ def vdl2_status() -> Response:
|
||||
@vdl2_bp.route('/start', methods=['POST'])
|
||||
def start_vdl2() -> Response:
|
||||
"""Start VDL2 decoder."""
|
||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
|
||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||
@@ -202,9 +204,16 @@ def start_vdl2() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2')
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -213,6 +222,7 @@ def start_vdl2() -> Response:
|
||||
}), 409
|
||||
|
||||
vdl2_active_device = device_int
|
||||
vdl2_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
# dumpvdl2 expects frequencies in Hz (integers)
|
||||
@@ -231,13 +241,6 @@ def start_vdl2() -> Response:
|
||||
vdl2_message_count = 0
|
||||
vdl2_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||
|
||||
# Build dumpvdl2 command
|
||||
@@ -297,8 +300,9 @@ def start_vdl2() -> Response:
|
||||
if process.poll() is not None:
|
||||
# Process died - release device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
@@ -329,8 +333,9 @@ def start_vdl2() -> Response:
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@@ -338,7 +343,7 @@ def start_vdl2() -> Response:
|
||||
@vdl2_bp.route('/stop', methods=['POST'])
|
||||
def stop_vdl2() -> Response:
|
||||
"""Stop VDL2 decoder."""
|
||||
global vdl2_active_device
|
||||
global vdl2_active_device, vdl2_active_sdr_type
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if not app_module.vdl2_process:
|
||||
@@ -359,8 +364,9 @@ def stop_vdl2() -> Response:
|
||||
|
||||
# Release device from registry
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr')
|
||||
vdl2_active_device = None
|
||||
vdl2_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -386,6 +392,7 @@ def stream_vdl2() -> Response:
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@vdl2_bp.route('/messages')
|
||||
def get_vdl2_messages() -> Response:
|
||||
"""Get recent VDL2 messages from correlator (for history reload)."""
|
||||
|
||||
@@ -367,6 +367,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
my_generation = None # tracks which capture generation this handler owns
|
||||
capture_center_mhz = 0.0
|
||||
capture_start_freq = 0.0
|
||||
@@ -430,8 +431,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
_set_shared_capture_state(running=False, generation=my_generation)
|
||||
my_generation = None
|
||||
stop_event.clear()
|
||||
@@ -513,7 +515,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
max_claim_attempts = 4 if was_restarting else 1
|
||||
claim_err = None
|
||||
for _claim_attempt in range(max_claim_attempts):
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type_str)
|
||||
if not claim_err:
|
||||
break
|
||||
if _claim_attempt < max_claim_attempts - 1:
|
||||
@@ -526,6 +528,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
claimed_sdr_type = sdr_type_str
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
@@ -539,8 +542,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index)
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
@@ -549,8 +553,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
|
||||
# Pre-flight: check the capture binary exists
|
||||
if not shutil.which(iq_cmd[0]):
|
||||
app_module.release_sdr_device(device_index)
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).',
|
||||
@@ -602,8 +607,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index)
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
@@ -806,8 +812,9 @@ def init_waterfall_websocket(app: Flask):
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
_set_shared_capture_state(running=False, generation=my_generation)
|
||||
my_generation = None
|
||||
stop_event.clear()
|
||||
@@ -825,7 +832,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
_set_shared_capture_state(running=False, generation=my_generation)
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
|
||||
+11
-7
@@ -33,11 +33,12 @@ _wefax_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track active SDR device
|
||||
wefax_active_device: int | None = None
|
||||
wefax_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
global wefax_active_device
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
|
||||
try:
|
||||
_wefax_queue.put_nowait(data)
|
||||
@@ -56,8 +57,9 @@ def _progress_callback(data: dict) -> None:
|
||||
and data.get('status') in ('complete', 'error', 'stopped')
|
||||
and wefax_active_device is not None
|
||||
):
|
||||
app_module.release_sdr_device(wefax_active_device)
|
||||
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
|
||||
wefax_active_device = None
|
||||
wefax_active_sdr_type = None
|
||||
|
||||
|
||||
@wefax_bp.route('/status')
|
||||
@@ -169,9 +171,9 @@ def start_decoder():
|
||||
}), 400
|
||||
|
||||
# Claim SDR device
|
||||
global wefax_active_device
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'wefax')
|
||||
error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -194,6 +196,7 @@ def start_decoder():
|
||||
|
||||
if success:
|
||||
wefax_active_device = device_int
|
||||
wefax_active_sdr_type = sdr_type_str
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency_khz': frequency_khz,
|
||||
@@ -209,7 +212,7 @@ def start_decoder():
|
||||
'device': device_int,
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int)
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
@@ -219,13 +222,14 @@ def start_decoder():
|
||||
@wefax_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop WeFax decoder."""
|
||||
global wefax_active_device
|
||||
global wefax_active_device, wefax_active_sdr_type
|
||||
decoder = get_wefax_decoder()
|
||||
decoder.stop()
|
||||
|
||||
if wefax_active_device is not None:
|
||||
app_module.release_sdr_device(wefax_active_device)
|
||||
app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr')
|
||||
wefax_active_device = None
|
||||
wefax_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user