diff --git a/app.py b/app.py index af8dd8a..84cb522 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ from typing import Any from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session from werkzeug.security import check_password_hash -from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED +from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES from utils.process import cleanup_stale_processes from utils.sdr import SDRFactory @@ -216,6 +216,52 @@ cleanup_manager.register(adsb_aircraft) cleanup_manager.register(ais_vessels) cleanup_manager.register(dsc_messages) +# ============================================ +# SDR DEVICE REGISTRY +# ============================================ +# Tracks which mode is using which SDR device to prevent conflicts +# Key: device_index (int), Value: mode_name (str) +sdr_device_registry: dict[int, str] = {} +sdr_device_registry_lock = threading.Lock() + + +def claim_sdr_device(device_index: int, mode_name: str) -> str | None: + """Claim an SDR device for a mode. + + Args: + device_index: The SDR device index to claim + mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr') + + Returns: + Error message if device is in use, None if successfully claimed + """ + with sdr_device_registry_lock: + if device_index in sdr_device_registry: + in_use_by = sdr_device_registry[device_index] + return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' + sdr_device_registry[device_index] = mode_name + return None + + +def release_sdr_device(device_index: int) -> None: + """Release an SDR device from the registry. + + Args: + device_index: The SDR device index to release + """ + with sdr_device_registry_lock: + sdr_device_registry.pop(device_index, None) + + +def get_sdr_device_status() -> dict[int, str]: + """Get current SDR device allocations. + + Returns: + Dictionary mapping device indices to mode names + """ + with sdr_device_registry_lock: + return dict(sdr_device_registry) + # ============================================ # MAIN ROUTES @@ -271,22 +317,22 @@ def login(): return render_template('login.html', version=VERSION) @app.route('/') -def index() -> str: - tools = { - 'rtl_fm': check_tool('rtl_fm'), - 'multimon': check_tool('multimon-ng'), - 'rtl_433': check_tool('rtl_433'), - 'rtlamr': check_tool('rtlamr') - } - devices = [d.to_dict() for d in SDRFactory.detect_devices()] - return render_template( - 'index.html', - tools=tools, - devices=devices, - version=VERSION, - changelog=CHANGELOG, - shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED, - ) +def index() -> str: + tools = { + 'rtl_fm': check_tool('rtl_fm'), + 'multimon': check_tool('multimon-ng'), + 'rtl_433': check_tool('rtl_433'), + 'rtlamr': check_tool('rtlamr') + } + devices = [d.to_dict() for d in SDRFactory.detect_devices()] + return render_template( + 'index.html', + tools=tools, + devices=devices, + version=VERSION, + changelog=CHANGELOG, + shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED, + ) @app.route('/favicon.svg') @@ -629,6 +675,10 @@ def kill_all() -> Response: dsc_process = None dsc_rtl_process = None + # Clear SDR device registry + with sdr_device_registry_lock: + sdr_device_registry.clear() + return jsonify({'status': 'killed', 'processes': killed}) diff --git a/routes/acars.py b/routes/acars.py index 1604b90..160ecd9 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -43,6 +43,9 @@ DEFAULT_ACARS_FREQUENCIES = [ acars_message_count = 0 acars_last_message_time = None +# Track which device is being used +acars_active_device: int | None = None + def find_acarsdec(): """Find acarsdec binary.""" @@ -175,7 +178,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 + global acars_message_count, acars_last_message_time, acars_active_device with app_module.acars_lock: if app_module.acars_process and app_module.acars_process.poll() is None: @@ -202,6 +205,18 @@ def start_acars() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Check if device is available + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'acars') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + + acars_active_device = device_int + # Get frequencies - use provided or defaults frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES) if isinstance(frequencies, str): @@ -282,7 +297,10 @@ def start_acars() -> Response: time.sleep(PROCESS_START_WAIT) if process.poll() is not None: - # Process died + # Process died - release device + if acars_active_device is not None: + app_module.release_sdr_device(acars_active_device) + acars_active_device = None stderr = '' if process.stderr: stderr = process.stderr.read().decode('utf-8', errors='replace') @@ -310,6 +328,10 @@ 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) + acars_active_device = None logger.error(f"Failed to start ACARS decoder: {e}") return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -317,6 +339,8 @@ def start_acars() -> Response: @acars_bp.route('/stop', methods=['POST']) def stop_acars() -> Response: """Stop ACARS decoder.""" + global acars_active_device + with app_module.acars_lock: if not app_module.acars_process: return jsonify({ @@ -334,6 +358,11 @@ def stop_acars() -> Response: app_module.acars_process = None + # Release device from registry + if acars_active_device is not None: + app_module.release_sdr_device(acars_active_device) + acars_active_device = None + return jsonify({'status': 'stopped'}) diff --git a/routes/adsb.py b/routes/adsb.py index 7bd8e1b..4ced99e 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -686,6 +686,16 @@ def start_adsb(): app_module.adsb_process = None logger.info("Killed stale ADS-B process") + # Check if device is available before starting local dump1090 + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'adsb') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + # 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) @@ -714,7 +724,8 @@ def start_adsb(): time.sleep(DUMP1090_START_WAIT) if app_module.adsb_process.poll() is not None: - # Process exited - try to get error message + # Process exited - release device and get error message + app_module.release_sdr_device(device_int) stderr_output = '' if app_module.adsb_process.stderr: try: @@ -752,6 +763,8 @@ def start_adsb(): 'session': session }) except Exception as e: + # Release device on failure + app_module.release_sdr_device(device_int) return jsonify({'status': 'error', 'message': str(e)}) @@ -779,6 +792,11 @@ def stop_adsb(): pass app_module.adsb_process = None logger.info("ADS-B process stopped") + + # Release device from registry + if adsb_active_device is not None: + app_module.release_sdr_device(adsb_active_device) + adsb_using_service = False adsb_active_device = None diff --git a/routes/ais.py b/routes/ais.py index 5edfe18..68f6140 100644 --- a/routes/ais.py +++ b/routes/ais.py @@ -370,6 +370,16 @@ def start_ais(): app_module.ais_process = None logger.info("Killed existing AIS process") + # Check if device is available + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'ais') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + # Build command using SDR abstraction sdr_device = SDRFactory.create_default_device(sdr_type, index=device) builder = SDRFactory.get_builder(sdr_type) @@ -400,6 +410,8 @@ def start_ais(): time.sleep(2.0) if app_module.ais_process.poll() is not None: + # Release device on failure + app_module.release_sdr_device(device_int) stderr_output = '' if app_module.ais_process.stderr: try: @@ -425,6 +437,8 @@ def start_ais(): 'port': tcp_port }) except Exception as e: + # Release device on failure + app_module.release_sdr_device(device_int) logger.error(f"Failed to start AIS-catcher: {e}") return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -448,6 +462,11 @@ def stop_ais(): pass app_module.ais_process = None logger.info("AIS process stopped") + + # Release device from registry + if ais_active_device is not None: + app_module.release_sdr_device(ais_active_device) + ais_running = False ais_active_device = None diff --git a/routes/dsc.py b/routes/dsc.py index be59288..913b1ba 100644 --- a/routes/dsc.py +++ b/routes/dsc.py @@ -47,6 +47,9 @@ dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc') # Module state (track if running independent of process state) dsc_running = False +# Track which device is being used +dsc_active_device: int | None = None + def _get_dsc_decoder_path() -> str | None: """Get path to DSC decoder.""" @@ -309,21 +312,18 @@ def start_decoding() -> Response: 'message': str(e) }), 400 - # Check if device is in use by AIS - try: - from routes import ais as ais_module - if hasattr(ais_module, 'ais_running') and ais_module.ais_running: - # AIS is running - check if same device - if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device): - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': f'SDR device {device} is in use by AIS tracking', - 'suggestion': 'Use a different SDR device or stop AIS tracking first', - 'in_use_by': 'ais' - }), 409 - except ImportError: - pass + # Check if device is available using centralized registry + global dsc_active_device + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'dsc') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + + dsc_active_device = device_int # Clear queue while not app_module.dsc_queue.empty(): @@ -408,11 +408,19 @@ def start_decoding() -> Response: }) except FileNotFoundError as e: + # Release device on failure + if dsc_active_device is not None: + app_module.release_sdr_device(dsc_active_device) + dsc_active_device = None return jsonify({ 'status': 'error', 'message': f'Tool not found: {e.filename}' }), 400 except Exception as e: + # Release device on failure + if dsc_active_device is not None: + app_module.release_sdr_device(dsc_active_device) + dsc_active_device = None logger.error(f"Failed to start DSC decoder: {e}") return jsonify({ 'status': 'error', @@ -423,7 +431,7 @@ def start_decoding() -> Response: @dsc_bp.route('/stop', methods=['POST']) def stop_decoding() -> Response: """Stop DSC decoder.""" - global dsc_running + global dsc_running, dsc_active_device with app_module.dsc_lock: if not app_module.dsc_process: @@ -460,6 +468,11 @@ def stop_decoding() -> Response: app_module.dsc_process = None app_module.dsc_rtl_process = None + # Release device from registry + if dsc_active_device is not None: + app_module.release_sdr_device(dsc_active_device) + dsc_active_device = None + return jsonify({'status': 'stopped'}) diff --git a/routes/pager.py b/routes/pager.py index 3b7a4e0..8a94d08 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -29,6 +29,9 @@ from utils.dependencies import get_tool_path pager_bp = Blueprint('pager', __name__) +# Track which device is being used +pager_active_device: int | None = None + def parse_multimon_output(line: str) -> dict[str, str] | None: """Parse multimon-ng output line.""" @@ -155,6 +158,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: @pager_bp.route('/start', methods=['POST']) def start_decoding() -> Response: + global pager_active_device + with app_module.process_lock: if app_module.current_process: return jsonify({'status': 'error', 'message': 'Already running'}), 409 @@ -178,10 +183,29 @@ def start_decoding() -> Response: except (ValueError, TypeError): return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 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, 'pager') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + pager_active_device = device_int + # 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) + pager_active_device = 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: @@ -213,10 +237,6 @@ def start_decoding() -> Response: except ValueError: sdr_type = SDRType.RTL_SDR - # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) - if rtl_tcp_host: # Validate and create network device try: @@ -302,13 +322,23 @@ def start_decoding() -> Response: return jsonify({'status': 'started', 'command': full_cmd}) except FileNotFoundError as e: + # Release device on failure + if pager_active_device is not None: + app_module.release_sdr_device(pager_active_device) + pager_active_device = None return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) except Exception as e: + # Release device on failure + if pager_active_device is not None: + app_module.release_sdr_device(pager_active_device) + pager_active_device = None return jsonify({'status': 'error', 'message': str(e)}) @pager_bp.route('/stop', methods=['POST']) def stop_decoding() -> Response: + global pager_active_device + with app_module.process_lock: if app_module.current_process: # Kill rtl_fm process first @@ -337,6 +367,12 @@ def stop_decoding() -> Response: app_module.current_process.kill() app_module.current_process = None + + # Release device from registry + if pager_active_device is not None: + app_module.release_sdr_device(pager_active_device) + pager_active_device = None + return jsonify({'status': 'stopped'}) return jsonify({'status': 'not_running'}) diff --git a/routes/rtlamr.py b/routes/rtlamr.py index b3439cd..a269d67 100644 --- a/routes/rtlamr.py +++ b/routes/rtlamr.py @@ -26,6 +26,9 @@ rtlamr_bp = Blueprint('rtlamr', __name__) rtl_tcp_process = None rtl_tcp_lock = threading.Lock() +# Track which device is being used +rtlamr_active_device: int | None = None + def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: """Stream rtlamr JSON output to queue.""" @@ -66,7 +69,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: @rtlamr_bp.route('/start_rtlamr', methods=['POST']) def start_rtlamr() -> Response: - global rtl_tcp_process + global rtl_tcp_process, rtlamr_active_device with app_module.rtlamr_lock: if app_module.rtlamr_process: @@ -83,6 +86,18 @@ def start_rtlamr() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Check if device is available + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'rtlamr') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + + rtlamr_active_device = device_int + # Clear queue while not app_module.rtlamr_queue.empty(): try: @@ -182,27 +197,33 @@ def start_rtlamr() -> Response: return jsonify({'status': 'started', 'command': full_cmd}) except FileNotFoundError: - # If rtlamr fails, clean up rtl_tcp + # If rtlamr fails, clean up rtl_tcp and release device with rtl_tcp_lock: if rtl_tcp_process: rtl_tcp_process.terminate() rtl_tcp_process.wait(timeout=2) rtl_tcp_process = None + if rtlamr_active_device is not None: + app_module.release_sdr_device(rtlamr_active_device) + rtlamr_active_device = None return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'}) except Exception as e: - # If rtlamr fails, clean up rtl_tcp + # If rtlamr fails, clean up rtl_tcp and release device with rtl_tcp_lock: if rtl_tcp_process: rtl_tcp_process.terminate() rtl_tcp_process.wait(timeout=2) rtl_tcp_process = None + if rtlamr_active_device is not None: + app_module.release_sdr_device(rtlamr_active_device) + rtlamr_active_device = None return jsonify({'status': 'error', 'message': str(e)}) @rtlamr_bp.route('/stop_rtlamr', methods=['POST']) def stop_rtlamr() -> Response: - global rtl_tcp_process - + global rtl_tcp_process, rtlamr_active_device + with app_module.rtlamr_lock: if app_module.rtlamr_process: app_module.rtlamr_process.terminate() @@ -211,7 +232,7 @@ def stop_rtlamr() -> Response: except subprocess.TimeoutExpired: app_module.rtlamr_process.kill() app_module.rtlamr_process = None - + # Also stop rtl_tcp with rtl_tcp_lock: if rtl_tcp_process: @@ -222,7 +243,12 @@ def stop_rtlamr() -> Response: rtl_tcp_process.kill() rtl_tcp_process = None logger.info("rtl_tcp stopped") - + + # Release device from registry + if rtlamr_active_device is not None: + app_module.release_sdr_device(rtlamr_active_device) + rtlamr_active_device = None + return jsonify({'status': 'stopped'}) diff --git a/routes/sensor.py b/routes/sensor.py index 87eb2af..e6dec53 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -24,6 +24,9 @@ from utils.sdr import SDRFactory, SDRType sensor_bp = Blueprint('sensor', __name__) +# Track which device is being used +sensor_active_device: int | None = None + def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: """Stream rtl_433 JSON output to queue.""" @@ -64,6 +67,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: @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 @@ -79,6 +84,22 @@ def start_sensor() -> Response: 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: @@ -93,10 +114,6 @@ def start_sensor() -> Response: except ValueError: sdr_type = SDRType.RTL_SDR - # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) - if rtl_tcp_host: # Validate and create network device try: @@ -155,13 +172,23 @@ def start_sensor() -> Response: 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() @@ -170,6 +197,12 @@ def stop_sensor() -> Response: 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'})