mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Merge branch 'smittix:main' into main
This commit is contained in:
23
Dockerfile
23
Dockerfile
@@ -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
102
app.py
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
753
routes/dmr.py
753
routes/dmr.py
@@ -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
|
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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
123
setup.sh
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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': ''
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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'] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user