mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
Merge upstream/main: sync fork with latest DMR fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,39 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.15.0] - 2026-02-09
|
||||
|
||||
### Added
|
||||
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
|
||||
- Click-to-tune, zoom controls, and auto-scaling quantization
|
||||
- Shared waterfall UI across SDR modes with function bar controls
|
||||
- WebSocket frame serialization and connection reuse
|
||||
- **Cross-Module Frequency Routing** - Tune from Listening Post directly to decoders
|
||||
- **Pure Python SSTV Decoder** - Replaces broken slowrx C dependency
|
||||
- Real-time decode progress with partial image streaming
|
||||
- VIS detector state in signal monitor diagnostics
|
||||
- Image gallery with delete and download functionality
|
||||
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||
- **SSTV Image Gallery** - Delete and download decoded images
|
||||
- **USB Device Probe** - Detect broken SDR devices before rtl_fm crashes
|
||||
|
||||
### Fixed
|
||||
- DMR dsd-fme protocol flags, device label, and tuning controls
|
||||
- DMR frontend/backend state desync causing 409 on start
|
||||
- Digital voice decoder producing no output due to wrong dsd-fme flags
|
||||
- SDR device lock-up from unreleased device registry on process crash
|
||||
- APRS crash on large station count and station list overflow
|
||||
- Settings modal overflowing viewport on smaller screens
|
||||
- Waterfall crash on zoom by reusing WebSocket and adding USB release retry
|
||||
- PD120 SSTV decode hang and false leader tone detection
|
||||
- WebSocket waterfall blocked by login redirect
|
||||
- TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
|
||||
|
||||
### Removed
|
||||
- GSM Spy functionality removed for legal compliance
|
||||
|
||||
---
|
||||
|
||||
## [2.14.0] - 2026-02-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -7,10 +7,23 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.14.0"
|
||||
VERSION = "2.15.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.15.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Real-time WebSocket waterfall with I/Q capture and server-side FFT",
|
||||
"Cross-module frequency routing from Listening Post to decoders",
|
||||
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
||||
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
|
||||
"SDR device lock-up fix from unreleased device registry on crash",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.14.0",
|
||||
"date": "February 2026",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.14.0"
|
||||
version = "2.15.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
+225
-23
@@ -20,7 +20,7 @@ from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
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
|
||||
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||
from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
@@ -39,10 +39,17 @@ 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. Writes to an ffmpeg
|
||||
# stdin when a streaming client is connected, discards otherwise.
|
||||
# This prevents dsd-fme from blocking on stdout (which would also
|
||||
# freeze stderr / text data output).
|
||||
_active_ffmpeg_stdin: Optional[object] = None # set by stream endpoint
|
||||
|
||||
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
||||
|
||||
# Classic dsd flags
|
||||
@@ -55,14 +62,26 @@ _DSD_PROTOCOL_FLAGS = {
|
||||
'provoice': ['-fv'],
|
||||
}
|
||||
|
||||
# dsd-fme uses different flag names
|
||||
# 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': [],
|
||||
'dmr': ['-fd'],
|
||||
'p25': ['-fp'],
|
||||
'nxdn': ['-fn'],
|
||||
'dstar': ['-fi'],
|
||||
'provoice': ['-fv'],
|
||||
'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF
|
||||
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
|
||||
'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice 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
|
||||
'p25': ['-mc'], # C4FM (Phase 1; Phase 2 would use -mq)
|
||||
'nxdn': ['-mc'], # C4FM
|
||||
}
|
||||
|
||||
# ============================================
|
||||
@@ -90,6 +109,11 @@ def find_rtl_fm() -> str | None:
|
||||
return shutil.which('rtl_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.
|
||||
|
||||
@@ -101,8 +125,11 @@ def parse_dsd_output(line: str) -> dict | None:
|
||||
return None
|
||||
|
||||
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
|
||||
# These contain box-drawing characters or are pure decoration.
|
||||
if re.search(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░]', line):
|
||||
# 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
|
||||
@@ -122,8 +149,9 @@ def parse_dsd_output(line: str) -> dict | None:
|
||||
# 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'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE
|
||||
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
|
||||
)
|
||||
if tg_match:
|
||||
result = {
|
||||
@@ -182,6 +210,45 @@ def parse_dsd_output(line: str) -> dict | None:
|
||||
|
||||
_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 _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 an audio streaming client is connected, forwards audio
|
||||
to its ffmpeg stdin with silence fill during voice gaps. When no
|
||||
client is connected, simply discards the data.
|
||||
"""
|
||||
try:
|
||||
while dmr_running:
|
||||
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
|
||||
if ready:
|
||||
data = os.read(dsd_stdout.fileno(), 4096)
|
||||
if not data:
|
||||
break
|
||||
sink = _active_ffmpeg_stdin
|
||||
if sink:
|
||||
try:
|
||||
sink.write(data)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass
|
||||
else:
|
||||
# No audio from decoder — feed silence if client connected
|
||||
sink = _active_ffmpeg_stdin
|
||||
if sink:
|
||||
try:
|
||||
sink.write(_SILENCE_CHUNK)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _queue_put(event: dict):
|
||||
"""Put an event on the DMR queue, dropping oldest if full."""
|
||||
@@ -229,6 +296,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
|
||||
if not text:
|
||||
continue
|
||||
|
||||
logger.debug("DSD raw: %s", text)
|
||||
parsed = parse_dsd_output(text)
|
||||
if parsed:
|
||||
_queue_put(parsed)
|
||||
@@ -262,7 +330,7 @@ def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Pop
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||
# Cleanup both processes
|
||||
# Cleanup decoder + demod processes
|
||||
for proc in [dsd_process, rtl_process]:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
@@ -294,9 +362,11 @@ def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
dsd_path, _ = find_dsd()
|
||||
rtl_fm = find_rtl_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
return jsonify({
|
||||
'dsd': dsd_path is not None,
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'available': dsd_path is not None and rtl_fm is not None,
|
||||
'protocols': VALID_PROTOCOLS,
|
||||
})
|
||||
@@ -305,7 +375,8 @@ def check_tools() -> Response:
|
||||
@dmr_bp.route('/start', methods=['POST'])
|
||||
def start_dmr() -> Response:
|
||||
"""Start digital voice decoding."""
|
||||
global dmr_rtl_process, dmr_dsd_process, dmr_thread, dmr_running, dmr_active_device
|
||||
global dmr_rtl_process, dmr_dsd_process, dmr_thread
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
with dmr_lock:
|
||||
if dmr_running:
|
||||
@@ -326,6 +397,7 @@ def start_dmr() -> Response:
|
||||
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
|
||||
|
||||
@@ -339,8 +411,10 @@ def start_dmr() -> Response:
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Claim SDR device
|
||||
error = app_module.claim_sdr_device(device, 'dmr')
|
||||
# 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:
|
||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||
|
||||
@@ -348,7 +422,10 @@ def start_dmr() -> Response:
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
|
||||
# Build rtl_fm command (48kHz sample rate for DSD)
|
||||
# Build rtl_fm command (48kHz sample rate for DSD).
|
||||
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream
|
||||
# mid-frame, destroying DSD sync. The decoder handles silence
|
||||
# internally via its own frame-sync detection.
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', 'fm',
|
||||
@@ -356,15 +433,32 @@ def start_dmr() -> Response:
|
||||
'-s', '48000',
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', '1', # squelch level
|
||||
'-l', '0',
|
||||
]
|
||||
if ppm != 0:
|
||||
rtl_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Build DSD command
|
||||
# Use -o - to send decoded audio to stdout (piped to DEVNULL)
|
||||
# instead of PulseAudio which may not be available under sudo
|
||||
dsd_cmd = [dsd_path, '-i', '-', '-o', '-']
|
||||
# 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, []))
|
||||
|
||||
@@ -376,10 +470,13 @@ def start_dmr() -> Response:
|
||||
)
|
||||
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=subprocess.DEVNULL,
|
||||
stdout=dsd_stdout,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
register_process(dmr_dsd_process)
|
||||
@@ -387,6 +484,17 @@ def start_dmr() -> Response:
|
||||
# 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()
|
||||
@@ -400,7 +508,8 @@ def start_dmr() -> Response:
|
||||
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 process and unregister both
|
||||
# Terminate surviving processes and unregister all
|
||||
dmr_has_audio = False
|
||||
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
@@ -450,6 +559,7 @@ def start_dmr() -> Response:
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'protocol': protocol,
|
||||
'has_audio': dmr_has_audio,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -463,10 +573,12 @@ def start_dmr() -> Response:
|
||||
@dmr_bp.route('/stop', methods=['POST'])
|
||||
def stop_dmr() -> Response:
|
||||
"""Stop digital voice decoding."""
|
||||
global dmr_rtl_process, dmr_dsd_process, dmr_running, dmr_active_device
|
||||
global dmr_rtl_process, dmr_dsd_process
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
with dmr_lock:
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
|
||||
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
||||
if proc and proc.poll() is None:
|
||||
@@ -497,9 +609,99 @@ def dmr_status() -> Response:
|
||||
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).
|
||||
"""
|
||||
global _active_ffmpeg_stdin
|
||||
|
||||
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()
|
||||
|
||||
# Tell the mux thread to start writing to this ffmpeg
|
||||
_active_ffmpeg_stdin = audio_proc.stdin
|
||||
|
||||
def generate():
|
||||
global _active_ffmpeg_stdin
|
||||
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
|
||||
_active_ffmpeg_stdin = None
|
||||
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."""
|
||||
|
||||
+26
-11
@@ -1310,20 +1310,35 @@ def start_audio() -> Response:
|
||||
_stop_waterfall_internal()
|
||||
time.sleep(0.2)
|
||||
|
||||
# Release waterfall device claim if the WebSocket waterfall is still
|
||||
# holding it. The JS client sends a stop command and closes the
|
||||
# WebSocket before requesting audio, but the backend handler may not
|
||||
# have finished its cleanup yet.
|
||||
device_status = app_module.get_sdr_device_status()
|
||||
if device_status.get(device) == 'waterfall':
|
||||
app_module.release_sdr_device(device)
|
||||
time.sleep(0.3)
|
||||
|
||||
# Claim device for listening audio
|
||||
# Claim device for listening audio. The WebSocket waterfall handler
|
||||
# may still be tearing down its IQ capture process (thread join +
|
||||
# safe_terminate can take several seconds), so we retry with back-off
|
||||
# to give the USB device time to be fully released.
|
||||
if listening_active_device is None or listening_active_device != device:
|
||||
if listening_active_device is not None:
|
||||
app_module.release_sdr_device(listening_active_device)
|
||||
error = app_module.claim_sdr_device(device, 'listening')
|
||||
listening_active_device = None
|
||||
|
||||
error = None
|
||||
max_claim_attempts = 6
|
||||
for attempt in range(max_claim_attempts):
|
||||
# Force-release a stale waterfall registry entry on each
|
||||
# attempt — the WebSocket handler may not have finished
|
||||
# cleanup yet.
|
||||
device_status = app_module.get_sdr_device_status()
|
||||
if device_status.get(device) == 'waterfall':
|
||||
app_module.release_sdr_device(device)
|
||||
|
||||
error = app_module.claim_sdr_device(device, 'listening')
|
||||
if not error:
|
||||
break
|
||||
if attempt < max_claim_attempts - 1:
|
||||
logger.debug(
|
||||
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
||||
f"failed, retrying in 0.5s: {error}"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
|
||||
+229
-10
@@ -10,6 +10,13 @@ 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';
|
||||
|
||||
// ============== SYNTHESIZER STATE ==============
|
||||
let dmrSynthCanvas = null;
|
||||
@@ -40,6 +47,7 @@ function checkDmrTools() {
|
||||
const missing = [];
|
||||
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
||||
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
|
||||
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
|
||||
|
||||
if (missing.length > 0) {
|
||||
warning.style.display = 'block';
|
||||
@@ -47,6 +55,9 @@ function checkDmrTools() {
|
||||
} else {
|
||||
warning.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update audio panel availability
|
||||
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -57,17 +68,29 @@ 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;
|
||||
|
||||
// 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('dmr')) {
|
||||
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 })
|
||||
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -86,10 +109,14 @@ function startDmr() {
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
if (typeof reserveDevice === 'function') {
|
||||
reserveDevice(parseInt(device), 'dmr');
|
||||
reserveDevice(parseInt(device), dmrModeLabel);
|
||||
}
|
||||
// Start audio if available
|
||||
dmrHasAudio = !!data.has_audio;
|
||||
if (dmrHasAudio) startDmrAudio();
|
||||
updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE');
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
||||
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
|
||||
@@ -116,6 +143,7 @@ function startDmr() {
|
||||
}
|
||||
|
||||
function stopDmr() {
|
||||
stopDmrAudio();
|
||||
fetch('/dmr/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
@@ -125,10 +153,11 @@ function stopDmr() {
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
updateDmrAudioStatus('OFF');
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
if (typeof releaseDevice === 'function') {
|
||||
releaseDevice('dmr');
|
||||
releaseDevice(dmrModeLabel);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[DMR] Stop error:', err));
|
||||
@@ -225,24 +254,28 @@ function handleDmrMessage(msg) {
|
||||
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('dmr');
|
||||
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('dmr');
|
||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,10 +354,12 @@ function drawDmrSynthesizer() {
|
||||
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||||
dmrSynthCtx.fillRect(0, 0, width, height);
|
||||
|
||||
// Decay activity toward target
|
||||
// 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 > 2000) {
|
||||
// No events for 2s — decay target toward idle
|
||||
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';
|
||||
@@ -511,6 +546,178 @@ function stopDmrSynthesizer() {
|
||||
|
||||
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);
|
||||
dmrBookmarks = saved ? JSON.parse(saved) : [];
|
||||
} catch (e) {
|
||||
dmrBookmarks = [];
|
||||
}
|
||||
renderDmrBookmarks();
|
||||
}
|
||||
|
||||
function saveDmrBookmarks() {
|
||||
try {
|
||||
localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks));
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
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 = 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) freqEl.value = freq;
|
||||
if (protoEl) protoEl.value = protocol;
|
||||
}
|
||||
|
||||
function renderDmrBookmarks() {
|
||||
const container = document.getElementById('dmrBookmarksList');
|
||||
if (!container) return;
|
||||
|
||||
if (dmrBookmarks.length === 0) {
|
||||
container.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = dmrBookmarks.map((b, i) => `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 3px; margin-bottom: 3px;">
|
||||
<span style="cursor: pointer; color: var(--accent-cyan); font-size: 11px; flex: 1;" onclick="dmrQuickTune(${b.freq}, '${b.protocol}')" title="${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})">${b.label}</span>
|
||||
<span style="color: var(--text-muted); font-size: 9px; margin: 0 6px;">${b.protocol.toUpperCase()}</span>
|
||||
<button onclick="removeDmrBookmark(${i})" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 12px; padding: 0 4px;">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ============== STATUS SYNC ==============
|
||||
|
||||
function checkDmrStatus() {
|
||||
@@ -544,6 +751,13 @@ function checkDmrStatus() {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============== INIT ==============
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
restoreDmrSettings();
|
||||
loadDmrBookmarks();
|
||||
});
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.startDmr = startDmr;
|
||||
@@ -551,3 +765,8 @@ 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;
|
||||
|
||||
@@ -3943,11 +3943,31 @@ async function stopWaterfall() {
|
||||
|
||||
// WebSocket path
|
||||
if (waterfallUseWebSocket && waterfallWebSocket) {
|
||||
const ws = waterfallWebSocket;
|
||||
try {
|
||||
if (waterfallWebSocket.readyState === WebSocket.OPEN) {
|
||||
waterfallWebSocket.send(JSON.stringify({ cmd: 'stop' }));
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
// Wait for server to confirm stop (it terminates the IQ
|
||||
// process and releases the USB device before responding).
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 4000);
|
||||
const prevHandler = ws.onmessage;
|
||||
ws.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.status === 'stopped') {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (prevHandler) prevHandler(event);
|
||||
};
|
||||
ws.send(JSON.stringify({ cmd: 'stop' }));
|
||||
});
|
||||
}
|
||||
waterfallWebSocket.close();
|
||||
ws.close();
|
||||
} catch (e) {
|
||||
console.error('[WATERFALL] WebSocket stop error:', e);
|
||||
}
|
||||
@@ -3958,8 +3978,6 @@ async function stopWaterfall() {
|
||||
if (typeof releaseDevice === 'function') {
|
||||
releaseDevice('waterfall');
|
||||
}
|
||||
// Allow backend WebSocket handler to finish cleanup and release SDR
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1699,6 +1699,18 @@
|
||||
</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;">
|
||||
|
||||
@@ -32,6 +32,42 @@
|
||||
<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>
|
||||
|
||||
<!-- Actions -->
|
||||
|
||||
+52
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS
|
||||
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
|
||||
|
||||
|
||||
# ============================================
|
||||
@@ -66,6 +66,16 @@ def test_parse_talkgroup_dsd_fme_format():
|
||||
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')
|
||||
@@ -98,13 +108,40 @@ def test_parse_unrecognized():
|
||||
assert result['text'] == 'some random text'
|
||||
|
||||
|
||||
def test_dsd_fme_protocol_flags_match_classic():
|
||||
"""dsd-fme flags must match classic DSD flags (same fork, same CLI)."""
|
||||
assert _DSD_FME_PROTOCOL_FLAGS == _DSD_PROTOCOL_FLAGS
|
||||
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'] == ['-ft'] # XDMA
|
||||
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!)
|
||||
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-f1'] # NOT -fp (ProVoice in fme)
|
||||
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():
|
||||
"""Protocol flags should map to the correct DSD -f flags."""
|
||||
"""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']
|
||||
@@ -113,6 +150,16 @@ def test_dsd_protocol_flags_known_values():
|
||||
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['p25'] == ['-mc']
|
||||
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
|
||||
# D-Star and ProVoice should not have forced modulation
|
||||
assert 'dstar' not in _DSD_FME_MODULATION
|
||||
assert 'provoice' not in _DSD_FME_MODULATION
|
||||
|
||||
|
||||
# ============================================
|
||||
# Endpoint tests
|
||||
# ============================================
|
||||
|
||||
Reference in New Issue
Block a user