diff --git a/Dockerfile b/Dockerfile index 6dacb09..fb48ca8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -94,7 +94,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libpulse-dev \ libfftw3-dev \ liblapack-dev \ - libcodec2-dev \ libglib2.0-dev \ libxml2-dev \ # Build dump1090 @@ -199,27 +198,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && go install github.com/bemasher/rtlamr@latest \ && cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \ && rm -rf /usr/local/go /tmp/gopath \ - # Build mbelib (required by DSD) - && cd /tmp \ - && git clone https://github.com/lwvmobile/mbelib.git \ - && cd mbelib \ - && (git checkout ambe_tones || true) \ - && mkdir build && cd build \ - && cmake .. \ - && make -j$(nproc) \ - && make install \ - && ldconfig \ - && rm -rf /tmp/mbelib \ - # Build DSD-FME (Digital Speech Decoder for DMR/P25) - && cd /tmp \ - && git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \ - && cd dsd-fme \ - && mkdir build && cd build \ - && cmake .. \ - && make -j$(nproc) \ - && make install \ - && ldconfig \ - && rm -rf /tmp/dsd-fme \ # Cleanup build tools to reduce image size # libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx && apt-get remove -y \ @@ -247,7 +225,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libpulse-dev \ libfftw3-dev \ liblapack-dev \ - libcodec2-dev \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* diff --git a/app.py b/app.py index f92caea..da0dc8e 100644 --- a/app.py +++ b/app.py @@ -96,19 +96,32 @@ def add_security_headers(response): # CONTEXT PROCESSORS # ============================================ -@app.context_processor -def inject_offline_settings(): - """Inject offline settings into all templates.""" - from utils.database import get_setting - return { - 'offline_settings': { - 'enabled': get_setting('offline.enabled', False), - 'assets_source': get_setting('offline.assets_source', 'cdn'), - 'fonts_source': get_setting('offline.fonts_source', 'cdn'), - 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), - 'tile_server_url': get_setting('offline.tile_server_url', '') - } - } +@app.context_processor +def inject_offline_settings(): + """Inject offline settings into all templates.""" + from utils.database import get_setting + + # Privacy-first defaults: keep dashboard assets/fonts local to avoid + # third-party tracker/storage defenses in strict browsers. + assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower() + fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower() + if assets_source not in ('local', 'cdn'): + assets_source = 'local' + if fonts_source not in ('local', 'cdn'): + fonts_source = 'local' + # Force local delivery for core dashboard pages. + assets_source = 'local' + fonts_source = 'local' + + return { + 'offline_settings': { + 'enabled': get_setting('offline.enabled', False), + 'assets_source': assets_source, + 'fonts_source': fonts_source, + 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), + 'tile_server_url': get_setting('offline.tile_server_url', '') + } + } # ============================================ @@ -177,15 +190,9 @@ dsc_rtl_process = None dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dsc_lock = threading.Lock() -# DMR / Digital Voice -dmr_process = None -dmr_rtl_process = None -dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -dmr_lock = threading.Lock() - -# TSCM (Technical Surveillance Countermeasures) -tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -tscm_lock = threading.Lock() +# TSCM (Technical Surveillance Countermeasures) +tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +tscm_lock = threading.Lock() # SubGHz Transceiver (HackRF) subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -652,21 +659,11 @@ def export_bluetooth() -> Response: }) -def _get_subghz_active() -> bool: - """Check if SubGHz manager has an active process.""" - try: - from utils.subghz import get_subghz_manager - return get_subghz_manager().active_mode != 'idle' - except Exception: - return False - - -def _get_dmr_active() -> bool: - """Check if Digital Voice decoder has an active process.""" +def _get_subghz_active() -> bool: + """Check if SubGHz manager has an active process.""" try: - from routes import dmr as dmr_module - proc = dmr_module.dmr_dsd_process - return bool(dmr_module.dmr_running and proc and proc.poll() is None) + from utils.subghz import get_subghz_manager + return get_subghz_manager().active_mode != 'idle' except Exception: return False @@ -746,7 +743,6 @@ def health_check() -> Response: 'wifi': wifi_active, 'bluetooth': bt_active, 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), - 'dmr': _get_dmr_active(), 'subghz': _get_subghz_active(), }, 'data': { @@ -761,12 +757,11 @@ def health_check() -> Response: @app.route('/killall', methods=['POST']) -def kill_all() -> Response: +def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global vdl2_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process - global dmr_process, dmr_rtl_process # Import adsb and ais modules to reset their state from routes import adsb as adsb_module @@ -778,7 +773,7 @@ def kill_all() -> Response: 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher', - 'hcitool', 'bluetoothctl', 'satdump', 'dsd', + 'hcitool', 'bluetoothctl', 'satdump', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', 'hackrf_transfer', 'hackrf_sweep' ] @@ -828,12 +823,7 @@ def kill_all() -> Response: dsc_process = None dsc_rtl_process = None - # Reset DMR state - with dmr_lock: - dmr_process = None - dmr_rtl_process = None - - # Reset Bluetooth state (legacy) + # Reset Bluetooth state (legacy) with bt_lock: if bt_process: try: @@ -853,16 +843,16 @@ def kill_all() -> Response: except Exception: pass - # Reset SubGHz state - try: - from utils.subghz import get_subghz_manager - get_subghz_manager().stop_all() - except Exception: - pass - - # Clear SDR device registry - with sdr_device_registry_lock: - sdr_device_registry.clear() + # Reset SubGHz state + try: + from utils.subghz import get_subghz_manager + get_subghz_manager().stop_all() + except Exception: + pass + + # Clear SDR device registry + with sdr_device_registry_lock: + sdr_device_registry.clear() return jsonify({'status': 'killed', 'processes': killed}) diff --git a/config.py b/config.py index f8d6287..6b94432 100644 --- a/config.py +++ b/config.py @@ -99,17 +99,14 @@ CHANGELOG = [ "Pure Python SSTV decoder replacing broken slowrx dependency", "Real-time signal scope for pager, sensor, and SSTV modes", "USB-level device probe to prevent cryptic rtl_fm crashes", - "DMR dsd-fme protocol fixes, tuning controls, and state sync", - "SDR device lock-up fix from unreleased device registry on crash", + "SDR device lock-up fix from unreleased device registry on crash", ] }, { "version": "2.14.0", "date": "February 2026", "highlights": [ - "DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme", - "DMR visual synthesizer with event-driven spring-physics bars", - "HF SSTV general mode with predefined shortwave frequencies", + "HF SSTV general mode with predefined shortwave frequencies", "WebSDR integration for remote HF/shortwave listening", "Listening Post signal scanner and audio pipeline improvements", "TSCM sweep resilience, WiFi detection, and correlation fixes", diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md index 8ba1a7b..05ddbfb 100644 --- a/docs/UI_GUIDE.md +++ b/docs/UI_GUIDE.md @@ -214,9 +214,7 @@ Extended base for full-screen dashboards (maps, visualizations). | `bt_locate` | BT Locate | | `analytics` | Analytics dashboard | | `spaceweather` | Space weather | -| `dmr` | DMR/P25 digital voice | - -### Navigation Groups +### Navigation Groups The navigation is organized into groups: - **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz diff --git a/routes/__init__.py b/routes/__init__.py index acbf3f2..844a117 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -29,14 +29,13 @@ def register_blueprints(app): from .sstv import sstv_bp from .weather_sat import weather_sat_bp from .sstv_general import sstv_general_bp - from .dmr import dmr_bp from .websdr import websdr_bp from .alerts import alerts_bp from .recordings import recordings_bp - from .subghz import subghz_bp - from .bt_locate import bt_locate_bp - from .analytics import analytics_bp - from .space_weather import space_weather_bp + from .subghz import subghz_bp + from .bt_locate import bt_locate_bp + from .analytics import analytics_bp + from .space_weather import space_weather_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -65,16 +64,15 @@ def register_blueprints(app): app.register_blueprint(sstv_bp) # ISS SSTV decoder app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder app.register_blueprint(sstv_general_bp) # General terrestrial SSTV - app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR app.register_blueprint(alerts_bp) # Cross-mode alerts app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF) - app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking - app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard - app.register_blueprint(space_weather_bp) # Space weather monitoring - - # Initialize TSCM state with queue and lock from app + app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking + app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard + app.register_blueprint(space_weather_bp) # Space weather monitoring + + # Initialize TSCM state with queue and lock from app import app as app_module if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'): init_tscm_state(app_module.tscm_queue, app_module.tscm_lock) diff --git a/routes/bt_locate.py b/routes/bt_locate.py index 11f1aa3..dda456d 100644 --- a/routes/bt_locate.py +++ b/routes/bt_locate.py @@ -109,14 +109,27 @@ def start_session(): f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})" ) - session = start_locate_session( - target, environment, custom_exponent, fallback_lat, fallback_lon - ) - - return jsonify({ - 'status': 'started', - 'session': session.get_status(), - }) + try: + session = start_locate_session( + target, environment, custom_exponent, fallback_lat, fallback_lon + ) + except RuntimeError as exc: + logger.warning(f"Unable to start BT Locate session: {exc}") + return jsonify({ + 'status': 'error', + 'error': 'Bluetooth scanner could not be started. Check adapter permissions/capabilities.', + }), 503 + except Exception as exc: + logger.exception(f"Unexpected error starting BT Locate session: {exc}") + return jsonify({ + 'status': 'error', + 'error': 'Failed to start locate session', + }), 500 + + return jsonify({ + 'status': 'started', + 'session': session.get_status(), + }) @bt_locate_bp.route('/stop', methods=['POST']) @@ -130,17 +143,18 @@ def stop_session(): return jsonify({'status': 'stopped'}) -@bt_locate_bp.route('/status', methods=['GET']) -def get_status(): - """Get locate session status.""" - session = get_locate_session() - if not session: +@bt_locate_bp.route('/status', methods=['GET']) +def get_status(): + """Get locate session status.""" + session = get_locate_session() + if not session: return jsonify({ - 'active': False, - 'target': None, - }) - - return jsonify(session.get_status()) + 'active': False, + 'target': None, + }) + + include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes') + return jsonify(session.get_status(include_debug=include_debug)) @bt_locate_bp.route('/trail', methods=['GET']) diff --git a/routes/dmr.py b/routes/dmr.py deleted file mode 100644 index 39750f1..0000000 --- a/routes/dmr.py +++ /dev/null @@ -1,753 +0,0 @@ -"""DMR / P25 / Digital Voice decoding routes.""" - -from __future__ import annotations - -import os -import queue -import re -import select -import shutil -import subprocess -import threading -import time -from datetime import datetime -from typing import Generator, Optional - -from flask import Blueprint, jsonify, request, Response - -import app as app_module -from utils.logging import get_logger -from utils.sse import sse_stream_fanout -from utils.event_pipeline import process_event -from utils.process import register_process, unregister_process -from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm -from utils.sdr import SDRFactory, SDRType -from utils.constants import ( - SSE_QUEUE_TIMEOUT, - SSE_KEEPALIVE_INTERVAL, - QUEUE_MAX_SIZE, -) - -logger = get_logger('intercept.dmr') - -dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr') - -# ============================================ -# GLOBAL STATE -# ============================================ - -dmr_rtl_process: Optional[subprocess.Popen] = None -dmr_dsd_process: Optional[subprocess.Popen] = None -dmr_thread: Optional[threading.Thread] = None -dmr_running = False -dmr_has_audio = False # True when ffmpeg available and dsd outputs audio -dmr_lock = threading.Lock() -dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -dmr_active_device: Optional[int] = None - -# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all -# active ffmpeg stdin sinks when streaming clients are connected. -# This prevents dsd-fme from blocking on stdout (which would also -# freeze stderr / text data output). -_ffmpeg_sinks: set[object] = set() -_ffmpeg_sinks_lock = threading.Lock() - -VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice'] - -# Classic dsd flags -_DSD_PROTOCOL_FLAGS = { - 'auto': [], - 'dmr': ['-fd'], - 'p25': ['-fp'], - 'nxdn': ['-fn'], - 'dstar': ['-fi'], - 'provoice': ['-fv'], -} - -# dsd-fme remapped several flags from classic DSD: -# -fs = DMR Simplex (NOT -fd which is D-STAR!), -# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25), -# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1, -# -ft = XDMA multi-protocol decoder -_DSD_FME_PROTOCOL_FLAGS = { - 'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA - 'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!) - 'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme) - 'nxdn': ['-fn'], # NXDN96 - 'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!) - 'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv) -} - -# Modulation hints: force C4FM for protocols that use it, improving -# sync reliability vs letting dsd-fme auto-detect modulation type. -_DSD_FME_MODULATION = { - 'dmr': ['-mc'], # C4FM - 'nxdn': ['-mc'], # C4FM -} - -# ============================================ -# HELPERS -# ============================================ - - -def find_dsd() -> tuple[str | None, bool]: - """Find DSD (Digital Speech Decoder) binary. - - Checks for dsd-fme first (common fork), then falls back to dsd. - Returns (path, is_fme) tuple. - """ - path = shutil.which('dsd-fme') - if path: - return path, True - path = shutil.which('dsd') - if path: - return path, False - return None, False - - -def find_rtl_fm() -> str | None: - """Find rtl_fm binary.""" - return shutil.which('rtl_fm') - - -def find_rx_fm() -> str | None: - """Find SoapySDR rx_fm binary.""" - return shutil.which('rx_fm') - - -def find_ffmpeg() -> str | None: - """Find ffmpeg for audio encoding.""" - return shutil.which('ffmpeg') - - -def parse_dsd_output(line: str) -> dict | None: - """Parse a line of DSD stderr output into a structured event. - - Handles output from both classic ``dsd`` and ``dsd-fme`` which use - different formatting for talkgroup / source / voice frame lines. - """ - line = line.strip() - if not line: - return None - - # Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.) - # Only filter lines that are purely decorative — dsd-fme uses box-drawing - # characters (│, ─) as column separators in DATA lines, so we must not - # discard lines that also contain alphanumeric content. - stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line) - if not stripped_of_box: - return None - if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line): - return None - - ts = datetime.now().strftime('%H:%M:%S') - - # Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1" - sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line) - if sync_match: - return { - 'type': 'sync', - 'protocol': sync_match.group(1).strip(), - 'timestamp': ts, - } - - # Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …" - # is captured as a call event rather than a bare slot event. - # Classic dsd: "TG: 12345 Src: 67890" - # dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890" - # "TGT: 12345 | SRC: 67890" (pipe-delimited variant) - tg_match = re.search( - r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE - ) - if tg_match: - result = { - 'type': 'call', - 'talkgroup': int(tg_match.group(1)), - 'source_id': int(tg_match.group(2)), - 'timestamp': ts, - } - # Extract slot if present on the same line - slot_inline = re.search(r'Slot\s*(\d+)', line) - if slot_inline: - result['slot'] = int(slot_inline.group(1)) - return result - - # P25 NAC (Network Access Code) — check before voice/slot - nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line) - if nac_match: - return { - 'type': 'nac', - 'nac': nac_match.group(1), - 'timestamp': ts, - } - - # Voice frame detection — check BEFORE bare slot match - # Classic dsd: "Voice" keyword in frame lines - # dsd-fme: "voice" or "Voice LC" or "VOICE" in output - if re.search(r'\bvoice\b', line, re.IGNORECASE): - result = { - 'type': 'voice', - 'detail': line, - 'timestamp': ts, - } - slot_inline = re.search(r'Slot\s*(\d+)', line) - if slot_inline: - result['slot'] = int(slot_inline.group(1)) - return result - - # Bare slot info (only when line is *just* slot info, not voice/call) - slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line) - if slot_match: - return { - 'type': 'slot', - 'slot': int(slot_match.group(1)), - 'timestamp': ts, - } - - # dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc. - # Also catches "Closing", "Input", and other lifecycle lines. - # Forward as raw so the frontend can show decoder is alive. - return { - 'type': 'raw', - 'text': line[:200], - 'timestamp': ts, - } - - -_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle - -# 100ms of silence at 8kHz 16-bit mono = 1600 bytes -_SILENCE_CHUNK = b'\x00' * 1600 - - -def _register_audio_sink(sink: object) -> None: - """Register an ffmpeg stdin sink for mux fanout.""" - with _ffmpeg_sinks_lock: - _ffmpeg_sinks.add(sink) - - -def _unregister_audio_sink(sink: object) -> None: - """Remove an ffmpeg stdin sink from mux fanout.""" - with _ffmpeg_sinks_lock: - _ffmpeg_sinks.discard(sink) - - -def _get_audio_sinks() -> tuple[object, ...]: - """Snapshot current audio sinks for lock-free iteration.""" - with _ffmpeg_sinks_lock: - return tuple(_ffmpeg_sinks) - - -def _stop_process(proc: Optional[subprocess.Popen]) -> None: - """Terminate and unregister a subprocess if present.""" - if not proc: - return - if proc.poll() is None: - try: - proc.terminate() - proc.wait(timeout=2) - except Exception: - try: - proc.kill() - except Exception: - pass - unregister_process(proc) - - -def _reset_runtime_state(*, release_device: bool) -> None: - """Reset process + runtime state and optionally release SDR ownership.""" - global dmr_rtl_process, dmr_dsd_process - global dmr_running, dmr_has_audio, dmr_active_device - - _stop_process(dmr_dsd_process) - _stop_process(dmr_rtl_process) - dmr_rtl_process = None - dmr_dsd_process = None - dmr_running = False - dmr_has_audio = False - with _ffmpeg_sinks_lock: - _ffmpeg_sinks.clear() - - if release_device and dmr_active_device is not None: - app_module.release_sdr_device(dmr_active_device) - dmr_active_device = None - - -def _dsd_audio_mux(dsd_stdout): - """Mux thread: sole reader of dsd-fme stdout. - - Always drains dsd-fme's audio output to prevent the process from - blocking on stdout writes (which would also freeze stderr / text - data). When streaming clients are connected, forwards data to all - active ffmpeg stdin sinks with silence fill during voice gaps. - """ - try: - while dmr_running: - ready, _, _ = select.select([dsd_stdout], [], [], 0.1) - if ready: - data = os.read(dsd_stdout.fileno(), 4096) - if not data: - break - sinks = _get_audio_sinks() - for sink in sinks: - try: - sink.write(data) - sink.flush() - except (BrokenPipeError, OSError, ValueError): - _unregister_audio_sink(sink) - else: - # No audio from decoder — feed silence if client connected - sinks = _get_audio_sinks() - for sink in sinks: - try: - sink.write(_SILENCE_CHUNK) - sink.flush() - except (BrokenPipeError, OSError, ValueError): - _unregister_audio_sink(sink) - except (OSError, ValueError): - pass - - -def _queue_put(event: dict): - """Put an event on the DMR queue, dropping oldest if full.""" - try: - dmr_queue.put_nowait(event) - except queue.Full: - try: - dmr_queue.get_nowait() - except queue.Empty: - pass - try: - dmr_queue.put_nowait(event) - except queue.Full: - pass - - -def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen): - """Read DSD stderr output and push parsed events to the queue. - - Uses select() with a timeout so we can send periodic heartbeat - events while readline() would otherwise block indefinitely during - silence (no signal being decoded). - """ - global dmr_running - - try: - _queue_put({'type': 'status', 'text': 'started'}) - last_heartbeat = time.time() - - while dmr_running: - if dsd_process.poll() is not None: - break - - # Wait up to 1s for data on stderr instead of blocking forever - ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0) - - if ready: - line = dsd_process.stderr.readline() - if not line: - if dsd_process.poll() is not None: - break - continue - - text = line.decode('utf-8', errors='replace').strip() - if not text: - continue - - logger.debug("DSD raw: %s", text) - parsed = parse_dsd_output(text) - if parsed: - _queue_put(parsed) - last_heartbeat = time.time() - else: - # No stderr output — send heartbeat so frontend knows - # decoder is still alive and listening - now = time.time() - if now - last_heartbeat >= _HEARTBEAT_INTERVAL: - _queue_put({ - 'type': 'heartbeat', - 'timestamp': datetime.now().strftime('%H:%M:%S'), - }) - last_heartbeat = now - - except Exception as e: - logger.error(f"DSD stream error: {e}") - finally: - global dmr_active_device, dmr_rtl_process, dmr_dsd_process - global dmr_has_audio - dmr_running = False - dmr_has_audio = False - with _ffmpeg_sinks_lock: - _ffmpeg_sinks.clear() - # Capture exit info for diagnostics - rc = dsd_process.poll() - reason = 'stopped' - detail = '' - if rc is not None and rc != 0: - reason = 'crashed' - try: - remaining = dsd_process.stderr.read(1024) - if remaining: - detail = remaining.decode('utf-8', errors='replace').strip()[:200] - except Exception: - pass - logger.warning(f"DSD process exited with code {rc}: {detail}") - # Cleanup decoder + demod processes - _stop_process(dsd_process) - _stop_process(rtl_process) - dmr_rtl_process = None - dmr_dsd_process = None - _queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail}) - # Release SDR device - if dmr_active_device is not None: - app_module.release_sdr_device(dmr_active_device) - dmr_active_device = None - logger.info("DSD stream thread stopped") - - -# ============================================ -# API ENDPOINTS -# ============================================ - -@dmr_bp.route('/tools') -def check_tools() -> Response: - """Check for required tools.""" - dsd_path, _ = find_dsd() - rtl_fm = find_rtl_fm() - rx_fm = find_rx_fm() - ffmpeg = find_ffmpeg() - return jsonify({ - 'dsd': dsd_path is not None, - 'rtl_fm': rtl_fm is not None, - 'rx_fm': rx_fm is not None, - 'ffmpeg': ffmpeg is not None, - 'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None), - 'protocols': VALID_PROTOCOLS, - }) - - -@dmr_bp.route('/start', methods=['POST']) -def start_dmr() -> Response: - """Start digital voice decoding.""" - global dmr_rtl_process, dmr_dsd_process, dmr_thread - global dmr_running, dmr_has_audio, dmr_active_device - - dsd_path, is_fme = find_dsd() - if not dsd_path: - return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503 - - data = request.json or {} - - try: - frequency = validate_frequency(data.get('frequency', 462.5625)) - gain = int(validate_gain(data.get('gain', 40))) - device = validate_device_index(data.get('device', 0)) - protocol = str(data.get('protocol', 'auto')).lower() - ppm = validate_ppm(data.get('ppm', 0)) - except (ValueError, TypeError) as e: - return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 - - sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR - - if protocol not in VALID_PROTOCOLS: - return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400 - - if sdr_type == SDRType.RTL_SDR: - if not find_rtl_fm(): - return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503 - else: - if not find_rx_fm(): - return jsonify({ - 'status': 'error', - 'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.' - }), 503 - - # Clear stale queue - try: - while True: - dmr_queue.get_nowait() - except queue.Empty: - pass - - # Reserve running state before we start claiming resources/processes - # so concurrent /start requests cannot race each other. - with dmr_lock: - if dmr_running: - return jsonify({'status': 'error', 'message': 'Already running'}), 409 - dmr_running = True - dmr_has_audio = False - - # Claim SDR device — use protocol name so the device panel shows - # "D-STAR", "P25", etc. instead of always "DMR" - mode_label = protocol.upper() if protocol != 'auto' else 'DMR' - error = app_module.claim_sdr_device(device, mode_label) - if error: - with dmr_lock: - dmr_running = False - return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 - - dmr_active_device = device - - # Build FM demodulation command via SDR abstraction. - try: - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - builder = SDRFactory.get_builder(sdr_type) - rtl_cmd = builder.build_fm_demod_command( - device=sdr_device, - frequency_mhz=frequency, - sample_rate=48000, - gain=float(gain) if gain > 0 else None, - ppm=int(ppm) if ppm != 0 else None, - modulation='fm', - squelch=None, - bias_t=bool(data.get('bias_t', False)), - ) - if sdr_type == SDRType.RTL_SDR: - # Keep squelch fully open for digital bitstreams. - rtl_cmd.extend(['-l', '0']) - except Exception as e: - _reset_runtime_state(release_device=True) - return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 - - # Build DSD command - # Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for - # ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'. - # If ffmpeg is unavailable, fall back to discarding audio. - ffmpeg_path = find_ffmpeg() - if ffmpeg_path: - audio_out = '-' - else: - audio_out = 'null' if is_fme else '-' - logger.warning("ffmpeg not found — audio streaming disabled, data-only mode") - dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out] - if is_fme: - dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, [])) - dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, [])) - # Event log to stderr so we capture TG/Source/Voice data that - # dsd-fme may not output on stderr by default. - dsd_cmd.extend(['-J', '/dev/stderr']) - # Relax CRC checks for marginal signals — lets more frames - # through at the cost of occasional decode errors. - if data.get('relaxCrc', False): - dsd_cmd.append('-F') - else: - dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, [])) - - try: - dmr_rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - register_process(dmr_rtl_process) - - # DSD stdout → PIPE when ffmpeg available (audio pipeline), - # otherwise DEVNULL (data-only mode) - dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL - dmr_dsd_process = subprocess.Popen( - dsd_cmd, - stdin=dmr_rtl_process.stdout, - stdout=dsd_stdout, - stderr=subprocess.PIPE, - ) - register_process(dmr_dsd_process) - - # Allow rtl_fm to send directly to dsd - dmr_rtl_process.stdout.close() - - # Start mux thread: always drains dsd-fme stdout to prevent the - # process from blocking (which would freeze stderr / text data). - # ffmpeg is started lazily per-client in /dmr/audio/stream. - if ffmpeg_path and dmr_dsd_process.stdout: - dmr_has_audio = True - threading.Thread( - target=_dsd_audio_mux, - args=(dmr_dsd_process.stdout,), - daemon=True, - ).start() - - time.sleep(0.3) - - rtl_rc = dmr_rtl_process.poll() - dsd_rc = dmr_dsd_process.poll() - if rtl_rc is not None or dsd_rc is not None: - # Process died — capture stderr for diagnostics - rtl_err = '' - if dmr_rtl_process.stderr: - rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500] - dsd_err = '' - if dmr_dsd_process.stderr: - dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500] - logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}") - # Terminate surviving processes and release resources. - _reset_runtime_state(release_device=True) - # Surface a clear error to the user - detail = rtl_err.strip() or dsd_err.strip() - if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err: - msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.' - elif detail: - msg = f'Failed to start DSD pipeline: {detail}' - else: - msg = 'Failed to start DSD pipeline' - return jsonify({'status': 'error', 'message': msg}), 500 - - # Drain rtl_fm stderr in background to prevent pipe blocking - def _drain_rtl_stderr(proc): - try: - for line in proc.stderr: - pass - except Exception: - pass - - threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start() - - dmr_thread = threading.Thread( - target=stream_dsd_output, - args=(dmr_rtl_process, dmr_dsd_process), - daemon=True, - ) - dmr_thread.start() - - return jsonify({ - 'status': 'started', - 'frequency': frequency, - 'protocol': protocol, - 'sdr_type': sdr_type.value, - 'has_audio': dmr_has_audio, - }) - - except Exception as e: - logger.error(f"Failed to start DMR: {e}") - _reset_runtime_state(release_device=True) - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@dmr_bp.route('/stop', methods=['POST']) -def stop_dmr() -> Response: - """Stop digital voice decoding.""" - with dmr_lock: - _reset_runtime_state(release_device=True) - - return jsonify({'status': 'stopped'}) - - -@dmr_bp.route('/status') -def dmr_status() -> Response: - """Get DMR decoder status.""" - return jsonify({ - 'running': dmr_running, - 'device': dmr_active_device, - 'has_audio': dmr_has_audio, - }) - - -@dmr_bp.route('/audio/stream') -def stream_dmr_audio() -> Response: - """Stream decoded digital voice audio as WAV. - - Starts a per-client ffmpeg encoder. The global mux thread - (_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while - the client is connected, and discards audio otherwise. This avoids - the pipe-buffer deadlock that occurs when ffmpeg is started at - decoder launch (its stdout fills up before any HTTP client reads - it, back-pressuring the entire pipeline and freezing stderr/text - data output). - """ - if not dmr_running or not dmr_has_audio: - return Response(b'', mimetype='audio/wav', status=204) - - ffmpeg_path = find_ffmpeg() - if not ffmpeg_path: - return Response(b'', mimetype='audio/wav', status=503) - - encoder_cmd = [ - ffmpeg_path, '-hide_banner', '-loglevel', 'error', - '-fflags', 'nobuffer', '-flags', 'low_delay', - '-probesize', '32', '-analyzeduration', '0', - '-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0', - '-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1', - ] - audio_proc = subprocess.Popen( - encoder_cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - # Drain ffmpeg stderr to prevent blocking - threading.Thread( - target=lambda p: [None for _ in p.stderr], - args=(audio_proc,), daemon=True, - ).start() - - if audio_proc.stdin: - _register_audio_sink(audio_proc.stdin) - - def generate(): - try: - while dmr_running and audio_proc.poll() is None: - ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0) - if ready: - chunk = audio_proc.stdout.read(4096) - if chunk: - yield chunk - else: - break - else: - if audio_proc.poll() is not None: - break - except GeneratorExit: - pass - except Exception as e: - logger.error(f"DMR audio stream error: {e}") - finally: - # Disconnect mux → ffmpeg, then clean up - if audio_proc.stdin: - _unregister_audio_sink(audio_proc.stdin) - try: - audio_proc.stdin.close() - except Exception: - pass - try: - audio_proc.terminate() - audio_proc.wait(timeout=2) - except Exception: - try: - audio_proc.kill() - except Exception: - pass - - return Response( - generate(), - mimetype='audio/wav', - headers={ - 'Content-Type': 'audio/wav', - 'Cache-Control': 'no-cache, no-store', - 'X-Accel-Buffering': 'no', - 'Transfer-Encoding': 'chunked', - }, - ) - - -@dmr_bp.route('/stream') -def stream_dmr() -> Response: - """SSE stream for DMR decoder events.""" - def _on_msg(msg: dict[str, Any]) -> None: - process_event('dmr', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=dmr_queue, - channel_key='dmr', - timeout=SSE_QUEUE_TIMEOUT, - keepalive_interval=SSE_KEEPALIVE_INTERVAL, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response diff --git a/routes/offline.py b/routes/offline.py index ff25922..51326f7 100644 --- a/routes/offline.py +++ b/routes/offline.py @@ -9,13 +9,14 @@ import os offline_bp = Blueprint('offline', __name__, url_prefix='/offline') # Default offline settings -OFFLINE_DEFAULTS = { - 'offline.enabled': False, - 'offline.assets_source': 'cdn', - 'offline.fonts_source': 'cdn', - 'offline.tile_provider': 'cartodb_dark_cyan', - 'offline.tile_server_url': '' -} +OFFLINE_DEFAULTS = { + 'offline.enabled': False, + # Default to bundled assets/fonts to avoid third-party CDN privacy blocks. + 'offline.assets_source': 'local', + 'offline.fonts_source': 'local', + 'offline.tile_provider': 'cartodb_dark_cyan', + 'offline.tile_server_url': '' +} # Asset paths to check ASSET_PATHS = { diff --git a/routes/satellite.py b/routes/satellite.py index 9134b54..f9fb3ca 100644 --- a/routes/satellite.py +++ b/routes/satellite.py @@ -584,40 +584,67 @@ def list_tracked_satellites(): return jsonify({'status': 'success', 'satellites': sats}) -@satellite_bp.route('/tracked', methods=['POST']) -def add_tracked_satellites_endpoint(): - """Add one or more tracked satellites.""" - global _tle_cache - data = request.json - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # Accept a single satellite dict or a list - sat_list = data if isinstance(data, list) else [data] - - added = 0 - for sat in sat_list: - norad_id = str(sat.get('norad_id', sat.get('norad', ''))) - name = sat.get('name', '') - if not norad_id or not name: - continue +@satellite_bp.route('/tracked', methods=['POST']) +def add_tracked_satellites_endpoint(): + """Add one or more tracked satellites.""" + global _tle_cache + data = request.get_json(silent=True) + if not data: + return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + + # Accept a single satellite dict or a list + sat_list = data if isinstance(data, list) else [data] + + normalized: list[dict] = [] + for sat in sat_list: + norad_id = str(sat.get('norad_id', sat.get('norad', ''))) + name = sat.get('name', '') + if not norad_id or not name: + continue tle1 = sat.get('tle_line1', sat.get('tle1')) tle2 = sat.get('tle_line2', sat.get('tle2')) enabled = sat.get('enabled', True) - if add_tracked_satellite(norad_id, name, tle1, tle2, enabled): - added += 1 - - # Also inject into TLE cache if we have TLE data - if tle1 and tle2: - cache_key = name.replace(' ', '-').upper() - _tle_cache[cache_key] = (name, tle1, tle2) - - return jsonify({ - 'status': 'success', - 'added': added, - 'satellites': get_tracked_satellites(), - }) + normalized.append({ + 'norad_id': norad_id, + 'name': name, + 'tle_line1': tle1, + 'tle_line2': tle2, + 'enabled': bool(enabled), + 'builtin': False, + }) + + # Also inject into TLE cache if we have TLE data + if tle1 and tle2: + cache_key = name.replace(' ', '-').upper() + _tle_cache[cache_key] = (name, tle1, tle2) + + # Single inserts preserve previous behavior; list inserts use DB-level bulk path. + if len(normalized) == 1: + sat = normalized[0] + added = 1 if add_tracked_satellite( + sat['norad_id'], + sat['name'], + sat.get('tle_line1'), + sat.get('tle_line2'), + sat.get('enabled', True), + sat.get('builtin', False), + ) else 0 + else: + added = bulk_add_tracked_satellites(normalized) + + response_payload = { + 'status': 'success', + 'added': added, + 'processed': len(normalized), + } + + # Returning all tracked satellites for very large imports can stall the UI. + include_satellites = request.args.get('include_satellites', '').lower() == 'true' + if include_satellites or len(normalized) <= 32: + response_payload['satellites'] = get_tracked_satellites() + + return jsonify(response_payload) @satellite_bp.route('/tracked/', methods=['PUT']) diff --git a/routes/subghz.py b/routes/subghz.py index 823e09d..83fe45c 100644 --- a/routes/subghz.py +++ b/routes/subghz.py @@ -10,9 +10,10 @@ import queue from flask import Blueprint, jsonify, request, Response, send_file -from utils.logging import get_logger -from utils.sse import sse_stream -from utils.subghz import get_subghz_manager +from utils.logging import get_logger +from utils.sse import sse_stream +from utils.subghz import get_subghz_manager +from utils.event_pipeline import process_event from utils.constants import ( SUBGHZ_FREQ_MIN_MHZ, SUBGHZ_FREQ_MAX_MHZ, @@ -32,10 +33,14 @@ subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz') _subghz_queue: queue.Queue = queue.Queue(maxsize=200) -def _event_callback(event: dict) -> None: - """Forward SubGhzManager events to the SSE queue.""" - try: - _subghz_queue.put_nowait(event) +def _event_callback(event: dict) -> None: + """Forward SubGhzManager events to the SSE queue.""" + try: + process_event('subghz', event, event.get('type')) + except Exception: + pass + try: + _subghz_queue.put_nowait(event) except queue.Full: try: _subghz_queue.get_nowait() diff --git a/setup.sh b/setup.sh index 60d0fa3..148fd6f 100755 --- a/setup.sh +++ b/setup.sh @@ -233,10 +233,6 @@ check_tools() { info "GPS:" check_required "gpsd" "GPS daemon" gpsd - echo - info "Digital Voice:" - check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme - echo info "Audio:" check_required "ffmpeg" "Audio encoder/decoder" ffmpeg @@ -458,95 +454,6 @@ install_multimon_ng_from_source_macos() { ) } -install_dsd_from_source() { - info "Building DSD (Digital Speech Decoder) from source..." - info "This requires mbelib (vocoder library) as a prerequisite." - - if [[ "$OS" == "macos" ]]; then - brew_install cmake - brew_install libsndfile - brew_install ncurses - brew_install fftw - brew_install codec2 - brew_install librtlsdr - brew_install pulseaudio || true - else - apt_install build-essential git cmake libsndfile1-dev libpulse-dev \ - libfftw3-dev liblapack-dev libncurses-dev librtlsdr-dev libcodec2-dev - fi - - ( - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' EXIT - - # Step 1: Build and install mbelib (required dependency) - info "Building mbelib (vocoder library)..." - git clone https://github.com/lwvmobile/mbelib.git "$tmp_dir/mbelib" >/dev/null 2>&1 \ - || { warn "Failed to clone mbelib"; exit 1; } - - cd "$tmp_dir/mbelib" - git checkout ambe_tones >/dev/null 2>&1 || true - mkdir -p build && cd build - - if cmake .. >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then - if [[ "$OS" == "macos" ]]; then - if [[ -w /usr/local/lib ]]; then - make install >/dev/null 2>&1 - else - refresh_sudo - $SUDO make install >/dev/null 2>&1 - fi - else - $SUDO make install >/dev/null 2>&1 - $SUDO ldconfig 2>/dev/null || true - fi - ok "mbelib installed" - else - warn "Failed to build mbelib. Cannot build DSD without it." - exit 1 - fi - - # Step 2: Build dsd-fme (or fall back to original dsd) - info "Building dsd-fme..." - git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \ - || { warn "Failed to clone dsd-fme, trying original DSD..."; - git clone --depth 1 https://github.com/szechyjs/dsd.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \ - || { warn "Failed to clone DSD"; exit 1; }; } - - cd "$tmp_dir/dsd-fme" - mkdir -p build && cd build - - # On macOS, help cmake find Homebrew ncurses - local cmake_flags="" - if [[ "$OS" == "macos" ]]; then - local ncurses_prefix - ncurses_prefix="$(brew --prefix ncurses 2>/dev/null || echo /opt/homebrew/opt/ncurses)" - cmake_flags="-DCMAKE_PREFIX_PATH=$ncurses_prefix" - fi - - info "Compiling DSD..." - if cmake .. $cmake_flags >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then - if [[ "$OS" == "macos" ]]; then - if [[ -w /usr/local/bin ]]; then - install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true - else - refresh_sudo - $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true - fi - else - $SUDO make install >/dev/null 2>&1 \ - || $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null \ - || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null \ - || true - $SUDO ldconfig 2>/dev/null || true - fi - ok "DSD installed successfully" - else - warn "Failed to build DSD from source. DMR/P25 decoding will not be available." - fi - ) -} - install_dump1090_from_source_macos() { info "dump1090 not available via Homebrew. Building from source..." @@ -918,7 +825,7 @@ install_macos_packages() { sudo -v || { fail "sudo authentication failed"; exit 1; } fi - TOTAL_STEPS=22 + TOTAL_STEPS=21 CURRENT_STEP=0 progress "Checking Homebrew" @@ -941,19 +848,6 @@ install_macos_packages() { progress "SSTV decoder" ok "SSTV uses built-in pure Python decoder (no external tools needed)" - progress "Installing DSD (Digital Speech Decoder, optional)" - if ! cmd_exists dsd && ! cmd_exists dsd-fme; then - echo - info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding." - if ask_yes_no "Do you want to install DSD?"; then - install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available." - else - warn "Skipping DSD installation. DMR/P25 decoding will not be available." - fi - else - ok "DSD already installed" - fi - progress "Installing ffmpeg" brew_install ffmpeg @@ -1409,7 +1303,7 @@ install_debian_packages() { export NEEDRESTART_MODE=a fi - TOTAL_STEPS=28 + TOTAL_STEPS=27 CURRENT_STEP=0 progress "Updating APT package lists" @@ -1474,19 +1368,6 @@ install_debian_packages() { progress "SSTV decoder" ok "SSTV uses built-in pure Python decoder (no external tools needed)" - progress "Installing DSD (Digital Speech Decoder, optional)" - if ! cmd_exists dsd && ! cmd_exists dsd-fme; then - echo - info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding." - if ask_yes_no "Do you want to install DSD?"; then - install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available." - else - warn "Skipping DSD installation. DMR/P25 decoding will not be available." - fi - else - ok "DSD already installed" - fi - progress "Installing ffmpeg" apt_install ffmpeg diff --git a/static/css/components/proximity-viz.css b/static/css/components/proximity-viz.css index 133c09a..2a07dab 100644 --- a/static/css/components/proximity-viz.css +++ b/static/css/components/proximity-viz.css @@ -13,13 +13,11 @@ } .radar-device { - transition: transform 0.2s ease; - transform-origin: center center; cursor: pointer; } -.radar-device:hover { - transform: scale(1.2); +.radar-device:hover .radar-dot { + filter: brightness(1.5); } /* Invisible larger hit area to prevent hover flicker */ diff --git a/static/css/index.css b/static/css/index.css index 526648c..99691c7 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -2172,6 +2172,10 @@ header h1 .tagline { } .control-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; padding: 6px 12px; background: transparent; border: 1px solid var(--border-color); @@ -2182,6 +2186,14 @@ header h1 .tagline { letter-spacing: 1px; transition: all 0.2s ease; font-family: var(--font-sans); + line-height: 1.1; + white-space: nowrap; +} + +.control-btn .icon { + display: inline-flex; + align-items: center; + justify-content: center; } .control-btn:hover { diff --git a/static/js/components/proximity-radar.js b/static/js/components/proximity-radar.js index 9cd43d1..c08f338 100644 --- a/static/js/components/proximity-radar.js +++ b/static/js/components/proximity-radar.js @@ -33,10 +33,7 @@ const ProximityRadar = (function() { let activeFilter = null; let onDeviceClick = null; let selectedDeviceKey = null; - let isHovered = false; - let renderPending = false; let renderTimer = null; - let interactionLockUntil = 0; // timestamp: suppress renders briefly after click /** * Initialize the radar component @@ -128,28 +125,10 @@ const ProximityRadar = (function() { if (!deviceEl) return; const deviceKey = deviceEl.getAttribute('data-device-key'); if (onDeviceClick && deviceKey) { - // Lock out re-renders briefly so the DOM stays stable after click - interactionLockUntil = Date.now() + 500; onDeviceClick(deviceKey); } }); - devicesGroup.addEventListener('mouseenter', (e) => { - if (e.target.closest('.radar-device')) { - isHovered = true; - } - }, true); // capture phase so we catch enter on child elements - - devicesGroup.addEventListener('mouseleave', (e) => { - if (e.target.closest('.radar-device')) { - isHovered = false; - if (renderPending) { - renderPending = false; - renderDevices(); - } - } - }, true); - // Add sweep animation animateSweep(); } @@ -191,17 +170,10 @@ const ProximityRadar = (function() { function updateDevices(deviceList) { if (isPaused) return; - // Update device map deviceList.forEach(device => { devices.set(device.device_key, device); }); - // Defer render while user is hovering or interacting to prevent DOM rebuild flicker - if (isHovered || Date.now() < interactionLockUntil) { - renderPending = true; - return; - } - // Debounce rapid updates (e.g. per-device SSE events) if (renderTimer) clearTimeout(renderTimer); renderTimer = setTimeout(() => { @@ -211,7 +183,9 @@ const ProximityRadar = (function() { } /** - * Render device dots on the radar + * Render device dots on the radar using in-place DOM updates. + * Elements are never destroyed and recreated — only their attributes and + * transforms are mutated — so hover state is never disturbed by a render. */ function renderDevices() { const devicesGroup = svg.querySelector('.radar-devices'); @@ -219,6 +193,7 @@ const ProximityRadar = (function() { const center = CONFIG.size / 2; const maxRadius = center - CONFIG.padding; + const ns = 'http://www.w3.org/2000/svg'; // Filter devices let visibleDevices = Array.from(devices.values()); @@ -234,69 +209,195 @@ const ProximityRadar = (function() { visibleDevices = visibleDevices.filter(d => !d.in_baseline); } - // Build SVG for each device - const dots = visibleDevices.map(device => { - // Calculate position - const { x, y, radius } = calculateDevicePosition(device, center, maxRadius); + const visibleKeys = new Set(visibleDevices.map(d => d.device_key)); - // Calculate dot size based on confidence + // Remove elements for devices no longer in the visible set + devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => { + if (!visibleKeys.has(el.getAttribute('data-device-key'))) { + el.remove(); + } + }); + + // Sort weakest signal first so strongest renders on top (SVG z-order) + visibleDevices.sort((a, b) => (a.rssi_current || -100) - (b.rssi_current || -100)); + + // Compute all positions upfront so we can spread overlapping dots + const posMap = new Map(); + visibleDevices.forEach(device => { + posMap.set(device.device_key, calculateDevicePosition(device, center, maxRadius)); + }); + + // Spread dots that land too close together within the same band. + // minGapPx = diameter of largest possible hit area + 2px breathing room. + const maxHitArea = CONFIG.dotMaxSize + 4; + spreadOverlappingDots(Array.from(posMap.values()), center, maxHitArea * 2 + 2); + + visibleDevices.forEach(device => { + const { x, y } = posMap.get(device.device_key); const confidence = device.distance_confidence || 0.5; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; - - // Get color based on proximity band const color = getBandColor(device.proximity_band); - - // Check if newly seen (pulse animation) const isNew = device.age_seconds < 5; - const pulseClass = isNew ? 'radar-dot-pulse' : ''; - const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey; + const isSelected = !!(selectedDeviceKey && device.device_key === selectedDeviceKey); + const hitAreaSize = dotSize + 4; + const key = device.device_key; - // Hit area size (prevents hover flicker when scaling) - const hitAreaSize = Math.max(dotSize * 2, 15); + const existing = devicesGroup.querySelector( + `.radar-device-wrapper[data-device-key="${CSS.escape(key)}"]` + ); - return ` - - - - - ${isSelected ? ` - - - ` : ''} - - ${device.is_new && !isSelected ? `` : ''} - ${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm) - - - `; - }).join(''); + if (existing) { + // ── In-place update: mutate attributes, never recreate ── + existing.setAttribute('transform', `translate(${x}, ${y})`); - devicesGroup.innerHTML = dots; + const innerG = existing.querySelector('.radar-device'); + if (innerG) { + innerG.className.baseVal = + `radar-device${isNew ? ' radar-dot-pulse' : ''}${isSelected ? ' selected' : ''}`; + + const hitArea = innerG.querySelector('.radar-device-hitarea'); + if (hitArea) hitArea.setAttribute('r', hitAreaSize); + + const dot = innerG.querySelector('.radar-dot'); + if (dot) { + dot.setAttribute('r', dotSize); + dot.setAttribute('fill', color); + dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5); + dot.setAttribute('stroke', isSelected ? '#00d4ff' : color); + dot.setAttribute('stroke-width', isSelected ? 2 : 1); + } + + const title = innerG.querySelector('title'); + if (title) { + title.textContent = + `${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`; + } + + // Selection ring: add if newly selected, remove if deselected + let ring = innerG.querySelector('.radar-select-ring'); + if (isSelected && !ring) { + ring = buildSelectRing(ns, dotSize); + const hitAreaEl = innerG.querySelector('.radar-device-hitarea'); + innerG.insertBefore(ring, hitAreaEl ? hitAreaEl.nextSibling : innerG.firstChild); + } else if (!isSelected && ring) { + ring.remove(); + } + + // New-device indicator ring + let newRing = innerG.querySelector('.radar-new-ring'); + if (device.is_new && !isSelected) { + if (!newRing) { + newRing = document.createElementNS(ns, 'circle'); + newRing.classList.add('radar-new-ring'); + newRing.setAttribute('fill', 'none'); + newRing.setAttribute('stroke', '#3b82f6'); + newRing.setAttribute('stroke-width', '1'); + newRing.setAttribute('stroke-dasharray', '2,2'); + innerG.appendChild(newRing); + } + newRing.setAttribute('r', dotSize + 3); + } else if (newRing) { + newRing.remove(); + } + } + } else { + // ── Create new element ── + const wrapperG = document.createElementNS(ns, 'g'); + wrapperG.classList.add('radar-device-wrapper'); + wrapperG.setAttribute('data-device-key', key); + wrapperG.setAttribute('transform', `translate(${x}, ${y})`); + + const innerG = document.createElementNS(ns, 'g'); + innerG.classList.add('radar-device'); + if (isNew) innerG.classList.add('radar-dot-pulse'); + if (isSelected) innerG.classList.add('selected'); + innerG.setAttribute('data-device-key', escapeAttr(key)); + innerG.style.cursor = 'pointer'; + + const hitArea = document.createElementNS(ns, 'circle'); + hitArea.classList.add('radar-device-hitarea'); + hitArea.setAttribute('r', hitAreaSize); + hitArea.setAttribute('fill', 'transparent'); + innerG.appendChild(hitArea); + + if (isSelected) { + innerG.appendChild(buildSelectRing(ns, dotSize)); + } + + const dot = document.createElementNS(ns, 'circle'); + dot.classList.add('radar-dot'); + dot.setAttribute('r', dotSize); + dot.setAttribute('fill', color); + dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5); + dot.setAttribute('stroke', isSelected ? '#00d4ff' : color); + dot.setAttribute('stroke-width', isSelected ? 2 : 1); + innerG.appendChild(dot); + + if (device.is_new && !isSelected) { + const newRing = document.createElementNS(ns, 'circle'); + newRing.classList.add('radar-new-ring'); + newRing.setAttribute('r', dotSize + 3); + newRing.setAttribute('fill', 'none'); + newRing.setAttribute('stroke', '#3b82f6'); + newRing.setAttribute('stroke-width', '1'); + newRing.setAttribute('stroke-dasharray', '2,2'); + innerG.appendChild(newRing); + } + + const title = document.createElementNS(ns, 'title'); + title.textContent = + `${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`; + innerG.appendChild(title); + + wrapperG.appendChild(innerG); + devicesGroup.appendChild(wrapperG); + } + }); + } + + /** + * Build an animated SVG selection ring element + */ + function buildSelectRing(ns, dotSize) { + const ring = document.createElementNS(ns, 'circle'); + ring.classList.add('radar-select-ring'); + ring.setAttribute('r', dotSize + 8); + ring.setAttribute('fill', 'none'); + ring.setAttribute('stroke', '#00d4ff'); + ring.setAttribute('stroke-width', '2'); + ring.setAttribute('stroke-opacity', '0.8'); + + const animR = document.createElementNS(ns, 'animate'); + animR.setAttribute('attributeName', 'r'); + animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`); + animR.setAttribute('dur', '1.5s'); + animR.setAttribute('repeatCount', 'indefinite'); + ring.appendChild(animR); + + const animO = document.createElementNS(ns, 'animate'); + animO.setAttribute('attributeName', 'stroke-opacity'); + animO.setAttribute('values', '0.8;0.4;0.8'); + animO.setAttribute('dur', '1.5s'); + animO.setAttribute('repeatCount', 'indefinite'); + ring.appendChild(animO); + + return ring; } /** * Calculate device position on radar */ function calculateDevicePosition(device, center, maxRadius) { - // Calculate radius based on proximity band/distance + // Position is band-only — the band is computed server-side from rssi_ema + // (already smoothed), so it changes infrequently and never jitters. + // Using raw estimated_distance_m caused constant micro-movement as RSSI + // fluctuated on every update cycle. let radiusRatio; - const band = device.proximity_band || 'unknown'; - - if (device.estimated_distance_m != null) { - // Use actual distance (log scale) - const maxDistance = 15; - radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1)); - } else { - // Use band-based positioning - switch (band) { - case 'immediate': radiusRatio = 0.15; break; - case 'near': radiusRatio = 0.4; break; - case 'far': radiusRatio = 0.7; break; - default: radiusRatio = 0.9; break; - } + switch (device.proximity_band || 'unknown') { + case 'immediate': radiusRatio = 0.15; break; + case 'near': radiusRatio = 0.40; break; + case 'far': radiusRatio = 0.70; break; + default: radiusRatio = 0.90; break; } // Calculate angle based on device key hash (stable positioning) @@ -306,7 +407,53 @@ const ProximityRadar = (function() { const x = center + Math.sin(angle) * radius; const y = center - Math.cos(angle) * radius; - return { x, y, radius }; + return { x, y, angle, radius }; + } + + /** + * Spread dots within the same band that land too close together. + * Groups entries by radius, sorts by angle, then nudges neighbours + * apart until the arc gap between any two dots is at least minGapPx. + * Positions are updated in-place on the entry objects. + */ + function spreadOverlappingDots(entries, center, minGapPx) { + const groups = new Map(); + entries.forEach(e => { + const key = Math.round(e.radius); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(e); + }); + + groups.forEach((group, r) => { + if (group.length < 2 || r < 1) return; + const minSep = minGapPx / r; // radians + + group.sort((a, b) => a.angle - b.angle); + + // Iterative push-apart (up to 8 passes) + for (let iter = 0; iter < 8; iter++) { + let moved = false; + for (let i = 0; i < group.length; i++) { + const j = (i + 1) % group.length; + let gap = group[j].angle - group[i].angle; + if (gap < 0) gap += 2 * Math.PI; + if (gap < minSep) { + const push = (minSep - gap) / 2; + group[i].angle -= push; + group[j].angle += push; + moved = true; + } + } + if (!moved) break; + } + + // Normalise angles back to [0, 2π) and recompute x/y + group.forEach(e => { + e.angle = ((e.angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + e.x = center + Math.sin(e.angle) * r; + e.y = center - Math.cos(e.angle) * r; + }); + }); } /** diff --git a/static/js/components/signal-guess.js b/static/js/components/signal-guess.js index 9b75e63..b583b39 100644 --- a/static/js/components/signal-guess.js +++ b/static/js/components/signal-guess.js @@ -289,23 +289,10 @@ const SignalGuess = (function() { regions: ['GLOBAL'] }, - // LoRaWAN - { - label: 'LoRaWAN / LoRa Device', - tags: ['iot', 'lora', 'lpwan', 'telemetry'], - description: 'LoRa long-range IoT device', - frequencyRanges: [[863000000, 870000000], [902000000, 928000000]], - modulationHints: ['LoRa', 'CSS', 'FSK'], - bandwidthRange: [125000, 500000], - baseScore: 11, - isBurstType: true, - regions: ['UK/EU', 'US'] - }, - - // Key Fob - { - label: 'Remote Control / Key Fob', - tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'], + // Key Fob + { + label: 'Remote Control / Key Fob', + tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'], description: 'Wireless remote control or vehicle key fob', frequencyRanges: [[314900000, 315100000], [433050000, 434790000], [867000000, 869000000]], modulationHints: ['OOK', 'ASK', 'FSK', 'rolling'], diff --git a/static/js/core/command-palette.js b/static/js/core/command-palette.js index 0938480..9502553 100644 --- a/static/js/core/command-palette.js +++ b/static/js/core/command-palette.js @@ -24,7 +24,6 @@ const CommandPalette = (function() { { mode: 'sstv_general', label: 'HF SSTV' }, { mode: 'gps', label: 'GPS' }, { mode: 'meshtastic', label: 'Meshtastic' }, - { mode: 'dmr', label: 'Digital Voice' }, { mode: 'websdr', label: 'WebSDR' }, { mode: 'analytics', label: 'Analytics' }, { mode: 'spaceweather', label: 'Space Weather' }, diff --git a/static/js/core/run-state.js b/static/js/core/run-state.js index 468b9a1..b9ea250 100644 --- a/static/js/core/run-state.js +++ b/static/js/core/run-state.js @@ -2,7 +2,7 @@ const RunState = (function() { 'use strict'; const REFRESH_MS = 5000; - const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz']; + const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'subghz']; const MODE_ALIASES = { bt: 'bluetooth', bt_locate: 'bluetooth', @@ -21,7 +21,6 @@ const RunState = (function() { vdl2: 'VDL2', aprs: 'APRS', dsc: 'DSC', - dmr: 'DMR', subghz: 'SubGHz', }; @@ -181,7 +180,6 @@ const RunState = (function() { if (normalized.includes('aprs')) return 'aprs'; if (normalized.includes('dsc')) return 'dsc'; if (normalized.includes('subghz')) return 'subghz'; - if (normalized.includes('dmr')) return 'dmr'; if (normalized.includes('433')) return 'sensor'; return 'pager'; } diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index f6983fc..cbbabf7 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -6,8 +6,8 @@ const Settings = { // Default settings defaults: { 'offline.enabled': false, - 'offline.assets_source': 'cdn', - 'offline.fonts_source': 'cdn', + 'offline.assets_source': 'local', + 'offline.fonts_source': 'local', 'offline.tile_provider': 'cartodb_dark_cyan', 'offline.tile_server_url': '' }, diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index eb8a802..a295730 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -31,13 +31,19 @@ const BtLocate = (function() { let movementHeadMarker = null; let strongestMarker = null; let confidenceCircle = null; - let heatmapEnabled = true; + let heatmapEnabled = false; let movementEnabled = true; let autoFollowEnabled = true; let smoothingEnabled = true; let lastRenderedDetectionKey = null; let pendingHeatSync = false; let mapStabilizeTimer = null; + let modeActive = false; + let queuedDetection = null; + let queuedDetectionOptions = null; + let queuedDetectionTimer = null; + let lastDetectionRenderAt = 0; + let startRequestInFlight = false; const MAX_HEAT_POINTS = 1200; const MAX_TRAIL_POINTS = 1200; @@ -45,8 +51,9 @@ const BtLocate = (function() { const OUTLIER_HARD_JUMP_METERS = 2000; const OUTLIER_SOFT_JUMP_METERS = 450; const OUTLIER_MAX_SPEED_MPS = 50; - const MAP_STABILIZE_INTERVAL_MS = 150; - const MAP_STABILIZE_ATTEMPTS = 28; + const MAP_STABILIZE_INTERVAL_MS = 220; + const MAP_STABILIZE_ATTEMPTS = 8; + const MIN_DETECTION_RENDER_MS = 220; const OVERLAY_STORAGE_KEYS = { heatmap: 'btLocateHeatmapEnabled', movement: 'btLocateMovementEnabled', @@ -66,6 +73,20 @@ const BtLocate = (function() { 1.0: '#ef4444', }, }; + const BT_LOCATE_DEBUG = (() => { + try { + const params = new URLSearchParams(window.location.search || ''); + return params.get('btlocate_debug') === '1' || + localStorage.getItem('btLocateDebug') === 'true'; + } catch (_) { + return false; + } + })(); + + function debugLog() { + if (!BT_LOCATE_DEBUG) return; + console.log.apply(console, arguments); + } function getMapContainer() { if (!map || typeof map.getContainer !== 'function') return null; @@ -84,7 +105,71 @@ const BtLocate = (function() { return true; } + function statusUrl() { + try { + const params = new URLSearchParams(window.location.search || ''); + const debugFlag = params.get('btlocate_debug') === '1' || + localStorage.getItem('btLocateDebug') === 'true'; + return debugFlag ? '/bt_locate/status?debug=1' : '/bt_locate/status'; + } catch (_) { + return '/bt_locate/status'; + } + } + + function coerceLocation(lat, lon) { + const nLat = Number(lat); + const nLon = Number(lon); + if (!isFinite(nLat) || !isFinite(nLon)) return null; + if (nLat < -90 || nLat > 90 || nLon < -180 || nLon > 180) return null; + return { lat: nLat, lon: nLon }; + } + + function resolveFallbackLocation() { + try { + if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) { + const shared = ObserverLocation.getShared(); + const normalized = coerceLocation(shared?.lat, shared?.lon); + if (normalized) return normalized; + } + } catch (_) {} + + try { + const stored = localStorage.getItem('observerLocation'); + if (stored) { + const parsed = JSON.parse(stored); + const normalized = coerceLocation(parsed?.lat, parsed?.lon); + if (normalized) return normalized; + } + } catch (_) {} + + try { + const normalized = coerceLocation( + localStorage.getItem('observerLat'), + localStorage.getItem('observerLon') + ); + if (normalized) return normalized; + } catch (_) {} + + return coerceLocation(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON); + } + + function setStartButtonBusy(busy) { + const startBtn = document.getElementById('btLocateStartBtn'); + if (!startBtn) return; + if (busy) { + if (!startBtn.dataset.defaultLabel) { + startBtn.dataset.defaultLabel = startBtn.textContent || 'Start Locate'; + } + startBtn.disabled = true; + startBtn.textContent = 'Starting...'; + return; + } + startBtn.disabled = false; + startBtn.textContent = startBtn.dataset.defaultLabel || 'Start Locate'; + } + function init() { + modeActive = true; loadOverlayPreferences(); syncOverlayControls(); @@ -158,10 +243,10 @@ const BtLocate = (function() { initialized = true; } - function checkStatus() { - fetch('/bt_locate/status') - .then(r => r.json()) - .then(data => { + function checkStatus() { + fetch(statusUrl()) + .then(r => r.json()) + .then(data => { if (data.active) { sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now(); showActiveUI(); @@ -171,12 +256,25 @@ const BtLocate = (function() { } }) .catch(() => {}); - } - - function start() { - const mac = document.getElementById('btLocateMac')?.value.trim(); - const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); - const irk = document.getElementById('btLocateIrk')?.value.trim(); + } + + function normalizeMacInput(value) { + const raw = (value || '').trim().toUpperCase().replace(/-/g, ':'); + if (!raw) return ''; + const compact = raw.replace(/[^0-9A-F]/g, ''); + if (compact.length === 12) { + return compact.match(/.{1,2}/g).join(':'); + } + return raw; + } + + function start() { + if (startRequestInFlight) { + return; + } + const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value); + const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); + const irk = document.getElementById('btLocateIrk')?.value.trim(); const body = { environment: currentEnvironment }; if (mac) body.mac_address = mac; @@ -188,30 +286,44 @@ const BtLocate = (function() { if (handoffData?.known_name) body.known_name = handoffData.known_name; if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer; if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi; - - // Include user location as fallback when GPS unavailable - const userLat = localStorage.getItem('observerLat'); - const userLon = localStorage.getItem('observerLon'); - if (userLat !== null && userLon !== null) { - body.fallback_lat = parseFloat(userLat); - body.fallback_lon = parseFloat(userLon); + + // Include user location as fallback when GPS unavailable + const fallbackLocation = resolveFallbackLocation(); + if (fallbackLocation) { + body.fallback_lat = fallbackLocation.lat; + body.fallback_lon = fallbackLocation.lon; } - console.log('[BtLocate] Starting with body:', body); + debugLog('[BtLocate] Starting with body:', body); if (!body.mac_address && !body.name_pattern && !body.irk_hex && !body.device_id && !body.device_key && !body.fingerprint_id) { alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.'); return; } - - fetch('/bt_locate/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - .then(r => r.json()) - .then(data => { + + startRequestInFlight = true; + setStartButtonBusy(true); + + fetch('/bt_locate/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(async (r) => { + let data = null; + try { + data = await r.json(); + } catch (_) { + data = {}; + } + if (!r.ok || data.status !== 'started') { + const message = data.error || data.message || ('HTTP ' + r.status); + throw new Error(message); + } + return data; + }) + .then(data => { if (data.status === 'started') { sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now(); showActiveUI(); @@ -222,36 +334,60 @@ const BtLocate = (function() { updateScanStatus(data.session); // Restore any existing trail (e.g. from a stop/start cycle) restoreTrail(); + pollStatus(); } }) - .catch(err => console.error('[BtLocate] Start error:', err)); + .catch(err => { + console.error('[BtLocate] Start error:', err); + alert('BT Locate failed to start: ' + (err?.message || 'Unknown error')); + showIdleUI(); + }) + .finally(() => { + startRequestInFlight = false; + setStartButtonBusy(false); + }); + } + + function stop() { + fetch('/bt_locate/stop', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + queuedDetection = null; + queuedDetectionOptions = null; + showIdleUI(); + disconnectSSE(); + stopAudio(); + }) + .catch(err => console.error('[BtLocate] Stop error:', err)); } - function stop() { - fetch('/bt_locate/stop', { method: 'POST' }) - .then(r => r.json()) - .then(() => { - showIdleUI(); - disconnectSSE(); - stopAudio(); - }) - .catch(err => console.error('[BtLocate] Stop error:', err)); - } - - function showActiveUI() { - const startBtn = document.getElementById('btLocateStartBtn'); - const stopBtn = document.getElementById('btLocateStopBtn'); - if (startBtn) startBtn.style.display = 'none'; + function showActiveUI() { + setStartButtonBusy(false); + const startBtn = document.getElementById('btLocateStartBtn'); + const stopBtn = document.getElementById('btLocateStopBtn'); + if (startBtn) startBtn.style.display = 'none'; if (stopBtn) stopBtn.style.display = 'inline-block'; show('btLocateHud'); } - function showIdleUI() { - const startBtn = document.getElementById('btLocateStartBtn'); - const stopBtn = document.getElementById('btLocateStopBtn'); - if (startBtn) startBtn.style.display = 'inline-block'; - if (stopBtn) stopBtn.style.display = 'none'; - hide('btLocateHud'); + function showIdleUI() { + startRequestInFlight = false; + setStartButtonBusy(false); + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + queuedDetection = null; + queuedDetectionOptions = null; + const startBtn = document.getElementById('btLocateStartBtn'); + const stopBtn = document.getElementById('btLocateStopBtn'); + if (startBtn) startBtn.style.display = 'inline-block'; + if (stopBtn) stopBtn.style.display = 'none'; + hide('btLocateHud'); hide('btLocateScanStatus'); } @@ -276,13 +412,13 @@ const BtLocate = (function() { function connectSSE() { if (eventSource) eventSource.close(); - console.log('[BtLocate] Connecting SSE stream'); + debugLog('[BtLocate] Connecting SSE stream'); eventSource = new EventSource('/bt_locate/stream'); eventSource.addEventListener('detection', function(e) { try { const event = JSON.parse(e.data); - console.log('[BtLocate] Detection event:', event); + debugLog('[BtLocate] Detection event:', event); handleDetection(event); } catch (err) { console.error('[BtLocate] Parse error:', err); @@ -295,15 +431,16 @@ const BtLocate = (function() { }); eventSource.onerror = function() { - console.warn('[BtLocate] SSE error, polling fallback active'); + debugLog('[BtLocate] SSE error, polling fallback active'); if (eventSource && eventSource.readyState === EventSource.CLOSED) { eventSource = null; } }; - // Start polling fallback (catches data even if SSE fails) - startPolling(); - } + // Start polling fallback (catches data even if SSE fails) + startPolling(); + pollStatus(); + } function disconnectSSE() { if (eventSource) { @@ -349,10 +486,10 @@ const BtLocate = (function() { if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0'); } - function pollStatus() { - fetch('/bt_locate/status') - .then(r => r.json()) - .then(data => { + function pollStatus() { + fetch(statusUrl()) + .then(r => r.json()) + .then(data => { if (!data.active) { showIdleUI(); disconnectSSE(); @@ -447,7 +584,42 @@ const BtLocate = (function() { } } + function flushQueuedDetection() { + if (!queuedDetection) return; + const event = queuedDetection; + const options = queuedDetectionOptions || {}; + queuedDetection = null; + queuedDetectionOptions = null; + queuedDetectionTimer = null; + renderDetection(event, options); + } + function handleDetection(event, options = {}) { + if (!modeActive) { + return; + } + const now = Date.now(); + if (options.force || (now - lastDetectionRenderAt) >= MIN_DETECTION_RENDER_MS) { + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + queuedDetection = null; + queuedDetectionOptions = null; + renderDetection(event, options); + return; + } + + // Keep only the freshest event while throttled. + queuedDetection = event; + queuedDetectionOptions = options; + if (!queuedDetectionTimer) { + queuedDetectionTimer = setTimeout(flushQueuedDetection, MIN_DETECTION_RENDER_MS); + } + } + + function renderDetection(event, options = {}) { + lastDetectionRenderAt = Date.now(); const d = event?.data || event; if (!d) return; const detectionKey = buildDetectionKey(d); @@ -473,7 +645,7 @@ const BtLocate = (function() { try { mapPointAdded = addMapMarker(d, { suppressFollow: options.suppressFollow === true }); } catch (error) { - console.warn('[BtLocate] Map update skipped:', error); + debugLog('[BtLocate] Map update skipped:', error); mapPointAdded = false; } } @@ -878,7 +1050,7 @@ const BtLocate = (function() { } function ensureHeatLayer() { - if (!map || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return; + if (!map || !heatmapEnabled || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return; if (!heatLayer) { heatLayer = L.heatLayer([], HEAT_LAYER_OPTIONS); } @@ -886,9 +1058,19 @@ const BtLocate = (function() { function syncHeatLayer() { if (!map) return; + if (!heatmapEnabled) { + if (heatLayer && map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } + pendingHeatSync = false; + return; + } ensureHeatLayer(); if (!heatLayer) return; - if (!isMapContainerVisible()) { + if (!modeActive || !isMapContainerVisible()) { + if (map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } pendingHeatSync = true; return; } @@ -899,6 +1081,13 @@ const BtLocate = (function() { return; } } + if (!Array.isArray(heatPoints) || heatPoints.length === 0) { + if (map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } + pendingHeatSync = false; + return; + } try { heatLayer.setLatLngs(heatPoints); if (heatmapEnabled) { @@ -914,10 +1103,52 @@ const BtLocate = (function() { if (map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); } - console.warn('[BtLocate] Heatmap redraw deferred:', error); + debugLog('[BtLocate] Heatmap redraw deferred:', error); } } + function setActiveMode(active) { + modeActive = !!active; + if (!map) return; + + if (!modeActive) { + stopMapStabilization(); + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + queuedDetection = null; + queuedDetectionOptions = null; + // Pause BT Locate frontend work when mode is hidden. + disconnectSSE(); + if (heatLayer && map.hasLayer(heatLayer)) { + map.removeLayer(heatLayer); + } + pendingHeatSync = true; + return; + } + + setTimeout(() => { + if (!modeActive) return; + safeInvalidateMap(); + flushPendingHeatSync(); + syncHeatLayer(); + syncMovementLayer(); + syncStrongestMarker(); + updateConfidenceLayer(); + scheduleMapStabilization(8); + checkStatus(); + }, 80); + + // A second pass after layout settles (sidebar/visual transitions). + setTimeout(() => { + if (!modeActive) return; + safeInvalidateMap(); + flushPendingHeatSync(); + syncHeatLayer(); + }, 260); + } + function isMapRenderable() { if (!map || !isMapContainerVisible()) return false; if (typeof map.getSize === 'function') { @@ -1370,7 +1601,7 @@ const BtLocate = (function() { if (typeof showNotification === 'function') { showNotification(title, message); } else { - console.log('[BtLocate] ' + title + ': ' + message); + debugLog('[BtLocate] ' + title + ': ' + message); } } @@ -1461,7 +1692,7 @@ const BtLocate = (function() { // Resume must happen within a user gesture handler const ctx = audioCtx; ctx.resume().then(() => { - console.log('[BtLocate] AudioContext state:', ctx.state); + debugLog('[BtLocate] AudioContext state:', ctx.state); // Confirmation beep so user knows audio is working playTone(600, 0.08); }); @@ -1482,14 +1713,14 @@ const BtLocate = (function() { btn.classList.toggle('active', btn.dataset.env === env); }); // Push to running session if active - fetch('/bt_locate/status').then(r => r.json()).then(data => { - if (data.active) { - fetch('/bt_locate/environment', { + fetch(statusUrl()).then(r => r.json()).then(data => { + if (data.active) { + fetch('/bt_locate/environment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ environment: env }), }).then(r => r.json()).then(res => { - console.log('[BtLocate] Environment updated:', res); + debugLog('[BtLocate] Environment updated:', res); }); } }).catch(() => {}); @@ -1506,7 +1737,7 @@ const BtLocate = (function() { } function handoff(deviceInfo) { - console.log('[BtLocate] Handoff received:', deviceInfo); + debugLog('[BtLocate] Handoff received:', deviceInfo); handoffData = deviceInfo; // Populate fields @@ -1633,10 +1864,11 @@ const BtLocate = (function() { scheduleMapStabilization(8); } - return { - init, - start, - stop, + return { + init, + setActiveMode, + start, + stop, handoff, clearHandoff, setEnvironment, @@ -1651,4 +1883,6 @@ const BtLocate = (function() { invalidateMap, fetchPairedIrks, }; -})(); +})(); + +window.BtLocate = BtLocate; diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js deleted file mode 100644 index bb5c811..0000000 --- a/static/js/modes/dmr.js +++ /dev/null @@ -1,852 +0,0 @@ -/** - * Intercept - DMR / Digital Voice Mode - * Decoding DMR, P25, NXDN, D-STAR digital voice protocols - */ - -// ============== STATE ============== -let isDmrRunning = false; -let dmrEventSource = null; -let dmrCallCount = 0; -let dmrSyncCount = 0; -let dmrCallHistory = []; -let dmrCurrentProtocol = '--'; -let dmrModeLabel = 'dmr'; // Protocol label for device reservation -let dmrHasAudio = false; - -// ============== BOOKMARKS ============== -let dmrBookmarks = []; -const DMR_BOOKMARKS_KEY = 'dmrBookmarks'; -const DMR_SETTINGS_KEY = 'dmrSettings'; -const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']); - -// ============== SYNTHESIZER STATE ============== -let dmrSynthCanvas = null; -let dmrSynthCtx = null; -let dmrSynthBars = []; -let dmrSynthAnimationId = null; -let dmrSynthInitialized = false; -let dmrActivityLevel = 0; -let dmrActivityTarget = 0; -let dmrEventType = 'idle'; -let dmrLastEventTime = 0; -const DMR_BAR_COUNT = 48; -const DMR_DECAY_RATE = 0.015; -const DMR_BURST_SYNC = 0.6; -const DMR_BURST_CALL = 0.85; -const DMR_BURST_VOICE = 0.95; - -// ============== TOOLS CHECK ============== - -function checkDmrTools() { - fetch('/dmr/tools') - .then(r => r.json()) - .then(data => { - const warning = document.getElementById('dmrToolsWarning'); - const warningText = document.getElementById('dmrToolsWarningText'); - if (!warning) return; - - const selectedType = (typeof getSelectedSDRType === 'function') - ? getSelectedSDRType() - : 'rtlsdr'; - const missing = []; - if (!data.dsd) missing.push('dsd (Digital Speech Decoder)'); - if (selectedType === 'rtlsdr') { - if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)'); - } else if (!data.rx_fm) { - missing.push('rx_fm (SoapySDR demodulator)'); - } - if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)'); - - if (missing.length > 0) { - warning.style.display = 'block'; - if (warningText) warningText.textContent = missing.join(', '); - } else { - warning.style.display = 'none'; - } - - // Update audio panel availability - updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE'); - }) - .catch(() => {}); -} - -// ============== START / STOP ============== - -function startDmr() { - const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625); - const protocol = document.getElementById('dmrProtocol')?.value || 'auto'; - const gain = parseInt(document.getElementById('dmrGain')?.value || 40); - const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0); - const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false; - const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0; - const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr'; - - // Use protocol name for device reservation so panel shows "D-STAR", "P25", etc. - dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr'; - - // Check device availability before starting - if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) { - return; - } - - // Save settings to localStorage for persistence - try { - localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({ - frequency, protocol, gain, ppm, relaxCrc - })); - } catch (e) { /* localStorage unavailable */ } - - fetch('/dmr/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType }) - }) - .then(r => r.json()) - .then(data => { - if (data.status === 'started') { - isDmrRunning = true; - dmrCallCount = 0; - dmrSyncCount = 0; - dmrCallHistory = []; - updateDmrUI(); - connectDmrSSE(); - dmrEventType = 'idle'; - dmrActivityTarget = 0.1; - dmrLastEventTime = Date.now(); - if (!dmrSynthInitialized) initDmrSynthesizer(); - updateDmrSynthStatus(); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'DECODING'; - if (typeof reserveDevice === 'function') { - reserveDevice(parseInt(device), dmrModeLabel); - } - // Start audio if available - dmrHasAudio = !!data.has_audio; - if (dmrHasAudio) startDmrAudio(); - updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE'); - if (typeof showNotification === 'function') { - showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`); - } - } else if (data.status === 'error' && data.message === 'Already running') { - // Backend has an active session the frontend lost track of — resync - isDmrRunning = true; - updateDmrUI(); - connectDmrSSE(); - if (!dmrSynthInitialized) initDmrSynthesizer(); - dmrEventType = 'idle'; - dmrActivityTarget = 0.1; - dmrLastEventTime = Date.now(); - updateDmrSynthStatus(); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'DECODING'; - if (typeof showNotification === 'function') { - showNotification('DMR', 'Reconnected to active session'); - } - } else { - if (typeof showNotification === 'function') { - showNotification('Error', data.message || 'Failed to start DMR'); - } - } - }) - .catch(err => console.error('[DMR] Start error:', err)); -} - -function stopDmr() { - stopDmrAudio(); - fetch('/dmr/stop', { method: 'POST' }) - .then(r => r.json()) - .then(() => { - isDmrRunning = false; - if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } - updateDmrUI(); - dmrEventType = 'stopped'; - dmrActivityTarget = 0; - updateDmrSynthStatus(); - updateDmrAudioStatus('OFF'); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'STOPPED'; - if (typeof releaseDevice === 'function') { - releaseDevice(dmrModeLabel); - } - }) - .catch(err => console.error('[DMR] Stop error:', err)); -} - -// ============== SSE STREAMING ============== - -function connectDmrSSE() { - if (dmrEventSource) dmrEventSource.close(); - dmrEventSource = new EventSource('/dmr/stream'); - - dmrEventSource.onmessage = function(event) { - const msg = JSON.parse(event.data); - handleDmrMessage(msg); - }; - - dmrEventSource.onerror = function() { - if (isDmrRunning) { - setTimeout(connectDmrSSE, 2000); - } - }; -} - -function handleDmrMessage(msg) { - if (dmrSynthInitialized) dmrSynthPulse(msg.type); - - if (msg.type === 'sync') { - dmrCurrentProtocol = msg.protocol || '--'; - const protocolEl = document.getElementById('dmrActiveProtocol'); - if (protocolEl) protocolEl.textContent = dmrCurrentProtocol; - const mainProtocolEl = document.getElementById('dmrMainProtocol'); - if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol; - dmrSyncCount++; - const syncCountEl = document.getElementById('dmrSyncCount'); - if (syncCountEl) syncCountEl.textContent = dmrSyncCount; - } else if (msg.type === 'call') { - dmrCallCount++; - const countEl = document.getElementById('dmrCallCount'); - if (countEl) countEl.textContent = dmrCallCount; - const mainCountEl = document.getElementById('dmrMainCallCount'); - if (mainCountEl) mainCountEl.textContent = dmrCallCount; - - // Update current call display - const slotInfo = msg.slot != null ? ` -
- Slot - ${msg.slot} -
` : ''; - const callEl = document.getElementById('dmrCurrentCall'); - if (callEl) { - callEl.innerHTML = ` -
- Talkgroup - ${msg.talkgroup} -
-
- Source ID - ${msg.source_id} -
${slotInfo} -
- Time - ${msg.timestamp} -
- `; - } - - // Add to history - dmrCallHistory.unshift({ - talkgroup: msg.talkgroup, - source_id: msg.source_id, - protocol: dmrCurrentProtocol, - time: msg.timestamp, - }); - if (dmrCallHistory.length > 50) dmrCallHistory.length = 50; - renderDmrHistory(); - - } else if (msg.type === 'slot') { - // Update slot info in current call - } else if (msg.type === 'raw') { - // Raw DSD output — triggers synthesizer activity via dmrSynthPulse - } else if (msg.type === 'heartbeat') { - // Decoder is alive and listening — keep synthesizer in listening state - if (isDmrRunning && dmrSynthInitialized) { - if (dmrEventType === 'idle' || dmrEventType === 'raw') { - dmrEventType = 'raw'; - dmrActivityTarget = Math.max(dmrActivityTarget, 0.15); - dmrLastEventTime = Date.now(); - updateDmrSynthStatus(); - } - } - } else if (msg.type === 'status') { - const statusEl = document.getElementById('dmrStatus'); - if (msg.text === 'started') { - if (statusEl) statusEl.textContent = 'DECODING'; - } else if (msg.text === 'crashed') { - isDmrRunning = false; - stopDmrAudio(); - updateDmrUI(); - dmrEventType = 'stopped'; - dmrActivityTarget = 0; - updateDmrSynthStatus(); - updateDmrAudioStatus('OFF'); - if (statusEl) statusEl.textContent = 'CRASHED'; - if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel); - const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`; - if (typeof showNotification === 'function') { - showNotification('DMR Error', detail); - } - } else if (msg.text === 'stopped') { - isDmrRunning = false; - stopDmrAudio(); - updateDmrUI(); - dmrEventType = 'stopped'; - dmrActivityTarget = 0; - updateDmrSynthStatus(); - updateDmrAudioStatus('OFF'); - if (statusEl) statusEl.textContent = 'STOPPED'; - if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel); - } - } -} - -// ============== UI ============== - -function updateDmrUI() { - const startBtn = document.getElementById('startDmrBtn'); - const stopBtn = document.getElementById('stopDmrBtn'); - if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block'; - if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none'; -} - -function renderDmrHistory() { - const container = document.getElementById('dmrHistoryBody'); - if (!container) return; - - const historyCountEl = document.getElementById('dmrHistoryCount'); - if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`; - - if (dmrCallHistory.length === 0) { - container.innerHTML = 'No calls recorded'; - return; - } - - container.innerHTML = dmrCallHistory.slice(0, 20).map(call => ` - - ${call.time} - ${call.talkgroup} - ${call.source_id} - ${call.protocol} - - `).join(''); -} - -// ============== SYNTHESIZER ============== - -function initDmrSynthesizer() { - dmrSynthCanvas = document.getElementById('dmrSynthCanvas'); - if (!dmrSynthCanvas) return; - - // Use the canvas element's own rendered size for the backing buffer - const rect = dmrSynthCanvas.getBoundingClientRect(); - const w = Math.round(rect.width) || 600; - const h = Math.round(rect.height) || 70; - dmrSynthCanvas.width = w; - dmrSynthCanvas.height = h; - - dmrSynthCtx = dmrSynthCanvas.getContext('2d'); - - dmrSynthBars = []; - for (let i = 0; i < DMR_BAR_COUNT; i++) { - dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 }; - } - - dmrActivityLevel = 0; - dmrActivityTarget = 0; - dmrEventType = isDmrRunning ? 'idle' : 'stopped'; - dmrSynthInitialized = true; - - updateDmrSynthStatus(); - - if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId); - drawDmrSynthesizer(); -} - -function drawDmrSynthesizer() { - if (!dmrSynthCtx || !dmrSynthCanvas) return; - - const width = dmrSynthCanvas.width; - const height = dmrSynthCanvas.height; - const barWidth = (width / DMR_BAR_COUNT) - 2; - const now = Date.now(); - - // Clear canvas - dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; - dmrSynthCtx.fillRect(0, 0, width, height); - - // Decay activity toward target. Window must exceed the backend - // heartbeat interval (3s) so the status doesn't flip-flop between - // LISTENING and IDLE on every heartbeat cycle. - const timeSinceEvent = now - dmrLastEventTime; - if (timeSinceEvent > 5000) { - // No events for 5s — decay target toward idle - dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE); - if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') { - dmrEventType = 'idle'; - updateDmrSynthStatus(); - } - } - - // Smooth approach to target - dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08; - - // Determine effective activity (idle breathing when stopped/idle) - let effectiveActivity = dmrActivityLevel; - if (dmrEventType === 'stopped') { - effectiveActivity = 0; - } else if (effectiveActivity < 0.1 && isDmrRunning) { - // Visible idle breathing — shows decoder is alive and listening - effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06; - } - - // Ripple timing for sync events - const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0; - // Voice ripple overlay - const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0; - - // Update bar targets and physics - for (let i = 0; i < DMR_BAR_COUNT; i++) { - const time = now / 200; - const wave1 = Math.sin(time + i * 0.3) * 0.2; - const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15; - const randomAmount = 0.05 + effectiveActivity * 0.25; - const random = (Math.random() - 0.5) * randomAmount; - - // Bell curve — center bars taller - const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2); - const centerBoost = 1 - centerDist * 0.5; - - // Sync ripple: center-outward wave burst - let rippleBoost = 0; - if (syncRippleAge > 0) { - const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2; - const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos; - rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4; - } - - const baseHeight = 0.1 + effectiveActivity * 0.55; - dmrSynthBars[i].targetHeight = Math.max(2, - (baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) * - effectiveActivity * centerBoost * height - ); - - // Spring physics - const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1; - const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height; - dmrSynthBars[i].velocity += diff * springStrength; - dmrSynthBars[i].velocity *= 0.78; - dmrSynthBars[i].height += dmrSynthBars[i].velocity; - dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height)); - } - - // Draw bars - for (let i = 0; i < DMR_BAR_COUNT; i++) { - const x = i * (barWidth + 2) + 1; - const barHeight = dmrSynthBars[i].height; - const y = (height - barHeight) / 2; - - // HSL color by event type - let hue, saturation, lightness; - if (dmrEventType === 'voice' && timeSinceEvent < 3000) { - hue = 30; // Orange - saturation = 85; - lightness = 40 + (barHeight / height) * 25; - } else if (dmrEventType === 'call' && timeSinceEvent < 3000) { - hue = 120; // Green - saturation = 80; - lightness = 35 + (barHeight / height) * 30; - } else if (dmrEventType === 'sync' && timeSinceEvent < 2000) { - hue = 185; // Cyan - saturation = 85; - lightness = 38 + (barHeight / height) * 25; - } else if (dmrEventType === 'stopped') { - hue = 220; - saturation = 20; - lightness = 18 + (barHeight / height) * 8; - } else { - // Idle / decayed - hue = 210; - saturation = 40; - lightness = 25 + (barHeight / height) * 15; - } - - // Vertical gradient per bar - const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight); - gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`); - gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`); - gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`); - - dmrSynthCtx.fillStyle = gradient; - dmrSynthCtx.fillRect(x, y, barWidth, barHeight); - - // Glow on tall bars - if (barHeight > height * 0.5 && effectiveActivity > 0.4) { - dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`; - dmrSynthCtx.shadowBlur = 8; - dmrSynthCtx.fillRect(x, y, barWidth, barHeight); - dmrSynthCtx.shadowBlur = 0; - } - } - - // Center line - dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)'; - dmrSynthCtx.lineWidth = 1; - dmrSynthCtx.beginPath(); - dmrSynthCtx.moveTo(0, height / 2); - dmrSynthCtx.lineTo(width, height / 2); - dmrSynthCtx.stroke(); - - dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer); -} - -function dmrSynthPulse(type) { - dmrLastEventTime = Date.now(); - - if (type === 'sync') { - dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC); - dmrEventType = 'sync'; - } else if (type === 'call') { - dmrActivityTarget = DMR_BURST_CALL; - dmrEventType = 'call'; - } else if (type === 'voice') { - dmrActivityTarget = DMR_BURST_VOICE; - dmrEventType = 'voice'; - } else if (type === 'slot' || type === 'nac') { - dmrActivityTarget = Math.max(dmrActivityTarget, 0.5); - } else if (type === 'raw') { - // Any DSD output means the decoder is alive and processing - dmrActivityTarget = Math.max(dmrActivityTarget, 0.25); - if (dmrEventType === 'idle') dmrEventType = 'raw'; - } - // keepalive and status don't change visuals - - updateDmrSynthStatus(); -} - -function updateDmrSynthStatus() { - const el = document.getElementById('dmrSynthStatus'); - if (!el) return; - - const labels = { - stopped: 'STOPPED', - idle: 'IDLE', - raw: 'LISTENING', - sync: 'SYNC', - call: 'CALL', - voice: 'VOICE' - }; - const colors = { - stopped: 'var(--text-muted)', - idle: 'var(--text-muted)', - raw: '#607d8b', - sync: '#00e5ff', - call: '#4caf50', - voice: '#ff9800' - }; - - el.textContent = labels[dmrEventType] || 'IDLE'; - el.style.color = colors[dmrEventType] || 'var(--text-muted)'; -} - -function resizeDmrSynthesizer() { - if (!dmrSynthCanvas) return; - const rect = dmrSynthCanvas.getBoundingClientRect(); - if (rect.width > 0) { - dmrSynthCanvas.width = Math.round(rect.width); - dmrSynthCanvas.height = Math.round(rect.height) || 70; - } -} - -function stopDmrSynthesizer() { - if (dmrSynthAnimationId) { - cancelAnimationFrame(dmrSynthAnimationId); - dmrSynthAnimationId = null; - } -} - -window.addEventListener('resize', resizeDmrSynthesizer); - -// ============== AUDIO ============== - -function startDmrAudio() { - const audioPlayer = document.getElementById('dmrAudioPlayer'); - if (!audioPlayer) return; - const streamUrl = `/dmr/audio/stream?t=${Date.now()}`; - audioPlayer.src = streamUrl; - const volSlider = document.getElementById('dmrAudioVolume'); - if (volSlider) audioPlayer.volume = volSlider.value / 100; - - audioPlayer.onplaying = () => updateDmrAudioStatus('STREAMING'); - audioPlayer.onerror = () => { - // Retry if decoder is still running (stream may have dropped) - if (isDmrRunning && dmrHasAudio) { - console.warn('[DMR] Audio stream error, retrying in 2s...'); - updateDmrAudioStatus('RECONNECTING'); - setTimeout(() => { - if (isDmrRunning && dmrHasAudio) startDmrAudio(); - }, 2000); - } else { - updateDmrAudioStatus('OFF'); - } - }; - - audioPlayer.play().catch(e => { - console.warn('[DMR] Audio autoplay blocked:', e); - if (typeof showNotification === 'function') { - showNotification('Audio Ready', 'Click the page or interact to enable audio playback'); - } - }); -} - -function stopDmrAudio() { - const audioPlayer = document.getElementById('dmrAudioPlayer'); - if (audioPlayer) { - audioPlayer.pause(); - audioPlayer.src = ''; - } - dmrHasAudio = false; -} - -function setDmrAudioVolume(value) { - const audioPlayer = document.getElementById('dmrAudioPlayer'); - if (audioPlayer) audioPlayer.volume = value / 100; -} - -function updateDmrAudioStatus(status) { - const el = document.getElementById('dmrAudioStatus'); - if (!el) return; - el.textContent = status; - const colors = { - 'OFF': 'var(--text-muted)', - 'STREAMING': 'var(--accent-green)', - 'ERROR': 'var(--accent-red)', - 'UNAVAILABLE': 'var(--text-muted)', - }; - el.style.color = colors[status] || 'var(--text-muted)'; -} - -// ============== SETTINGS PERSISTENCE ============== - -function restoreDmrSettings() { - try { - const saved = localStorage.getItem(DMR_SETTINGS_KEY); - if (!saved) return; - const s = JSON.parse(saved); - const freqEl = document.getElementById('dmrFrequency'); - const protoEl = document.getElementById('dmrProtocol'); - const gainEl = document.getElementById('dmrGain'); - const ppmEl = document.getElementById('dmrPPM'); - const crcEl = document.getElementById('dmrRelaxCrc'); - if (freqEl && s.frequency != null) freqEl.value = s.frequency; - if (protoEl && s.protocol) protoEl.value = s.protocol; - if (gainEl && s.gain != null) gainEl.value = s.gain; - if (ppmEl && s.ppm != null) ppmEl.value = s.ppm; - if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc; - } catch (e) { /* localStorage unavailable */ } -} - -// ============== BOOKMARKS ============== - -function loadDmrBookmarks() { - try { - const saved = localStorage.getItem(DMR_BOOKMARKS_KEY); - const parsed = saved ? JSON.parse(saved) : []; - if (!Array.isArray(parsed)) { - dmrBookmarks = []; - } else { - dmrBookmarks = parsed - .map((entry) => { - const freq = Number(entry?.freq); - if (!Number.isFinite(freq) || freq <= 0) return null; - const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol); - const rawLabel = String(entry?.label || '').trim(); - const label = rawLabel || `${freq.toFixed(4)} MHz`; - return { - freq, - protocol, - label, - added: entry?.added, - }; - }) - .filter(Boolean); - } - } catch (e) { - dmrBookmarks = []; - } - renderDmrBookmarks(); -} - -function saveDmrBookmarks() { - try { - localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks)); - } catch (e) { /* localStorage unavailable */ } -} - -function sanitizeDmrBookmarkProtocol(protocol) { - const value = String(protocol || 'auto').toLowerCase(); - return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto'; -} - -function addDmrBookmark() { - const freqInput = document.getElementById('dmrBookmarkFreq'); - const labelInput = document.getElementById('dmrBookmarkLabel'); - if (!freqInput) return; - - const freq = parseFloat(freqInput.value); - if (isNaN(freq) || freq <= 0) { - if (typeof showNotification === 'function') { - showNotification('Invalid Frequency', 'Enter a valid frequency'); - } - return; - } - - const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto'); - const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`; - - // Duplicate check - if (dmrBookmarks.some(b => b.freq === freq && b.protocol === protocol)) { - if (typeof showNotification === 'function') { - showNotification('Duplicate', 'This frequency/protocol is already bookmarked'); - } - return; - } - - dmrBookmarks.push({ freq, protocol, label, added: new Date().toISOString() }); - saveDmrBookmarks(); - renderDmrBookmarks(); - freqInput.value = ''; - if (labelInput) labelInput.value = ''; - - if (typeof showNotification === 'function') { - showNotification('Bookmark Added', `${freq.toFixed(4)} MHz saved`); - } -} - -function addCurrentDmrFreqBookmark() { - const freqEl = document.getElementById('dmrFrequency'); - const freqInput = document.getElementById('dmrBookmarkFreq'); - if (freqEl && freqInput) { - freqInput.value = freqEl.value; - } - addDmrBookmark(); -} - -function removeDmrBookmark(index) { - dmrBookmarks.splice(index, 1); - saveDmrBookmarks(); - renderDmrBookmarks(); -} - -function dmrQuickTune(freq, protocol) { - const freqEl = document.getElementById('dmrFrequency'); - const protoEl = document.getElementById('dmrProtocol'); - if (freqEl && Number.isFinite(freq)) freqEl.value = freq; - if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol); -} - -function renderDmrBookmarks() { - const container = document.getElementById('dmrBookmarksList'); - if (!container) return; - - container.replaceChildren(); - - if (dmrBookmarks.length === 0) { - const emptyEl = document.createElement('div'); - emptyEl.style.color = 'var(--text-muted)'; - emptyEl.style.textAlign = 'center'; - emptyEl.style.padding = '10px'; - emptyEl.style.fontSize = '11px'; - emptyEl.textContent = 'No bookmarks saved'; - container.appendChild(emptyEl); - return; - } - - dmrBookmarks.forEach((b, i) => { - const row = document.createElement('div'); - row.style.display = 'flex'; - row.style.justifyContent = 'space-between'; - row.style.alignItems = 'center'; - row.style.padding = '4px 6px'; - row.style.background = 'rgba(0,0,0,0.2)'; - row.style.borderRadius = '3px'; - row.style.marginBottom = '3px'; - - const tuneBtn = document.createElement('button'); - tuneBtn.type = 'button'; - tuneBtn.style.cursor = 'pointer'; - tuneBtn.style.color = 'var(--accent-cyan)'; - tuneBtn.style.fontSize = '11px'; - tuneBtn.style.flex = '1'; - tuneBtn.style.background = 'none'; - tuneBtn.style.border = 'none'; - tuneBtn.style.textAlign = 'left'; - tuneBtn.style.padding = '0'; - tuneBtn.textContent = b.label; - tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`; - tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol)); - - const protocolEl = document.createElement('span'); - protocolEl.style.color = 'var(--text-muted)'; - protocolEl.style.fontSize = '9px'; - protocolEl.style.margin = '0 6px'; - protocolEl.textContent = b.protocol.toUpperCase(); - - const deleteBtn = document.createElement('button'); - deleteBtn.type = 'button'; - deleteBtn.style.background = 'none'; - deleteBtn.style.border = 'none'; - deleteBtn.style.color = 'var(--accent-red)'; - deleteBtn.style.cursor = 'pointer'; - deleteBtn.style.fontSize = '12px'; - deleteBtn.style.padding = '0 4px'; - deleteBtn.textContent = '\u00d7'; - deleteBtn.addEventListener('click', () => removeDmrBookmark(i)); - - row.appendChild(tuneBtn); - row.appendChild(protocolEl); - row.appendChild(deleteBtn); - container.appendChild(row); - }); -} - -// ============== STATUS SYNC ============== - -function checkDmrStatus() { - fetch('/dmr/status') - .then(r => r.json()) - .then(data => { - if (data.running && !isDmrRunning) { - // Backend is running but frontend lost track — resync - isDmrRunning = true; - updateDmrUI(); - connectDmrSSE(); - if (!dmrSynthInitialized) initDmrSynthesizer(); - dmrEventType = 'idle'; - dmrActivityTarget = 0.1; - dmrLastEventTime = Date.now(); - updateDmrSynthStatus(); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'DECODING'; - } else if (!data.running && isDmrRunning) { - // Backend stopped but frontend didn't know - isDmrRunning = false; - if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; } - updateDmrUI(); - dmrEventType = 'stopped'; - dmrActivityTarget = 0; - updateDmrSynthStatus(); - const statusEl = document.getElementById('dmrStatus'); - if (statusEl) statusEl.textContent = 'STOPPED'; - } - }) - .catch(() => {}); -} - -// ============== INIT ============== - -document.addEventListener('DOMContentLoaded', () => { - restoreDmrSettings(); - loadDmrBookmarks(); -}); - -// ============== EXPORTS ============== - -window.startDmr = startDmr; -window.stopDmr = stopDmr; -window.checkDmrTools = checkDmrTools; -window.checkDmrStatus = checkDmrStatus; -window.initDmrSynthesizer = initDmrSynthesizer; -window.setDmrAudioVolume = setDmrAudioVolume; -window.addDmrBookmark = addDmrBookmark; -window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark; -window.removeDmrBookmark = removeDmrBookmark; -window.dmrQuickTune = dmrQuickTune; diff --git a/templates/index.html b/templates/index.html index 477c053..a72830c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -612,8 +612,6 @@ {% include 'partials/modes/meshtastic.html' %} - {% include 'partials/modes/dmr.html' %} - {% include 'partials/modes/websdr.html' %} {% include 'partials/modes/subghz.html' %} @@ -1883,76 +1881,6 @@ - - -