Merge branch 'smittix:main' into main

This commit is contained in:
Mitch Ross
2026-02-21 12:12:54 -05:00
committed by GitHub
30 changed files with 1034 additions and 2795 deletions

View File

@@ -94,7 +94,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpulse-dev \ libpulse-dev \
libfftw3-dev \ libfftw3-dev \
liblapack-dev \ liblapack-dev \
libcodec2-dev \
libglib2.0-dev \ libglib2.0-dev \
libxml2-dev \ libxml2-dev \
# Build dump1090 # Build dump1090
@@ -199,27 +198,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& go install github.com/bemasher/rtlamr@latest \ && go install github.com/bemasher/rtlamr@latest \
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \ && cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
&& rm -rf /usr/local/go /tmp/gopath \ && 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 # Cleanup build tools to reduce image size
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx # libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
&& apt-get remove -y \ && apt-get remove -y \
@@ -247,7 +225,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpulse-dev \ libpulse-dev \
libfftw3-dev \ libfftw3-dev \
liblapack-dev \ liblapack-dev \
libcodec2-dev \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

102
app.py
View File

@@ -96,19 +96,32 @@ def add_security_headers(response):
# CONTEXT PROCESSORS # CONTEXT PROCESSORS
# ============================================ # ============================================
@app.context_processor @app.context_processor
def inject_offline_settings(): def inject_offline_settings():
"""Inject offline settings into all templates.""" """Inject offline settings into all templates."""
from utils.database import get_setting from utils.database import get_setting
return {
'offline_settings': { # Privacy-first defaults: keep dashboard assets/fonts local to avoid
'enabled': get_setting('offline.enabled', False), # third-party tracker/storage defenses in strict browsers.
'assets_source': get_setting('offline.assets_source', 'cdn'), assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
'fonts_source': get_setting('offline.fonts_source', 'cdn'), fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), if assets_source not in ('local', 'cdn'):
'tile_server_url': get_setting('offline.tile_server_url', '') 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_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dsc_lock = threading.Lock() dsc_lock = threading.Lock()
# DMR / Digital Voice # TSCM (Technical Surveillance Countermeasures)
dmr_process = None tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dmr_rtl_process = None tscm_lock = threading.Lock()
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()
# SubGHz Transceiver (HackRF) # SubGHz Transceiver (HackRF)
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -652,21 +659,11 @@ def export_bluetooth() -> Response:
}) })
def _get_subghz_active() -> bool: def _get_subghz_active() -> bool:
"""Check if SubGHz manager has an active process.""" """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."""
try: try:
from routes import dmr as dmr_module from utils.subghz import get_subghz_manager
proc = dmr_module.dmr_dsd_process return get_subghz_manager().active_mode != 'idle'
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
except Exception: except Exception:
return False return False
@@ -746,7 +743,6 @@ def health_check() -> Response:
'wifi': wifi_active, 'wifi': wifi_active,
'bluetooth': bt_active, 'bluetooth': bt_active,
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'dmr': _get_dmr_active(),
'subghz': _get_subghz_active(), 'subghz': _get_subghz_active(),
}, },
'data': { 'data': {
@@ -761,12 +757,11 @@ def health_check() -> Response:
@app.route('/killall', methods=['POST']) @app.route('/killall', methods=['POST'])
def kill_all() -> Response: def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes.""" """Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global vdl2_process global vdl2_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
global dmr_process, dmr_rtl_process
# Import adsb and ais modules to reset their state # Import adsb and ais modules to reset their state
from routes import adsb as adsb_module from routes import adsb as adsb_module
@@ -778,7 +773,7 @@ def kill_all() -> Response:
'rtl_fm', 'multimon-ng', 'rtl_433', 'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng', 'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher', 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump', 'dsd', 'hcitool', 'bluetoothctl', 'satdump',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep' 'hackrf_transfer', 'hackrf_sweep'
] ]
@@ -828,12 +823,7 @@ def kill_all() -> Response:
dsc_process = None dsc_process = None
dsc_rtl_process = None dsc_rtl_process = None
# Reset DMR state # Reset Bluetooth state (legacy)
with dmr_lock:
dmr_process = None
dmr_rtl_process = None
# Reset Bluetooth state (legacy)
with bt_lock: with bt_lock:
if bt_process: if bt_process:
try: try:
@@ -853,16 +843,16 @@ def kill_all() -> Response:
except Exception: except Exception:
pass pass
# Reset SubGHz state # Reset SubGHz state
try: try:
from utils.subghz import get_subghz_manager from utils.subghz import get_subghz_manager
get_subghz_manager().stop_all() get_subghz_manager().stop_all()
except Exception: except Exception:
pass pass
# Clear SDR device registry # Clear SDR device registry
with sdr_device_registry_lock: with sdr_device_registry_lock:
sdr_device_registry.clear() sdr_device_registry.clear()
return jsonify({'status': 'killed', 'processes': killed}) return jsonify({'status': 'killed', 'processes': killed})

View File

@@ -99,17 +99,14 @@ CHANGELOG = [
"Pure Python SSTV decoder replacing broken slowrx dependency", "Pure Python SSTV decoder replacing broken slowrx dependency",
"Real-time signal scope for pager, sensor, and SSTV modes", "Real-time signal scope for pager, sensor, and SSTV modes",
"USB-level device probe to prevent cryptic rtl_fm crashes", "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", "version": "2.14.0",
"date": "February 2026", "date": "February 2026",
"highlights": [ "highlights": [
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme", "HF SSTV general mode with predefined shortwave frequencies",
"DMR visual synthesizer with event-driven spring-physics bars",
"HF SSTV general mode with predefined shortwave frequencies",
"WebSDR integration for remote HF/shortwave listening", "WebSDR integration for remote HF/shortwave listening",
"Listening Post signal scanner and audio pipeline improvements", "Listening Post signal scanner and audio pipeline improvements",
"TSCM sweep resilience, WiFi detection, and correlation fixes", "TSCM sweep resilience, WiFi detection, and correlation fixes",

View File

@@ -214,9 +214,7 @@ Extended base for full-screen dashboards (maps, visualizations).
| `bt_locate` | BT Locate | | `bt_locate` | BT Locate |
| `analytics` | Analytics dashboard | | `analytics` | Analytics dashboard |
| `spaceweather` | Space weather | | `spaceweather` | Space weather |
| `dmr` | DMR/P25 digital voice | ### Navigation Groups
### Navigation Groups
The navigation is organized into groups: The navigation is organized into groups:
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz - **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz

View File

@@ -29,14 +29,13 @@ def register_blueprints(app):
from .sstv import sstv_bp from .sstv import sstv_bp
from .weather_sat import weather_sat_bp from .weather_sat import weather_sat_bp
from .sstv_general import sstv_general_bp from .sstv_general import sstv_general_bp
from .dmr import dmr_bp
from .websdr import websdr_bp from .websdr import websdr_bp
from .alerts import alerts_bp from .alerts import alerts_bp
from .recordings import recordings_bp from .recordings import recordings_bp
from .subghz import subghz_bp from .subghz import subghz_bp
from .bt_locate import bt_locate_bp from .bt_locate import bt_locate_bp
from .analytics import analytics_bp from .analytics import analytics_bp
from .space_weather import space_weather_bp from .space_weather import space_weather_bp
app.register_blueprint(pager_bp) app.register_blueprint(pager_bp)
app.register_blueprint(sensor_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(sstv_bp) # ISS SSTV decoder
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV 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(websdr_bp) # HF/Shortwave WebSDR
app.register_blueprint(alerts_bp) # Cross-mode alerts app.register_blueprint(alerts_bp) # Cross-mode alerts
app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF) app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
app.register_blueprint(space_weather_bp) # Space weather monitoring app.register_blueprint(space_weather_bp) # Space weather monitoring
# Initialize TSCM state with queue and lock from app # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'): if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock) init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)

View File

@@ -109,14 +109,27 @@ def start_session():
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})" f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
) )
session = start_locate_session( try:
target, environment, custom_exponent, fallback_lat, fallback_lon session = start_locate_session(
) target, environment, custom_exponent, fallback_lat, fallback_lon
)
return jsonify({ except RuntimeError as exc:
'status': 'started', logger.warning(f"Unable to start BT Locate session: {exc}")
'session': session.get_status(), 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']) @bt_locate_bp.route('/stop', methods=['POST'])
@@ -130,17 +143,18 @@ def stop_session():
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@bt_locate_bp.route('/status', methods=['GET']) @bt_locate_bp.route('/status', methods=['GET'])
def get_status(): def get_status():
"""Get locate session status.""" """Get locate session status."""
session = get_locate_session() session = get_locate_session()
if not session: if not session:
return jsonify({ return jsonify({
'active': False, 'active': False,
'target': None, 'target': None,
}) })
return jsonify(session.get_status()) 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']) @bt_locate_bp.route('/trail', methods=['GET'])

View File

@@ -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

View File

@@ -9,13 +9,14 @@ import os
offline_bp = Blueprint('offline', __name__, url_prefix='/offline') offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
# Default offline settings # Default offline settings
OFFLINE_DEFAULTS = { OFFLINE_DEFAULTS = {
'offline.enabled': False, 'offline.enabled': False,
'offline.assets_source': 'cdn', # Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
'offline.fonts_source': 'cdn', 'offline.assets_source': 'local',
'offline.tile_provider': 'cartodb_dark_cyan', 'offline.fonts_source': 'local',
'offline.tile_server_url': '' 'offline.tile_provider': 'cartodb_dark_cyan',
} 'offline.tile_server_url': ''
}
# Asset paths to check # Asset paths to check
ASSET_PATHS = { ASSET_PATHS = {

View File

@@ -584,40 +584,67 @@ def list_tracked_satellites():
return jsonify({'status': 'success', 'satellites': sats}) return jsonify({'status': 'success', 'satellites': sats})
@satellite_bp.route('/tracked', methods=['POST']) @satellite_bp.route('/tracked', methods=['POST'])
def add_tracked_satellites_endpoint(): def add_tracked_satellites_endpoint():
"""Add one or more tracked satellites.""" """Add one or more tracked satellites."""
global _tle_cache global _tle_cache
data = request.json data = request.get_json(silent=True)
if not data: if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400 return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# Accept a single satellite dict or a list # Accept a single satellite dict or a list
sat_list = data if isinstance(data, list) else [data] sat_list = data if isinstance(data, list) else [data]
added = 0 normalized: list[dict] = []
for sat in sat_list: for sat in sat_list:
norad_id = str(sat.get('norad_id', sat.get('norad', ''))) norad_id = str(sat.get('norad_id', sat.get('norad', '')))
name = sat.get('name', '') name = sat.get('name', '')
if not norad_id or not name: if not norad_id or not name:
continue continue
tle1 = sat.get('tle_line1', sat.get('tle1')) tle1 = sat.get('tle_line1', sat.get('tle1'))
tle2 = sat.get('tle_line2', sat.get('tle2')) tle2 = sat.get('tle_line2', sat.get('tle2'))
enabled = sat.get('enabled', True) enabled = sat.get('enabled', True)
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled): normalized.append({
added += 1 'norad_id': norad_id,
'name': name,
# Also inject into TLE cache if we have TLE data 'tle_line1': tle1,
if tle1 and tle2: 'tle_line2': tle2,
cache_key = name.replace(' ', '-').upper() 'enabled': bool(enabled),
_tle_cache[cache_key] = (name, tle1, tle2) 'builtin': False,
})
return jsonify({
'status': 'success', # Also inject into TLE cache if we have TLE data
'added': added, if tle1 and tle2:
'satellites': get_tracked_satellites(), 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/<norad_id>', methods=['PUT']) @satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])

View File

@@ -10,9 +10,10 @@ import queue
from flask import Blueprint, jsonify, request, Response, send_file from flask import Blueprint, jsonify, request, Response, send_file
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import sse_stream from utils.sse import sse_stream
from utils.subghz import get_subghz_manager from utils.subghz import get_subghz_manager
from utils.event_pipeline import process_event
from utils.constants import ( from utils.constants import (
SUBGHZ_FREQ_MIN_MHZ, SUBGHZ_FREQ_MIN_MHZ,
SUBGHZ_FREQ_MAX_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) _subghz_queue: queue.Queue = queue.Queue(maxsize=200)
def _event_callback(event: dict) -> None: def _event_callback(event: dict) -> None:
"""Forward SubGhzManager events to the SSE queue.""" """Forward SubGhzManager events to the SSE queue."""
try: try:
_subghz_queue.put_nowait(event) process_event('subghz', event, event.get('type'))
except Exception:
pass
try:
_subghz_queue.put_nowait(event)
except queue.Full: except queue.Full:
try: try:
_subghz_queue.get_nowait() _subghz_queue.get_nowait()

123
setup.sh
View File

@@ -233,10 +233,6 @@ check_tools() {
info "GPS:" info "GPS:"
check_required "gpsd" "GPS daemon" gpsd check_required "gpsd" "GPS daemon" gpsd
echo
info "Digital Voice:"
check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme
echo echo
info "Audio:" info "Audio:"
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg 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() { install_dump1090_from_source_macos() {
info "dump1090 not available via Homebrew. Building from source..." info "dump1090 not available via Homebrew. Building from source..."
@@ -918,7 +825,7 @@ install_macos_packages() {
sudo -v || { fail "sudo authentication failed"; exit 1; } sudo -v || { fail "sudo authentication failed"; exit 1; }
fi fi
TOTAL_STEPS=22 TOTAL_STEPS=21
CURRENT_STEP=0 CURRENT_STEP=0
progress "Checking Homebrew" progress "Checking Homebrew"
@@ -941,19 +848,6 @@ install_macos_packages() {
progress "SSTV decoder" progress "SSTV decoder"
ok "SSTV uses built-in pure Python decoder (no external tools needed)" 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" progress "Installing ffmpeg"
brew_install ffmpeg brew_install ffmpeg
@@ -1409,7 +1303,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a export NEEDRESTART_MODE=a
fi fi
TOTAL_STEPS=28 TOTAL_STEPS=27
CURRENT_STEP=0 CURRENT_STEP=0
progress "Updating APT package lists" progress "Updating APT package lists"
@@ -1474,19 +1368,6 @@ install_debian_packages() {
progress "SSTV decoder" progress "SSTV decoder"
ok "SSTV uses built-in pure Python decoder (no external tools needed)" 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" progress "Installing ffmpeg"
apt_install ffmpeg apt_install ffmpeg

View File

@@ -13,13 +13,11 @@
} }
.radar-device { .radar-device {
transition: transform 0.2s ease;
transform-origin: center center;
cursor: pointer; cursor: pointer;
} }
.radar-device:hover { .radar-device:hover .radar-dot {
transform: scale(1.2); filter: brightness(1.5);
} }
/* Invisible larger hit area to prevent hover flicker */ /* Invisible larger hit area to prevent hover flicker */

View File

@@ -2172,6 +2172,10 @@ header h1 .tagline {
} }
.control-btn { .control-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px; padding: 6px 12px;
background: transparent; background: transparent;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -2182,6 +2186,14 @@ header h1 .tagline {
letter-spacing: 1px; letter-spacing: 1px;
transition: all 0.2s ease; transition: all 0.2s ease;
font-family: var(--font-sans); 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 { .control-btn:hover {

View File

@@ -33,10 +33,7 @@ const ProximityRadar = (function() {
let activeFilter = null; let activeFilter = null;
let onDeviceClick = null; let onDeviceClick = null;
let selectedDeviceKey = null; let selectedDeviceKey = null;
let isHovered = false;
let renderPending = false;
let renderTimer = null; let renderTimer = null;
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
/** /**
* Initialize the radar component * Initialize the radar component
@@ -128,28 +125,10 @@ const ProximityRadar = (function() {
if (!deviceEl) return; if (!deviceEl) return;
const deviceKey = deviceEl.getAttribute('data-device-key'); const deviceKey = deviceEl.getAttribute('data-device-key');
if (onDeviceClick && deviceKey) { if (onDeviceClick && deviceKey) {
// Lock out re-renders briefly so the DOM stays stable after click
interactionLockUntil = Date.now() + 500;
onDeviceClick(deviceKey); 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 // Add sweep animation
animateSweep(); animateSweep();
} }
@@ -191,17 +170,10 @@ const ProximityRadar = (function() {
function updateDevices(deviceList) { function updateDevices(deviceList) {
if (isPaused) return; if (isPaused) return;
// Update device map
deviceList.forEach(device => { deviceList.forEach(device => {
devices.set(device.device_key, 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) // Debounce rapid updates (e.g. per-device SSE events)
if (renderTimer) clearTimeout(renderTimer); if (renderTimer) clearTimeout(renderTimer);
renderTimer = setTimeout(() => { 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() { function renderDevices() {
const devicesGroup = svg.querySelector('.radar-devices'); const devicesGroup = svg.querySelector('.radar-devices');
@@ -219,6 +193,7 @@ const ProximityRadar = (function() {
const center = CONFIG.size / 2; const center = CONFIG.size / 2;
const maxRadius = center - CONFIG.padding; const maxRadius = center - CONFIG.padding;
const ns = 'http://www.w3.org/2000/svg';
// Filter devices // Filter devices
let visibleDevices = Array.from(devices.values()); let visibleDevices = Array.from(devices.values());
@@ -234,69 +209,195 @@ const ProximityRadar = (function() {
visibleDevices = visibleDevices.filter(d => !d.in_baseline); visibleDevices = visibleDevices.filter(d => !d.in_baseline);
} }
// Build SVG for each device const visibleKeys = new Set(visibleDevices.map(d => d.device_key));
const dots = visibleDevices.map(device => {
// Calculate position
const { x, y, radius } = calculateDevicePosition(device, center, maxRadius);
// 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 confidence = device.distance_confidence || 0.5;
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence; const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
// Get color based on proximity band
const color = getBandColor(device.proximity_band); const color = getBandColor(device.proximity_band);
// Check if newly seen (pulse animation)
const isNew = device.age_seconds < 5; 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 existing = devicesGroup.querySelector(
const hitAreaSize = Math.max(dotSize * 2, 15); `.radar-device-wrapper[data-device-key="${CSS.escape(key)}"]`
);
return ` if (existing) {
<g transform="translate(${x}, ${y})"> // ── In-place update: mutate attributes, never recreate ──
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}" existing.setAttribute('transform', `translate(${x}, ${y})`);
style="cursor: pointer;">
<!-- Invisible hit area to prevent hover flicker -->
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
</circle>` : ''}
<circle r="${dotSize}" fill="${color}"
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
</g>
</g>
`;
}).join('');
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 * Calculate device position on radar
*/ */
function calculateDevicePosition(device, center, maxRadius) { 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; let radiusRatio;
const band = device.proximity_band || 'unknown'; switch (device.proximity_band || 'unknown') {
case 'immediate': radiusRatio = 0.15; break;
if (device.estimated_distance_m != null) { case 'near': radiusRatio = 0.40; break;
// Use actual distance (log scale) case 'far': radiusRatio = 0.70; break;
const maxDistance = 15; default: radiusRatio = 0.90; break;
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;
}
} }
// Calculate angle based on device key hash (stable positioning) // Calculate angle based on device key hash (stable positioning)
@@ -306,7 +407,53 @@ const ProximityRadar = (function() {
const x = center + Math.sin(angle) * radius; const x = center + Math.sin(angle) * radius;
const y = center - Math.cos(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;
});
});
} }
/** /**

View File

@@ -289,23 +289,10 @@ const SignalGuess = (function() {
regions: ['GLOBAL'] regions: ['GLOBAL']
}, },
// LoRaWAN // Key Fob
{ {
label: 'LoRaWAN / LoRa Device', label: 'Remote Control / Key Fob',
tags: ['iot', 'lora', 'lpwan', 'telemetry'], tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'],
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'],
description: 'Wireless remote control or vehicle key fob', description: 'Wireless remote control or vehicle key fob',
frequencyRanges: [[314900000, 315100000], [433050000, 434790000], [867000000, 869000000]], frequencyRanges: [[314900000, 315100000], [433050000, 434790000], [867000000, 869000000]],
modulationHints: ['OOK', 'ASK', 'FSK', 'rolling'], modulationHints: ['OOK', 'ASK', 'FSK', 'rolling'],

View File

@@ -24,7 +24,6 @@ const CommandPalette = (function() {
{ mode: 'sstv_general', label: 'HF SSTV' }, { mode: 'sstv_general', label: 'HF SSTV' },
{ mode: 'gps', label: 'GPS' }, { mode: 'gps', label: 'GPS' },
{ mode: 'meshtastic', label: 'Meshtastic' }, { mode: 'meshtastic', label: 'Meshtastic' },
{ mode: 'dmr', label: 'Digital Voice' },
{ mode: 'websdr', label: 'WebSDR' }, { mode: 'websdr', label: 'WebSDR' },
{ mode: 'analytics', label: 'Analytics' }, { mode: 'analytics', label: 'Analytics' },
{ mode: 'spaceweather', label: 'Space Weather' }, { mode: 'spaceweather', label: 'Space Weather' },

View File

@@ -2,7 +2,7 @@ const RunState = (function() {
'use strict'; 'use strict';
const REFRESH_MS = 5000; 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 = { const MODE_ALIASES = {
bt: 'bluetooth', bt: 'bluetooth',
bt_locate: 'bluetooth', bt_locate: 'bluetooth',
@@ -21,7 +21,6 @@ const RunState = (function() {
vdl2: 'VDL2', vdl2: 'VDL2',
aprs: 'APRS', aprs: 'APRS',
dsc: 'DSC', dsc: 'DSC',
dmr: 'DMR',
subghz: 'SubGHz', subghz: 'SubGHz',
}; };
@@ -181,7 +180,6 @@ const RunState = (function() {
if (normalized.includes('aprs')) return 'aprs'; if (normalized.includes('aprs')) return 'aprs';
if (normalized.includes('dsc')) return 'dsc'; if (normalized.includes('dsc')) return 'dsc';
if (normalized.includes('subghz')) return 'subghz'; if (normalized.includes('subghz')) return 'subghz';
if (normalized.includes('dmr')) return 'dmr';
if (normalized.includes('433')) return 'sensor'; if (normalized.includes('433')) return 'sensor';
return 'pager'; return 'pager';
} }

View File

@@ -6,8 +6,8 @@ const Settings = {
// Default settings // Default settings
defaults: { defaults: {
'offline.enabled': false, 'offline.enabled': false,
'offline.assets_source': 'cdn', 'offline.assets_source': 'local',
'offline.fonts_source': 'cdn', 'offline.fonts_source': 'local',
'offline.tile_provider': 'cartodb_dark_cyan', 'offline.tile_provider': 'cartodb_dark_cyan',
'offline.tile_server_url': '' 'offline.tile_server_url': ''
}, },

View File

@@ -31,13 +31,19 @@ const BtLocate = (function() {
let movementHeadMarker = null; let movementHeadMarker = null;
let strongestMarker = null; let strongestMarker = null;
let confidenceCircle = null; let confidenceCircle = null;
let heatmapEnabled = true; let heatmapEnabled = false;
let movementEnabled = true; let movementEnabled = true;
let autoFollowEnabled = true; let autoFollowEnabled = true;
let smoothingEnabled = true; let smoothingEnabled = true;
let lastRenderedDetectionKey = null; let lastRenderedDetectionKey = null;
let pendingHeatSync = false; let pendingHeatSync = false;
let mapStabilizeTimer = null; 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_HEAT_POINTS = 1200;
const MAX_TRAIL_POINTS = 1200; const MAX_TRAIL_POINTS = 1200;
@@ -45,8 +51,9 @@ const BtLocate = (function() {
const OUTLIER_HARD_JUMP_METERS = 2000; const OUTLIER_HARD_JUMP_METERS = 2000;
const OUTLIER_SOFT_JUMP_METERS = 450; const OUTLIER_SOFT_JUMP_METERS = 450;
const OUTLIER_MAX_SPEED_MPS = 50; const OUTLIER_MAX_SPEED_MPS = 50;
const MAP_STABILIZE_INTERVAL_MS = 150; const MAP_STABILIZE_INTERVAL_MS = 220;
const MAP_STABILIZE_ATTEMPTS = 28; const MAP_STABILIZE_ATTEMPTS = 8;
const MIN_DETECTION_RENDER_MS = 220;
const OVERLAY_STORAGE_KEYS = { const OVERLAY_STORAGE_KEYS = {
heatmap: 'btLocateHeatmapEnabled', heatmap: 'btLocateHeatmapEnabled',
movement: 'btLocateMovementEnabled', movement: 'btLocateMovementEnabled',
@@ -66,6 +73,20 @@ const BtLocate = (function() {
1.0: '#ef4444', 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() { function getMapContainer() {
if (!map || typeof map.getContainer !== 'function') return null; if (!map || typeof map.getContainer !== 'function') return null;
@@ -84,7 +105,71 @@ const BtLocate = (function() {
return true; 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() { function init() {
modeActive = true;
loadOverlayPreferences(); loadOverlayPreferences();
syncOverlayControls(); syncOverlayControls();
@@ -158,10 +243,10 @@ const BtLocate = (function() {
initialized = true; initialized = true;
} }
function checkStatus() { function checkStatus() {
fetch('/bt_locate/status') fetch(statusUrl())
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.active) { if (data.active) {
sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now(); sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now();
showActiveUI(); showActiveUI();
@@ -171,12 +256,25 @@ const BtLocate = (function() {
} }
}) })
.catch(() => {}); .catch(() => {});
} }
function start() { function normalizeMacInput(value) {
const mac = document.getElementById('btLocateMac')?.value.trim(); const raw = (value || '').trim().toUpperCase().replace(/-/g, ':');
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim(); if (!raw) return '';
const irk = document.getElementById('btLocateIrk')?.value.trim(); 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 }; const body = { environment: currentEnvironment };
if (mac) body.mac_address = mac; 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_name) body.known_name = handoffData.known_name;
if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer; if (handoffData?.known_manufacturer) body.known_manufacturer = handoffData.known_manufacturer;
if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi; if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi;
// Include user location as fallback when GPS unavailable // Include user location as fallback when GPS unavailable
const userLat = localStorage.getItem('observerLat'); const fallbackLocation = resolveFallbackLocation();
const userLon = localStorage.getItem('observerLon'); if (fallbackLocation) {
if (userLat !== null && userLon !== null) { body.fallback_lat = fallbackLocation.lat;
body.fallback_lat = parseFloat(userLat); body.fallback_lon = fallbackLocation.lon;
body.fallback_lon = parseFloat(userLon);
} }
console.log('[BtLocate] Starting with body:', body); debugLog('[BtLocate] Starting with body:', body);
if (!body.mac_address && !body.name_pattern && !body.irk_hex && if (!body.mac_address && !body.name_pattern && !body.irk_hex &&
!body.device_id && !body.device_key && !body.fingerprint_id) { !body.device_id && !body.device_key && !body.fingerprint_id) {
alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.'); alert('Please provide at least one target identifier or use hand-off from Bluetooth mode.');
return; return;
} }
fetch('/bt_locate/start', { startRequestInFlight = true;
method: 'POST', setStartButtonBusy(true);
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), fetch('/bt_locate/start', {
}) method: 'POST',
.then(r => r.json()) headers: { 'Content-Type': 'application/json' },
.then(data => { 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') { if (data.status === 'started') {
sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now(); sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
showActiveUI(); showActiveUI();
@@ -222,36 +334,60 @@ const BtLocate = (function() {
updateScanStatus(data.session); updateScanStatus(data.session);
// Restore any existing trail (e.g. from a stop/start cycle) // Restore any existing trail (e.g. from a stop/start cycle)
restoreTrail(); 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() { function showActiveUI() {
fetch('/bt_locate/stop', { method: 'POST' }) setStartButtonBusy(false);
.then(r => r.json()) const startBtn = document.getElementById('btLocateStartBtn');
.then(() => { const stopBtn = document.getElementById('btLocateStopBtn');
showIdleUI(); if (startBtn) startBtn.style.display = 'none';
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';
if (stopBtn) stopBtn.style.display = 'inline-block'; if (stopBtn) stopBtn.style.display = 'inline-block';
show('btLocateHud'); show('btLocateHud');
} }
function showIdleUI() { function showIdleUI() {
const startBtn = document.getElementById('btLocateStartBtn'); startRequestInFlight = false;
const stopBtn = document.getElementById('btLocateStopBtn'); setStartButtonBusy(false);
if (startBtn) startBtn.style.display = 'inline-block'; if (queuedDetectionTimer) {
if (stopBtn) stopBtn.style.display = 'none'; clearTimeout(queuedDetectionTimer);
hide('btLocateHud'); 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'); hide('btLocateScanStatus');
} }
@@ -276,13 +412,13 @@ const BtLocate = (function() {
function connectSSE() { function connectSSE() {
if (eventSource) eventSource.close(); if (eventSource) eventSource.close();
console.log('[BtLocate] Connecting SSE stream'); debugLog('[BtLocate] Connecting SSE stream');
eventSource = new EventSource('/bt_locate/stream'); eventSource = new EventSource('/bt_locate/stream');
eventSource.addEventListener('detection', function(e) { eventSource.addEventListener('detection', function(e) {
try { try {
const event = JSON.parse(e.data); const event = JSON.parse(e.data);
console.log('[BtLocate] Detection event:', event); debugLog('[BtLocate] Detection event:', event);
handleDetection(event); handleDetection(event);
} catch (err) { } catch (err) {
console.error('[BtLocate] Parse error:', err); console.error('[BtLocate] Parse error:', err);
@@ -295,15 +431,16 @@ const BtLocate = (function() {
}); });
eventSource.onerror = 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) { if (eventSource && eventSource.readyState === EventSource.CLOSED) {
eventSource = null; eventSource = null;
} }
}; };
// Start polling fallback (catches data even if SSE fails) // Start polling fallback (catches data even if SSE fails)
startPolling(); startPolling();
} pollStatus();
}
function disconnectSSE() { function disconnectSSE() {
if (eventSource) { if (eventSource) {
@@ -349,10 +486,10 @@ const BtLocate = (function() {
if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0'); if (timeEl) timeEl.textContent = mins + ':' + String(secs).padStart(2, '0');
} }
function pollStatus() { function pollStatus() {
fetch('/bt_locate/status') fetch(statusUrl())
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (!data.active) { if (!data.active) {
showIdleUI(); showIdleUI();
disconnectSSE(); 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 = {}) { 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; const d = event?.data || event;
if (!d) return; if (!d) return;
const detectionKey = buildDetectionKey(d); const detectionKey = buildDetectionKey(d);
@@ -473,7 +645,7 @@ const BtLocate = (function() {
try { try {
mapPointAdded = addMapMarker(d, { suppressFollow: options.suppressFollow === true }); mapPointAdded = addMapMarker(d, { suppressFollow: options.suppressFollow === true });
} catch (error) { } catch (error) {
console.warn('[BtLocate] Map update skipped:', error); debugLog('[BtLocate] Map update skipped:', error);
mapPointAdded = false; mapPointAdded = false;
} }
} }
@@ -878,7 +1050,7 @@ const BtLocate = (function() {
} }
function ensureHeatLayer() { 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) { if (!heatLayer) {
heatLayer = L.heatLayer([], HEAT_LAYER_OPTIONS); heatLayer = L.heatLayer([], HEAT_LAYER_OPTIONS);
} }
@@ -886,9 +1058,19 @@ const BtLocate = (function() {
function syncHeatLayer() { function syncHeatLayer() {
if (!map) return; if (!map) return;
if (!heatmapEnabled) {
if (heatLayer && map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
pendingHeatSync = false;
return;
}
ensureHeatLayer(); ensureHeatLayer();
if (!heatLayer) return; if (!heatLayer) return;
if (!isMapContainerVisible()) { if (!modeActive || !isMapContainerVisible()) {
if (map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
pendingHeatSync = true; pendingHeatSync = true;
return; return;
} }
@@ -899,6 +1081,13 @@ const BtLocate = (function() {
return; return;
} }
} }
if (!Array.isArray(heatPoints) || heatPoints.length === 0) {
if (map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
pendingHeatSync = false;
return;
}
try { try {
heatLayer.setLatLngs(heatPoints); heatLayer.setLatLngs(heatPoints);
if (heatmapEnabled) { if (heatmapEnabled) {
@@ -914,10 +1103,52 @@ const BtLocate = (function() {
if (map.hasLayer(heatLayer)) { if (map.hasLayer(heatLayer)) {
map.removeLayer(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() { function isMapRenderable() {
if (!map || !isMapContainerVisible()) return false; if (!map || !isMapContainerVisible()) return false;
if (typeof map.getSize === 'function') { if (typeof map.getSize === 'function') {
@@ -1370,7 +1601,7 @@ const BtLocate = (function() {
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {
showNotification(title, message); showNotification(title, message);
} else { } 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 // Resume must happen within a user gesture handler
const ctx = audioCtx; const ctx = audioCtx;
ctx.resume().then(() => { ctx.resume().then(() => {
console.log('[BtLocate] AudioContext state:', ctx.state); debugLog('[BtLocate] AudioContext state:', ctx.state);
// Confirmation beep so user knows audio is working // Confirmation beep so user knows audio is working
playTone(600, 0.08); playTone(600, 0.08);
}); });
@@ -1482,14 +1713,14 @@ const BtLocate = (function() {
btn.classList.toggle('active', btn.dataset.env === env); btn.classList.toggle('active', btn.dataset.env === env);
}); });
// Push to running session if active // Push to running session if active
fetch('/bt_locate/status').then(r => r.json()).then(data => { fetch(statusUrl()).then(r => r.json()).then(data => {
if (data.active) { if (data.active) {
fetch('/bt_locate/environment', { fetch('/bt_locate/environment', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment: env }), body: JSON.stringify({ environment: env }),
}).then(r => r.json()).then(res => { }).then(r => r.json()).then(res => {
console.log('[BtLocate] Environment updated:', res); debugLog('[BtLocate] Environment updated:', res);
}); });
} }
}).catch(() => {}); }).catch(() => {});
@@ -1506,7 +1737,7 @@ const BtLocate = (function() {
} }
function handoff(deviceInfo) { function handoff(deviceInfo) {
console.log('[BtLocate] Handoff received:', deviceInfo); debugLog('[BtLocate] Handoff received:', deviceInfo);
handoffData = deviceInfo; handoffData = deviceInfo;
// Populate fields // Populate fields
@@ -1633,10 +1864,11 @@ const BtLocate = (function() {
scheduleMapStabilization(8); scheduleMapStabilization(8);
} }
return { return {
init, init,
start, setActiveMode,
stop, start,
stop,
handoff, handoff,
clearHandoff, clearHandoff,
setEnvironment, setEnvironment,
@@ -1651,4 +1883,6 @@ const BtLocate = (function() {
invalidateMap, invalidateMap,
fetchPairedIrks, fetchPairedIrks,
}; };
})(); })();
window.BtLocate = BtLocate;

View File

@@ -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 ? `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Slot</span>
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
</div>` : '';
const callEl = document.getElementById('dmrCurrentCall');
if (callEl) {
callEl.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Talkgroup</span>
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: var(--text-muted);">Source ID</span>
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
</div>${slotInfo}
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-muted);">Time</span>
<span style="color: var(--text-primary);">${msg.timestamp}</span>
</div>
`;
}
// 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 = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
return;
}
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
<tr>
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
<td style="padding: 3px 6px;">${call.protocol}</td>
</tr>
`).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;

View File

@@ -612,8 +612,6 @@
{% include 'partials/modes/meshtastic.html' %} {% include 'partials/modes/meshtastic.html' %}
{% include 'partials/modes/dmr.html' %}
{% include 'partials/modes/websdr.html' %} {% include 'partials/modes/websdr.html' %}
{% include 'partials/modes/subghz.html' %} {% include 'partials/modes/subghz.html' %}
@@ -1883,76 +1881,6 @@
</div> </div>
</div> </div>
<!-- DMR / Digital Voice Dashboard -->
<div id="dmrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0;">
<!-- DMR Synthesizer -->
<div class="radio-module-box" style="padding: 10px;">
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
<span>SIGNAL ACTIVITY</span>
<span id="dmrSynthStatus" style="color: var(--text-muted); font-size: 9px; font-family: var(--font-mono);">IDLE</span>
</div>
<canvas id="dmrSynthCanvas" style="width: 100%; height: 70px; background: rgba(0,0,0,0.4); border-radius: 4px; display: block;"></canvas>
</div>
<!-- Audio Output -->
<div class="radio-module-box" style="padding: 8px 12px;">
<audio id="dmrAudioPlayer" style="display: none;"></audio>
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">AUDIO</span>
<span id="dmrAudioStatus" style="font-size: 9px; font-family: var(--font-mono); color: var(--text-muted);">OFF</span>
<div style="display: flex; align-items: center; gap: 4px; margin-left: auto;">
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
<input type="range" id="dmrAudioVolume" min="0" max="100" value="80" style="width: 80px;" oninput="setDmrAudioVolume(this.value)">
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<!-- Call History Panel -->
<div class="radio-module-box" style="padding: 10px;">
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
<span>CALL HISTORY</span>
<span id="dmrHistoryCount" style="color: var(--accent-cyan);">0 calls</span>
</div>
<div style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
<thead>
<tr style="color: var(--text-muted); border-bottom: 1px solid var(--border-color);">
<th style="text-align: left; padding: 4px;">Time</th>
<th style="text-align: left; padding: 4px;">Talkgroup</th>
<th style="text-align: left; padding: 4px;">Source</th>
<th style="text-align: left; padding: 4px;">Protocol</th>
</tr>
</thead>
<tbody id="dmrHistoryBody">
<tr><td colspan="4" style="padding: 15px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Protocol Info Panel -->
<div class="radio-module-box" style="padding: 10px;">
<div class="module-header" style="margin-bottom: 8px; font-size: 10px;">
<span>DECODER STATUS</span>
</div>
<div style="display: flex; flex-direction: column; gap: 12px; padding: 10px;">
<div style="text-align: center;">
<div style="font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 4px;">PROTOCOL</div>
<div id="dmrMainProtocol" style="font-size: 28px; font-weight: bold; color: var(--accent-cyan); font-family: var(--font-mono);">--</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div style="text-align: center; padding: 8px; background: rgba(0,0,0,0.3); border-radius: 4px;">
<div style="font-size: 9px; color: var(--text-muted);">CALLS</div>
<div id="dmrMainCallCount" style="font-size: 22px; font-weight: bold; color: var(--accent-green); font-family: var(--font-mono);">0</div>
</div>
<div style="text-align: center; padding: 8px; background: rgba(0,0,0,0.3); border-radius: 4px;">
<div style="font-size: 9px; color: var(--text-muted);">SYNCS</div>
<div id="dmrSyncCount" style="font-size: 22px; font-weight: bold; color: var(--accent-orange); font-family: var(--font-mono);">0</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- SubGHz Transceiver Dashboard --> <!-- SubGHz Transceiver Dashboard -->
<div id="subghzVisuals" class="subghz-visuals-container" style="display: none;"> <div id="subghzVisuals" class="subghz-visuals-container" style="display: none;">
@@ -3270,7 +3198,6 @@
<script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/gps.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/gps.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script> <script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script> <script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
@@ -4016,7 +3943,6 @@
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais'); document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations'); document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic'); document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr'); document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz'); document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics'); document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics');
@@ -4057,7 +3983,6 @@
const weatherSatVisuals = document.getElementById('weatherSatVisuals'); const weatherSatVisuals = document.getElementById('weatherSatVisuals');
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals'); const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
const gpsVisuals = document.getElementById('gpsVisuals'); const gpsVisuals = document.getElementById('gpsVisuals');
const dmrVisuals = document.getElementById('dmrVisuals');
const websdrVisuals = document.getElementById('websdrVisuals'); const websdrVisuals = document.getElementById('websdrVisuals');
const subghzVisuals = document.getElementById('subghzVisuals'); const subghzVisuals = document.getElementById('subghzVisuals');
const btLocateVisuals = document.getElementById('btLocateVisuals'); const btLocateVisuals = document.getElementById('btLocateVisuals');
@@ -4074,12 +3999,16 @@
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none'; if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none'; if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
if (gpsVisuals) gpsVisuals.style.display = mode === 'gps' ? 'flex' : 'none'; if (gpsVisuals) gpsVisuals.style.display = mode === 'gps' ? 'flex' : 'none';
if (dmrVisuals) dmrVisuals.style.display = mode === 'dmr' ? 'flex' : 'none';
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none'; if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none'; if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none'; if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none'; if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
BtLocate.setActiveMode(mode === 'bt_locate');
}
// Hide sidebar by default for Meshtastic mode, show for others // Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content'); const mainContent = document.querySelector('.main-content');
if (mainContent) { if (mainContent) {
@@ -4134,7 +4063,7 @@
const reconBtn = document.getElementById('reconBtn'); const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel'); const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') { if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') {
if (reconPanel) reconPanel.style.display = 'none'; if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none'; if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none'; if (intelBtn) intelBtn.style.display = 'none';
@@ -4154,7 +4083,7 @@
// Show RTL-SDR device section for modes that use it // Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection'); const rtlDeviceSection = document.getElementById('rtlDeviceSection');
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none'; if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general') ? 'block' : 'none';
// Show waterfall panel if running in listening mode // Show waterfall panel if running in listening mode
const waterfallPanel = document.getElementById('waterfallPanel'); const waterfallPanel = document.getElementById('waterfallPanel');
@@ -4172,8 +4101,8 @@
// Hide output console for modes with their own visualizations // Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output'); const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar'); const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') ? 'none' : 'block'; if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex'; if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it) // Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') { if (mode !== 'meshtastic') {
@@ -4232,10 +4161,6 @@
SSTVGeneral.init(); SSTVGeneral.init();
} else if (mode === 'gps') { } else if (mode === 'gps') {
GPS.init(); GPS.init();
} else if (mode === 'dmr') {
if (typeof checkDmrTools === 'function') checkDmrTools();
if (typeof checkDmrStatus === 'function') checkDmrStatus();
if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100);
} else if (mode === 'websdr') { } else if (mode === 'websdr') {
if (typeof initWebSDR === 'function') initWebSDR(); if (typeof initWebSDR === 'function') initWebSDR();
} else if (mode === 'subghz') { } else if (mode === 'subghz') {
@@ -10432,7 +10357,7 @@
return; return;
} }
const lines = tleText.split('\\n').map(l => l.trim()).filter(l => l); const lines = tleText.split(/\r?\n/).map(l => l.trim()).filter(l => l);
const toAdd = []; const toAdd = [];
for (let i = 0; i < lines.length; i += 3) { for (let i = 0; i < lines.length; i += 3) {
@@ -10483,7 +10408,7 @@
fetch('/satellite/celestrak/' + category) fetch('/satellite/celestrak/' + category)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(async data => {
if (data.status === 'success' && data.satellites) { if (data.status === 'success' && data.satellites) {
const toAdd = data.satellites const toAdd = data.satellites
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad))) .filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
@@ -10500,27 +10425,36 @@
return; return;
} }
fetch('/satellite/tracked', { const batchSize = 250;
method: 'POST', let addedTotal = 0;
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toAdd) for (let i = 0; i < toAdd.length; i += batchSize) {
}) const batch = toAdd.slice(i, i + batchSize);
.then(r => r.json()) const completed = Math.min(i + batch.length, toAdd.length);
.then(result => { status.innerHTML = `<span style="color: var(--accent-cyan);">Importing ${completed}/${toAdd.length} from ${category}...</span>`;
if (result.status === 'success') {
_loadSatellitesFromAPI(); const resp = await fetch('/satellite/tracked', {
status.innerHTML = `<span style="color: var(--accent-green);">Added ${result.added} satellites (${data.satellites.length} total in category)</span>`; method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch)
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok || result.status !== 'success') {
throw new Error(result.message || result.error || `HTTP ${resp.status}`);
} }
}) addedTotal += Number(result.added || 0);
.catch(() => { }
status.innerHTML = `<span style="color: var(--accent-red);">Failed to save satellites</span>`;
}); _loadSatellitesFromAPI();
status.innerHTML = `<span style="color: var(--accent-green);">Added ${addedTotal} satellites (${data.satellites.length} total in category)</span>`;
} else { } else {
status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`; status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`;
} }
}) })
.catch(() => { .catch((err) => {
status.innerHTML = `<span style="color: var(--accent-red);">Network error</span>`; const msg = err && err.message ? err.message : 'Network error';
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${msg}</span>`;
}); });
} }

View File

@@ -1,114 +0,0 @@
<!-- DMR / DIGITAL VOICE MODE -->
<div id="dmrMode" class="mode-content">
<div class="section">
<h3>Digital Voice</h3>
<div class="alpha-mode-notice">
ALPHA: Digital Voice decoding is still in active development. Expect occasional decode instability and false protocol locks.
</div>
<!-- Dependency Warning -->
<div id="dmrToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
<strong>Missing:</strong><br>
<span id="dmrToolsWarningText"></span>
</p>
</div>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="dmrFrequency" value="462.5625" step="0.0001" style="width: 100%;">
</div>
<div class="form-group">
<label>Protocol</label>
<select id="dmrProtocol">
<option value="auto" selected>Auto Detect (DMR/P25/D-STAR)</option>
<option value="dmr">DMR</option>
<option value="p25">P25</option>
<option value="nxdn">NXDN</option>
<option value="dstar">D-STAR</option>
<option value="provoice">ProVoice</option>
</select>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
For NXDN and ProVoice, use manual protocol selection for best lock reliability
</span>
</div>
<div class="form-group">
<label>Gain</label>
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="dmrPPM" value="0" min="-200" max="200" step="1" style="width: 100%;"
title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset.">
</div>
<div class="form-group" style="margin-top: 4px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);">
<span>Relax CRC (weak signals)</span>
</label>
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
Allows more frames through on marginal signals at the cost of occasional errors
</span>
</div>
</div>
<!-- Bookmarks -->
<div class="section" style="margin-top: 8px;">
<h3>Bookmarks</h3>
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
<input type="number" id="dmrBookmarkFreq" placeholder="Freq MHz" step="0.0001"
style="flex: 1; font-size: 11px; padding: 4px 6px;">
<button class="preset-btn" onclick="addDmrBookmark()" style="font-size: 10px; padding: 4px 8px;"
title="Add bookmark">+</button>
</div>
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
<input type="text" id="dmrBookmarkLabel" placeholder="Label (optional)"
style="flex: 1; font-size: 11px; padding: 4px 6px;">
<button class="preset-btn" onclick="addCurrentDmrFreqBookmark()" style="font-size: 9px; padding: 4px 6px;"
title="Save current frequency">Save current</button>
</div>
<div id="dmrBookmarksList" style="max-height: 150px; overflow-y: auto;">
<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>
</div>
</div>
<!-- Current Call -->
<div class="section" style="margin-top: 12px;">
<h3>Current Call</h3>
<div id="dmrCurrentCall" style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
<div style="color: var(--text-muted); text-align: center;">No active call</div>
</div>
</div>
<!-- Status -->
<div class="section" style="margin-top: 12px;">
<h3>Status</h3>
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Protocol</span>
<span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Calls</span>
<span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
</div>
</div>
</div>
<div class="mode-actions-bottom">
<button class="run-btn" id="startDmrBtn" onclick="startDmr()">
Start Decoder
</button>
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none;">
Stop Decoder
</button>
</div>
</div>

View File

@@ -128,13 +128,21 @@ class TestLocateTarget:
device.name = None device.name = None
assert target.matches(device) is True assert target.matches(device) is True
def test_match_by_mac_case_insensitive(self): def test_match_by_mac_case_insensitive(self):
target = LocateTarget(mac_address='aa:bb:cc:dd:ee:ff') target = LocateTarget(mac_address='aa:bb:cc:dd:ee:ff')
device = MagicMock() device = MagicMock()
device.device_id = 'other' device.device_id = 'other'
device.address = 'AA:BB:CC:DD:EE:FF' device.address = 'AA:BB:CC:DD:EE:FF'
device.name = None device.name = None
assert target.matches(device) is True assert target.matches(device) is True
def test_match_by_mac_without_separators(self):
target = LocateTarget(mac_address='aabbccddeeff')
device = MagicMock()
device.device_id = 'other'
device.address = 'AA:BB:CC:DD:EE:FF'
device.name = None
assert target.matches(device) is True
def test_match_by_name_pattern(self): def test_match_by_name_pattern(self):
target = LocateTarget(name_pattern='iPhone') target = LocateTarget(name_pattern='iPhone')
@@ -243,7 +251,7 @@ class TestLocateSession:
assert status['detection_count'] == 0 assert status['detection_count'] == 0
class TestModuleLevelSessionManagement: class TestModuleLevelSessionManagement:
"""Test module-level session functions.""" """Test module-level session functions."""
@patch('utils.bt_locate.get_bluetooth_scanner') @patch('utils.bt_locate.get_bluetooth_scanner')
@@ -261,9 +269,9 @@ class TestModuleLevelSessionManagement:
assert get_locate_session() is None assert get_locate_session() is None
@patch('utils.bt_locate.get_bluetooth_scanner') @patch('utils.bt_locate.get_bluetooth_scanner')
def test_start_replaces_existing_session(self, mock_get_scanner): def test_start_replaces_existing_session(self, mock_get_scanner):
mock_scanner = MagicMock() mock_scanner = MagicMock()
mock_get_scanner.return_value = mock_scanner mock_get_scanner.return_value = mock_scanner
target1 = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') target1 = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')
session1 = start_locate_session(target1) session1 = start_locate_session(target1)
@@ -273,6 +281,19 @@ class TestModuleLevelSessionManagement:
assert get_locate_session() is session2 assert get_locate_session() is session2
assert session1.active is False assert session1.active is False
assert session2.active is True assert session2.active is True
stop_locate_session() stop_locate_session()
@patch('utils.bt_locate.get_bluetooth_scanner')
def test_start_raises_when_scanner_cannot_start(self, mock_get_scanner):
mock_scanner = MagicMock()
mock_scanner.is_scanning = False
mock_scanner.start_scan.return_value = False
status = MagicMock()
status.error = 'No adapter'
mock_scanner.get_status.return_value = status
mock_get_scanner.return_value = mock_scanner
with pytest.raises(RuntimeError):
start_locate_session(LocateTarget(mac_address='AA:BB:CC:DD:EE:FF'))

View File

@@ -1,311 +0,0 @@
"""Tests for the DMR / Digital Voice decoding module."""
import queue
from unittest.mock import patch, MagicMock
import pytest
import routes.dmr as dmr_module
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
# ============================================
# parse_dsd_output() tests
# ============================================
def test_parse_sync_dmr():
"""Should parse DMR sync line."""
result = parse_dsd_output('Sync: +DMR (data)')
assert result is not None
assert result['type'] == 'sync'
assert 'DMR' in result['protocol']
def test_parse_sync_p25():
"""Should parse P25 sync line."""
result = parse_dsd_output('Sync: +P25 Phase 1')
assert result is not None
assert result['type'] == 'sync'
assert 'P25' in result['protocol']
def test_parse_talkgroup_and_source():
"""Should parse talkgroup and source ID."""
result = parse_dsd_output('TG: 12345 Src: 67890')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 12345
assert result['source_id'] == 67890
def test_parse_slot():
"""Should parse slot info."""
result = parse_dsd_output('Slot 1')
assert result is not None
assert result['type'] == 'slot'
assert result['slot'] == 1
def test_parse_voice():
"""Should parse voice frame info."""
result = parse_dsd_output('Voice Frame 1')
assert result is not None
assert result['type'] == 'voice'
def test_parse_nac():
"""Should parse P25 NAC."""
result = parse_dsd_output('NAC: 293')
assert result is not None
assert result['type'] == 'nac'
assert result['nac'] == '293'
def test_parse_talkgroup_dsd_fme_format():
"""Should parse dsd-fme comma-separated TG/Src format."""
result = parse_dsd_output('TG: 12345, Src: 67890')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 12345
assert result['source_id'] == 67890
def test_parse_talkgroup_dsd_fme_tgt_src_format():
"""Should parse dsd-fme TGT/SRC pipe-delimited format."""
result = parse_dsd_output('Slot 1 | TGT: 12345 | SRC: 67890')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 12345
assert result['source_id'] == 67890
assert result['slot'] == 1
def test_parse_talkgroup_with_slot():
"""TG line with slot info should capture both."""
result = parse_dsd_output('Slot 1 Voice LC, TG: 100, Src: 200')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 100
assert result['source_id'] == 200
assert result['slot'] == 1
def test_parse_voice_with_slot():
"""Voice frame with slot info should be voice, not slot."""
result = parse_dsd_output('Slot 2 Voice Frame')
assert result is not None
assert result['type'] == 'voice'
assert result['slot'] == 2
def test_parse_empty_line():
"""Empty lines should return None."""
assert parse_dsd_output('') is None
assert parse_dsd_output(' ') is None
def test_parse_unrecognized():
"""Unrecognized lines should return raw event for diagnostics."""
result = parse_dsd_output('some random text')
assert result is not None
assert result['type'] == 'raw'
assert result['text'] == 'some random text'
def test_parse_banner_filtered():
"""Pure box-drawing lines (banners) should be filtered."""
assert parse_dsd_output('╔══════════════╗') is None
assert parse_dsd_output('║ ║') is None
assert parse_dsd_output('╚══════════════╝') is None
assert parse_dsd_output('───────────────') is None
def test_parse_box_drawing_with_data_not_filtered():
"""Lines with box-drawing separators AND data should NOT be filtered."""
result = parse_dsd_output('DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890')
assert result is not None
assert result['type'] == 'call'
assert result['talkgroup'] == 12345
assert result['source_id'] == 67890
def test_dsd_fme_flags_differ_from_classic():
"""dsd-fme remapped several flags; tables must NOT be identical."""
assert _DSD_FME_PROTOCOL_FLAGS != _DSD_PROTOCOL_FLAGS
def test_dsd_fme_protocol_flags_known_values():
"""dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-fa'] # Broad auto
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!)
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-ft'] # P25 P1/P2 coverage
assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn']
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
def test_dsd_protocol_flags_known_values():
"""Classic DSD protocol flags should map to the correct -f flags."""
assert _DSD_PROTOCOL_FLAGS['dmr'] == ['-fd']
assert _DSD_PROTOCOL_FLAGS['p25'] == ['-fp']
assert _DSD_PROTOCOL_FLAGS['nxdn'] == ['-fn']
assert _DSD_PROTOCOL_FLAGS['dstar'] == ['-fi']
assert _DSD_PROTOCOL_FLAGS['provoice'] == ['-fv']
assert _DSD_PROTOCOL_FLAGS['auto'] == []
def test_dsd_fme_modulation_hints():
"""C4FM modulation hints should be set for C4FM protocols."""
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
# P25, D-Star and ProVoice should not have forced modulation
assert 'p25' not in _DSD_FME_MODULATION
assert 'dstar' not in _DSD_FME_MODULATION
assert 'provoice' not in _DSD_FME_MODULATION
# ============================================
# Endpoint tests
# ============================================
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
@pytest.fixture(autouse=True)
def reset_dmr_globals():
"""Reset DMR globals before/after each test to avoid cross-test bleed."""
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
yield
dmr_module.dmr_rtl_process = None
dmr_module.dmr_dsd_process = None
dmr_module.dmr_thread = None
dmr_module.dmr_running = False
dmr_module.dmr_has_audio = False
dmr_module.dmr_active_device = None
with dmr_module._ffmpeg_sinks_lock:
dmr_module._ffmpeg_sinks.clear()
try:
while True:
dmr_module.dmr_queue.get_nowait()
except queue.Empty:
pass
def test_dmr_tools(auth_client):
"""Tools endpoint should return availability info."""
resp = auth_client.get('/dmr/tools')
assert resp.status_code == 200
data = resp.get_json()
assert 'dsd' in data
assert 'rtl_fm' in data
assert 'protocols' in data
def test_dmr_status(auth_client):
"""Status endpoint should work."""
resp = auth_client.get('/dmr/status')
assert resp.status_code == 200
data = resp.get_json()
assert 'running' in data
def test_dmr_start_no_dsd(auth_client):
"""Start should fail gracefully when dsd is not installed."""
with patch('routes.dmr.find_dsd', return_value=(None, False)):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'auto',
})
assert resp.status_code == 503
data = resp.get_json()
assert 'dsd' in data['message']
def test_dmr_start_no_rtl_fm(auth_client):
"""Start should fail when rtl_fm is missing."""
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
patch('routes.dmr.find_rtl_fm', return_value=None):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
})
assert resp.status_code == 503
def test_dmr_start_invalid_protocol(auth_client):
"""Start should reject invalid protocol."""
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'invalid',
})
assert resp.status_code == 400
def test_dmr_stop(auth_client):
"""Stop should succeed."""
resp = auth_client.post('/dmr/stop')
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'stopped'
def test_dmr_stream_mimetype(auth_client):
"""Stream should return event-stream content type."""
resp = auth_client.get('/dmr/stream')
assert resp.content_type.startswith('text/event-stream')
def test_dmr_start_exception_cleans_up_resources(auth_client):
"""If startup fails after rtl_fm launch, process/device state should be reset."""
rtl_proc = MagicMock()
rtl_proc.poll.return_value = None
rtl_proc.wait.return_value = 0
rtl_proc.stdout = MagicMock()
rtl_proc.stderr = MagicMock()
builder = MagicMock()
builder.build_fm_demod_command.return_value = ['rtl_fm', '-f', '462.5625M']
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'), \
patch('routes.dmr.find_ffmpeg', return_value=None), \
patch('routes.dmr.SDRFactory.create_default_device', return_value=MagicMock()), \
patch('routes.dmr.SDRFactory.get_builder', return_value=builder), \
patch('routes.dmr.app_module.claim_sdr_device', return_value=None), \
patch('routes.dmr.app_module.release_sdr_device') as release_mock, \
patch('routes.dmr.register_process') as register_mock, \
patch('routes.dmr.unregister_process') as unregister_mock, \
patch('routes.dmr.subprocess.Popen', side_effect=[rtl_proc, RuntimeError('dsd launch failed')]):
resp = auth_client.post('/dmr/start', json={
'frequency': 462.5625,
'protocol': 'auto',
'device': 0,
})
assert resp.status_code == 500
assert 'dsd launch failed' in resp.get_json()['message']
register_mock.assert_called_once_with(rtl_proc)
rtl_proc.terminate.assert_called_once()
unregister_mock.assert_called_once_with(rtl_proc)
release_mock.assert_called_once_with(0)
assert dmr_module.dmr_running is False
assert dmr_module.dmr_rtl_process is None
assert dmr_module.dmr_dsd_process is None

View File

@@ -58,17 +58,6 @@ class TestHealthEndpoint:
assert 'wifi' in processes assert 'wifi' in processes
assert 'bluetooth' in processes assert 'bluetooth' in processes
def test_health_reports_dmr_route_process(self, client):
"""Health should reflect DMR route module state (not stale app globals)."""
mock_proc = MagicMock()
mock_proc.poll.return_value = None
with patch('routes.dmr.dmr_running', True), \
patch('routes.dmr.dmr_dsd_process', mock_proc):
response = client.get('/health')
data = json.loads(response.data)
assert data['processes']['dmr'] is True
class TestDevicesEndpoint: class TestDevicesEndpoint:
"""Tests for devices endpoint.""" """Tests for devices endpoint."""

View File

@@ -122,14 +122,14 @@ def _get_mode_counts() -> dict[str, int]:
except Exception: except Exception:
counts['aprs'] = 0 counts['aprs'] = 0
# Meshtastic recent messages (route-level list) # Meshtastic recent messages (route-level list)
try: try:
import routes.meshtastic as mesh_route import routes.meshtastic as mesh_route
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', [])) counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
except Exception: except Exception:
counts['meshtastic'] = 0 counts['meshtastic'] = 0
return counts return counts
def get_cross_mode_summary() -> dict[str, Any]: def get_cross_mode_summary() -> dict[str, Any]:
@@ -160,12 +160,11 @@ def get_mode_health() -> dict[str, dict]:
'acars': 'acars_process', 'acars': 'acars_process',
'vdl2': 'vdl2_process', 'vdl2': 'vdl2_process',
'aprs': 'aprs_process', 'aprs': 'aprs_process',
'wifi': 'wifi_process', 'wifi': 'wifi_process',
'bluetooth': 'bt_process', 'bluetooth': 'bt_process',
'dsc': 'dsc_process', 'dsc': 'dsc_process',
'rtlamr': 'rtlamr_process', 'rtlamr': 'rtlamr_process',
'dmr': 'dmr_process', }
}
for mode, attr in process_map.items(): for mode, attr in process_map.items():
proc = getattr(app_module, attr, None) proc = getattr(app_module, attr, None)
@@ -187,16 +186,16 @@ def get_mode_health() -> dict[str, dict]:
pass pass
# Meshtastic: check client connection status # Meshtastic: check client connection status
try: try:
from utils.meshtastic import get_meshtastic_client from utils.meshtastic import get_meshtastic_client
client = get_meshtastic_client() client = get_meshtastic_client()
health['meshtastic'] = {'running': client._interface is not None} health['meshtastic'] = {'running': client._interface is not None}
except Exception: except Exception:
health['meshtastic'] = {'running': False} health['meshtastic'] = {'running': False}
try: try:
sdr_status = app_module.get_sdr_device_status() sdr_status = app_module.get_sdr_device_status()
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()} health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
except Exception: except Exception:
health['sdr_devices'] = {} health['sdr_devices'] = {}

View File

@@ -7,24 +7,68 @@ distance estimation, and proximity alerts for search and rescue operations.
from __future__ import annotations from __future__ import annotations
import logging import logging
import queue import queue
import threading import threading
from dataclasses import dataclass import time
from datetime import datetime from dataclasses import dataclass, field
from enum import Enum from datetime import datetime
from enum import Enum
from utils.bluetooth.models import BTDeviceAggregate from utils.bluetooth.models import BTDeviceAggregate
from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner
from utils.gps import get_current_position from utils.gps import get_current_position
logger = logging.getLogger('intercept.bt_locate') logger = logging.getLogger('intercept.bt_locate')
# Maximum trail points to retain # Maximum trail points to retain
MAX_TRAIL_POINTS = 500 MAX_TRAIL_POINTS = 500
# EMA smoothing factor for RSSI # EMA smoothing factor for RSSI
EMA_ALPHA = 0.3 EMA_ALPHA = 0.3
# Polling/restart tuning for scanner resilience without high CPU churn.
POLL_INTERVAL_SECONDS = 1.5
SCAN_RESTART_BACKOFF_SECONDS = 8.0
NO_MATCH_LOG_EVERY_POLLS = 10
def _normalize_mac(address: str | None) -> str | None:
"""Normalize MAC string to colon-separated uppercase form when possible."""
if not address:
return None
text = str(address).strip().upper().replace('-', ':')
if not text:
return None
# Handle raw 12-hex form: AABBCCDDEEFF
raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF')
if ':' not in text and len(raw) == 12:
text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
parts = text.split(':')
if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts):
return ':'.join(parts)
# Return cleaned original when not a strict MAC (caller may still use exact matching)
return text
def _address_looks_like_rpa(address: str | None) -> bool:
"""
Return True when an address looks like a Resolvable Private Address.
RPA check: most-significant two bits of the first octet are `01`.
"""
normalized = _normalize_mac(address)
if not normalized:
return False
try:
first_octet = int(normalized.split(':', 1)[0], 16)
except (ValueError, TypeError):
return False
return (first_octet >> 6) == 1
class Environment(Enum): class Environment(Enum):
@@ -94,8 +138,27 @@ class LocateTarget:
known_name: str | None = None known_name: str | None = None
known_manufacturer: str | None = None known_manufacturer: str | None = None
last_known_rssi: int | None = None last_known_rssi: int | None = None
_cached_irk_hex: str | None = field(default=None, init=False, repr=False)
_cached_irk_bytes: bytes | None = field(default=None, init=False, repr=False)
def matches(self, device: BTDeviceAggregate) -> bool: def _get_irk_bytes(self) -> bytes | None:
"""Parse/cache target IRK bytes once for repeated match checks."""
if not self.irk_hex:
return None
if self._cached_irk_hex == self.irk_hex:
return self._cached_irk_bytes
self._cached_irk_hex = self.irk_hex
self._cached_irk_bytes = None
try:
parsed = bytes.fromhex(self.irk_hex)
except (ValueError, TypeError):
return None
if len(parsed) != 16:
return None
self._cached_irk_bytes = parsed
return parsed
def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool:
"""Check if a device matches this target.""" """Check if a device matches this target."""
# Match by stable device key (survives MAC randomization for many devices) # Match by stable device key (survives MAC randomization for many devices)
if self.device_key and getattr(device, 'device_key', None) == self.device_key: if self.device_key and getattr(device, 'device_key', None) == self.device_key:
@@ -112,28 +175,30 @@ class LocateTarget:
if target_addr_part and dev_addr == target_addr_part: if target_addr_part and dev_addr == target_addr_part:
return True return True
# Match by MAC/address (case-insensitive, normalize separators) # Match by MAC/address (case-insensitive, normalize separators)
if self.mac_address: if self.mac_address:
dev_addr = (device.address or '').upper().replace('-', ':') dev_addr = _normalize_mac(device.address)
target_addr = self.mac_address.upper().replace('-', ':') target_addr = _normalize_mac(self.mac_address)
if dev_addr == target_addr: if dev_addr and target_addr and dev_addr == target_addr:
return True return True
# Match by payload fingerprint (guard against low-stability generic fingerprints) # Match by payload fingerprint.
# For explicit hand-off sessions, allow exact fingerprint matches even if
# stability is still warming up.
if self.fingerprint_id: if self.fingerprint_id:
dev_fp = getattr(device, 'payload_fingerprint_id', None) dev_fp = getattr(device, 'payload_fingerprint_id', None)
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0 dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
if dev_fp and dev_fp == self.fingerprint_id and dev_fp_stability >= 0.35: if dev_fp and dev_fp == self.fingerprint_id:
return True if dev_fp_stability >= 0.35:
return True
if any([self.device_id, self.device_key, self.mac_address, self.known_name]):
return True
# Match by RPA resolution # Match by RPA resolution
if self.irk_hex: if self.irk_hex and device.address and _address_looks_like_rpa(device.address):
try: irk = irk_bytes or self._get_irk_bytes()
irk = bytes.fromhex(self.irk_hex) if irk and resolve_rpa(irk, device.address):
if len(irk) == 16 and device.address and resolve_rpa(irk, device.address): return True
return True
except (ValueError, TypeError):
pass
# Match by name pattern # Match by name pattern
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower(): if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
@@ -235,7 +300,7 @@ class LocateSession:
self.environment = environment self.environment = environment
self.fallback_lat = fallback_lat self.fallback_lat = fallback_lat
self.fallback_lon = fallback_lon self.fallback_lon = fallback_lon
self._lock = threading.Lock() self._lock = threading.Lock()
# Distance estimator # Distance estimator
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
@@ -259,7 +324,9 @@ class LocateSession:
# Debug counters # Debug counters
self.callback_call_count = 0 self.callback_call_count = 0
self.poll_count = 0 self.poll_count = 0
self._last_seen_device: str | None = None self._last_seen_device: str | None = None
self._last_scan_restart_attempt = 0.0
self._target_irk = target._get_irk_bytes()
# Scanner reference # Scanner reference
self._scanner: BluetoothScanner | None = None self._scanner: BluetoothScanner | None = None
@@ -268,27 +335,34 @@ class LocateSession:
# Track last RSSI per device to detect changes # Track last RSSI per device to detect changes
self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only
def start(self) -> bool: def start(self) -> bool:
"""Start the locate session. """Start the locate session.
Subscribes to scanner callbacks AND runs a polling thread that Subscribes to scanner callbacks AND runs a polling thread that
checks the aggregator directly (handles bleak scan timeout). checks the aggregator directly (handles bleak scan timeout).
""" """
self._scanner = get_bluetooth_scanner() self._scanner = get_bluetooth_scanner()
self._scanner.add_device_callback(self._on_device) self._scanner.add_device_callback(self._on_device)
self._scanner_started_by_us = False
# Ensure BLE scanning is active
if not self._scanner.is_scanning: # Ensure BLE scanning is active
logger.info("BT scanner not running, starting scan for locate session") if not self._scanner.is_scanning:
self._scanner_started_by_us = True logger.info("BT scanner not running, starting scan for locate session")
if not self._scanner.start_scan(mode='auto'): self._scanner_started_by_us = True
logger.warning("Failed to start BT scanner for locate session") self._last_scan_restart_attempt = time.monotonic()
else: if not self._scanner.start_scan(mode='auto'):
self._scanner_started_by_us = False # Surface startup failure to caller and avoid leaving stale callbacks.
status = self._scanner.get_status()
self.active = True reason = status.error or "unknown error"
self.started_at = datetime.now() logger.warning(f"Failed to start BT scanner for locate session: {reason}")
self._stop_event.clear() self._scanner.remove_device_callback(self._on_device)
self._scanner = None
self._scanner_started_by_us = False
return False
self.active = True
self.started_at = datetime.now()
self._stop_event.clear()
# Start polling thread as reliable fallback # Start polling thread as reliable fallback
self._poll_thread = threading.Thread( self._poll_thread = threading.Thread(
@@ -314,37 +388,40 @@ class LocateSession:
def _poll_loop(self) -> None: def _poll_loop(self) -> None:
"""Poll scanner aggregator for target device updates.""" """Poll scanner aggregator for target device updates."""
while not self._stop_event.is_set(): while not self._stop_event.is_set():
self._stop_event.wait(timeout=1.5) self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS)
if self._stop_event.is_set(): if self._stop_event.is_set():
break break
try: try:
self._check_aggregator() self._check_aggregator()
except Exception as e: except Exception as e:
logger.error(f"Locate poll error: {e}") logger.error(f"Locate poll error: {e}")
def _check_aggregator(self) -> None: def _check_aggregator(self) -> None:
"""Check the scanner's aggregator for the target device.""" """Check the scanner's aggregator for the target device."""
if not self._scanner: if not self._scanner:
return return
self.poll_count += 1 self.poll_count += 1
# Restart scan if it expired (bleak 10s timeout) # Restart scan if it expired (bleak 10s timeout)
if not self._scanner.is_scanning: if not self._scanner.is_scanning:
logger.info("Scanner stopped, restarting for locate session") now = time.monotonic()
self._scanner.start_scan(mode='auto') if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS:
self._last_scan_restart_attempt = now
# Check devices seen within a recent window. Using a short window logger.info("Scanner stopped, restarting for locate session")
self._scanner.start_scan(mode='auto')
# Check devices seen within a recent window. Using a short window
# (rather than the aggregator's full 120s) so that once a device # (rather than the aggregator's full 120s) so that once a device
# goes silent its stale RSSI stops producing detections. The window # goes silent its stale RSSI stops producing detections. The window
# must survive bleak's 10s scan cycle + restart gap (~3s). # must survive bleak's 10s scan cycle + restart gap (~3s).
devices = self._scanner.get_devices(max_age_seconds=15) devices = self._scanner.get_devices(max_age_seconds=15)
found_target = False found_target = False
for device in devices: for device in devices:
if not self.target.matches(device): if not self.target.matches(device, irk_bytes=self._target_irk):
continue continue
found_target = True found_target = True
rssi = device.rssi_current rssi = device.rssi_current
if rssi is None: if rssi is None:
continue continue
@@ -352,10 +429,14 @@ class LocateSession:
break # One match per poll cycle is sufficient break # One match per poll cycle is sufficient
# Log periodically for debugging # Log periodically for debugging
if self.poll_count % 20 == 0 or (self.poll_count <= 5) or not found_target: if (
logger.info( self.poll_count <= 5
f"Poll #{self.poll_count}: {len(devices)} devices, " or self.poll_count % 20 == 0
f"target_found={found_target}, " or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0)
):
logger.info(
f"Poll #{self.poll_count}: {len(devices)} devices, "
f"target_found={found_target}, "
f"detections={self.detection_count}, " f"detections={self.detection_count}, "
f"scanning={self._scanner.is_scanning}" f"scanning={self._scanner.is_scanning}"
) )
@@ -368,8 +449,8 @@ class LocateSession:
self.callback_call_count += 1 self.callback_call_count += 1
self._last_seen_device = f"{device.device_id}|{device.name}" self._last_seen_device = f"{device.device_id}|{device.name}"
if not self.target.matches(device): if not self.target.matches(device, irk_bytes=self._target_irk):
return return
rssi = device.rssi_current rssi = device.rssi_current
if rssi is None: if rssi is None:
@@ -397,13 +478,9 @@ class LocateSession:
band = DistanceEstimator.proximity_band(distance) band = DistanceEstimator.proximity_band(distance)
# Check RPA resolution # Check RPA resolution
rpa_resolved = False rpa_resolved = False
if self.target.irk_hex and device.address: if self._target_irk and device.address and _address_looks_like_rpa(device.address):
try: rpa_resolved = resolve_rpa(self._target_irk, device.address)
irk = bytes.fromhex(self.target.irk_hex)
rpa_resolved = resolve_rpa(irk, device.address)
except (ValueError, TypeError):
pass
# GPS tag — prefer live GPS, fall back to user-set coordinates # GPS tag — prefer live GPS, fall back to user-set coordinates
gps_pos = get_current_position() gps_pos = get_current_position()
@@ -465,15 +542,15 @@ class LocateSession:
with self._lock: with self._lock:
return [p.to_dict() for p in self.trail if p.lat is not None] return [p.to_dict() for p in self.trail if p.lat is not None]
def get_status(self) -> dict: def get_status(self, include_debug: bool = False) -> dict:
"""Get session status.""" """Get session status."""
gps_pos = get_current_position() gps_pos = get_current_position()
# Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA # Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA
# deadlock: get_status would hold self._lock then wait on # deadlock: get_status would hold self._lock then wait on
# aggregator._lock, while _poll_loop holds aggregator._lock then # aggregator._lock, while _poll_loop holds aggregator._lock then
# waits on self._lock in _record_detection. # waits on self._lock in _record_detection.
debug_devices = self._debug_device_sample() debug_devices = self._debug_device_sample() if include_debug else []
scanner_running = self._scanner.is_scanning if self._scanner else False scanner_running = self._scanner.is_scanning if self._scanner else False
scanner_device_count = self._scanner.device_count if self._scanner else 0 scanner_device_count = self._scanner.device_count if self._scanner else 0
callback_registered = ( callback_registered = (
@@ -509,8 +586,8 @@ class LocateSession:
'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None, 'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None,
'latest_distance': round(self.trail[-1].estimated_distance, 2) if self.trail else None, 'latest_distance': round(self.trail[-1].estimated_distance, 2) if self.trail else None,
'latest_band': self.trail[-1].proximity_band if self.trail else None, 'latest_band': self.trail[-1].proximity_band if self.trail else None,
'debug_devices': debug_devices, 'debug_devices': debug_devices,
} }
def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None: def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None:
"""Update the environment and recalculate distance estimator.""" """Update the environment and recalculate distance estimator."""
@@ -525,16 +602,16 @@ class LocateSession:
return [] return []
try: try:
devices = self._scanner.get_devices(max_age_seconds=30) devices = self._scanner.get_devices(max_age_seconds=30)
return [ return [
{ {
'id': d.device_id, 'id': d.device_id,
'addr': d.address, 'addr': d.address,
'name': d.name, 'name': d.name,
'rssi': d.rssi_current, 'rssi': d.rssi_current,
'match': self.target.matches(d), 'match': self.target.matches(d, irk_bytes=self._target_irk),
} }
for d in devices[:8] for d in devices[:8]
] ]
except Exception: except Exception:
return [] return []
@@ -550,25 +627,27 @@ _session: LocateSession | None = None
_session_lock = threading.Lock() _session_lock = threading.Lock()
def start_locate_session( def start_locate_session(
target: LocateTarget, target: LocateTarget,
environment: Environment = Environment.OUTDOOR, environment: Environment = Environment.OUTDOOR,
custom_exponent: float | None = None, custom_exponent: float | None = None,
fallback_lat: float | None = None, fallback_lat: float | None = None,
fallback_lon: float | None = None, fallback_lon: float | None = None,
) -> LocateSession: ) -> LocateSession:
"""Start a new locate session, stopping any existing one.""" """Start a new locate session, stopping any existing one."""
global _session global _session
with _session_lock: with _session_lock:
if _session and _session.active: if _session and _session.active:
_session.stop() _session.stop()
_session = LocateSession( _session = LocateSession(
target, environment, custom_exponent, fallback_lat, fallback_lon target, environment, custom_exponent, fallback_lat, fallback_lon
) )
_session.start() if not _session.start():
return _session _session = None
raise RuntimeError("Bluetooth scanner failed to start")
return _session
def stop_locate_session() -> None: def stop_locate_session() -> None:

View File

@@ -550,12 +550,12 @@ def init_db() -> None:
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1) VALUES ('25544', 'ISS (ZARYA)', NULL, NULL, 1, 1)
''') ''')
conn.execute(''' conn.execute('''
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin) INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1) VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
''') ''')
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
def close_db() -> None: def close_db() -> None:
@@ -2285,10 +2285,10 @@ def update_tracked_satellite(norad_id: str, enabled: bool) -> bool:
return cursor.rowcount > 0 return cursor.rowcount > 0
def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]: def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
"""Delete a tracked satellite by NORAD ID. Refuses to delete builtins.""" """Delete a tracked satellite by NORAD ID. Refuses to delete builtins."""
with get_db() as conn: with get_db() as conn:
row = conn.execute( row = conn.execute(
'SELECT builtin FROM tracked_satellites WHERE norad_id = ?', 'SELECT builtin FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),), (str(norad_id),),
).fetchone() ).fetchone()
@@ -2296,9 +2296,10 @@ def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
return False, 'Satellite not found' return False, 'Satellite not found'
if row[0]: if row[0]:
return False, 'Cannot remove builtin satellite' return False, 'Cannot remove builtin satellite'
conn.execute( conn.execute(
'DELETE FROM tracked_satellites WHERE norad_id = ?', 'DELETE FROM tracked_satellites WHERE norad_id = ?',
(str(norad_id),), (str(norad_id),),
) )
return True, 'Removed' return True, 'Removed'

View File

@@ -54,7 +54,6 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) -
# Alert failures should never break streaming # Alert failures should never break streaming
pass pass
def _extract_device_id(event: dict) -> str | None: def _extract_device_id(event: dict) -> str | None:
for field in DEVICE_ID_FIELDS: for field in DEVICE_ID_FIELDS:
value = event.get(field) value = event.get(field)

View File

@@ -343,26 +343,10 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [
regions=["GLOBAL"], regions=["GLOBAL"],
), ),
# LoRaWAN # Key Fob / Remote
SignalTypeDefinition( SignalTypeDefinition(
label="LoRaWAN / LoRa Device", label="Remote Control / Key Fob",
tags=["iot", "lora", "lpwan", "telemetry"], tags=["remote", "keyfob", "automotive", "burst", "ism"],
description="LoRa long-range IoT device",
frequency_ranges=[
(863_000_000, 870_000_000), # EU868
(902_000_000, 928_000_000), # US915
],
modulation_hints=["LoRa", "CSS", "FSK"],
bandwidth_range=(125_000, 500_000), # LoRa spreading bandwidths
base_score=11,
is_burst_type=True,
regions=["UK/EU", "US"],
),
# Key Fob / Remote
SignalTypeDefinition(
label="Remote Control / Key Fob",
tags=["remote", "keyfob", "automotive", "burst", "ism"],
description="Wireless remote control or vehicle key fob", description="Wireless remote control or vehicle key fob",
frequency_ranges=[ frequency_ranges=[
(314_900_000, 315_100_000), # 315 MHz (US) (314_900_000, 315_100_000), # 315 MHz (US)