mirror of
https://github.com/smittix/intercept.git
synced 2026-06-11 07:23:30 -07:00
856 lines
29 KiB
Python
856 lines
29 KiB
Python
"""DMR / P25 / Digital Voice decoding routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import json
|
|
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 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, validate_ppm
|
|
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. 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', 'p25p2', 'nxdn', 'dstar', 'provoice']
|
|
VALID_DEMODS = ['nfm', 'fm']
|
|
|
|
# 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': ['-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!)
|
|
'p25p2': ['-f2'], # P25 Phase 2
|
|
'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)
|
|
'p25p2': ['-mq'], # CQPSK (Phase 2)
|
|
'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_ffmpeg() -> str | None:
|
|
"""Find ffmpeg for audio encoding."""
|
|
return shutil.which('ffmpeg')
|
|
|
|
|
|
def _coerce_int(value) -> int | None:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _parse_dsd_json(payload: dict, ts: str) -> dict | None:
|
|
"""Parse JSON output lines from dsd-fme into events."""
|
|
event_type = str(payload.get('type') or payload.get('event') or payload.get('msg') or payload.get('kind') or '').lower()
|
|
nested = payload.get('data') if isinstance(payload.get('data'), dict) else {}
|
|
|
|
def first_of(keys):
|
|
for obj in (payload, nested):
|
|
for key in keys:
|
|
if key in obj and obj[key] is not None:
|
|
return obj[key]
|
|
return None
|
|
|
|
talkgroup = _coerce_int(first_of([
|
|
'tg', 'tgt', 'talkgroup', 'talk_group', 'tgid',
|
|
'group', 'group_id', 'groupId', 'dst', 'dest',
|
|
'destination', 'target'
|
|
]))
|
|
source = _coerce_int(first_of([
|
|
'src', 'source', 'src_id', 'source_id', 'sourceId',
|
|
'uid', 'unit', 'radio', 'rid', 'radio_id', 'radioId'
|
|
]))
|
|
slot = _coerce_int(first_of(['slot', 'timeslot', 'time_slot', 'ts']))
|
|
nac = first_of(['nac'])
|
|
protocol = first_of(['protocol', 'mode', 'system', 'sys', 'network'])
|
|
|
|
if talkgroup is not None and source is not None:
|
|
event = {
|
|
'type': 'call',
|
|
'talkgroup': talkgroup,
|
|
'source_id': source,
|
|
'timestamp': ts,
|
|
}
|
|
if slot is not None:
|
|
event['slot'] = slot
|
|
if protocol:
|
|
event['protocol'] = str(protocol)
|
|
return event
|
|
|
|
if nac is not None:
|
|
return {'type': 'nac', 'nac': str(nac), 'timestamp': ts}
|
|
|
|
if 'sync' in event_type:
|
|
return {
|
|
'type': 'sync',
|
|
'protocol': str(protocol or event_type),
|
|
'timestamp': ts,
|
|
}
|
|
|
|
voice_flag = first_of(['voice', 'voice_frame', 'voiceFrame'])
|
|
if 'voice' in event_type or voice_flag is True:
|
|
event = {
|
|
'type': 'voice',
|
|
'detail': str(first_of(['detail', 'text']) or event_type or 'voice'),
|
|
'timestamp': ts,
|
|
}
|
|
if slot is not None:
|
|
event['slot'] = slot
|
|
return event
|
|
|
|
if protocol:
|
|
return {'type': 'sync', 'protocol': str(protocol), 'timestamp': ts}
|
|
|
|
return None
|
|
|
|
|
|
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
|
|
|
|
ts = datetime.now().strftime('%H:%M:%S')
|
|
|
|
# Frame-level error / OK indicators (useful for quality metrics)
|
|
if re.search(r'\bDUID\s+ERR\b', line, re.IGNORECASE):
|
|
return {
|
|
'type': 'frame_error',
|
|
'kind': 'duid',
|
|
'detail': line[:200],
|
|
'timestamp': ts,
|
|
}
|
|
if re.search(r'\bR-?S\s+ERR\b', line, re.IGNORECASE):
|
|
return {
|
|
'type': 'frame_error',
|
|
'kind': 'rs',
|
|
'detail': line[:200],
|
|
'timestamp': ts,
|
|
}
|
|
if re.search(r'\bP25p2\b.*\b4V\b', line, re.IGNORECASE):
|
|
return {
|
|
'type': 'frame_ok',
|
|
'kind': 'p25p2',
|
|
'timestamp': ts,
|
|
}
|
|
|
|
# If dsd-fme is emitting JSON (via -J), parse it first.
|
|
if line.startswith('{') and line.endswith('}'):
|
|
try:
|
|
payload = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
payload = None
|
|
if isinstance(payload, dict):
|
|
parsed = _parse_dsd_json(payload, ts)
|
|
if parsed:
|
|
return parsed
|
|
return {
|
|
'type': 'raw',
|
|
'text': line[:200],
|
|
'timestamp': ts,
|
|
}
|
|
|
|
# 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
|
|
|
|
# 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 _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."""
|
|
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
|
|
dmr_running = False
|
|
# 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
|
|
for proc in [dsd_process, rtl_process]:
|
|
if proc and proc.poll() is None:
|
|
try:
|
|
proc.terminate()
|
|
proc.wait(timeout=2)
|
|
except Exception:
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
if proc:
|
|
unregister_process(proc)
|
|
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()
|
|
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,
|
|
})
|
|
|
|
|
|
@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
|
|
|
|
with dmr_lock:
|
|
if dmr_running:
|
|
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
|
|
|
dsd_path, is_fme = find_dsd()
|
|
if not dsd_path:
|
|
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
|
|
|
|
rtl_fm_path = find_rtl_fm()
|
|
if not rtl_fm_path:
|
|
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 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))
|
|
fine_tune = int(data.get('fineTune', 0) or 0)
|
|
demod = str(data.get('demod', 'nfm')).lower()
|
|
except (ValueError, TypeError) as e:
|
|
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
|
|
|
if protocol not in VALID_PROTOCOLS:
|
|
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
|
if demod not in VALID_DEMODS:
|
|
return jsonify({'status': 'error', 'message': f'Invalid demod. Use: {", ".join(VALID_DEMODS)}'}), 400
|
|
if protocol == 'p25p2' and not is_fme:
|
|
return jsonify({'status': 'error', 'message': 'P25 Phase 2 requires dsd-fme.'}), 400
|
|
if abs(fine_tune) > 20000:
|
|
return jsonify({'status': 'error', 'message': 'Fine tune offset too large (max +/- 20000 Hz).'}), 400
|
|
|
|
# Clear stale queue
|
|
try:
|
|
while True:
|
|
dmr_queue.get_nowait()
|
|
except queue.Empty:
|
|
pass
|
|
|
|
# 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
|
|
|
|
dmr_active_device = device
|
|
|
|
freq_hz = int((frequency * 1e6) + fine_tune)
|
|
|
|
# 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', demod,
|
|
'-f', str(freq_hz),
|
|
'-s', '48000',
|
|
'-g', str(gain),
|
|
'-d', str(device),
|
|
'-l', '0',
|
|
]
|
|
if ppm != 0:
|
|
rtl_cmd.extend(['-p', str(ppm)])
|
|
|
|
# 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()
|
|
|
|
# Mark running before starting mux so it doesn't exit immediately.
|
|
dmr_running = True
|
|
|
|
# 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 unregister all
|
|
dmr_running = False
|
|
dmr_has_audio = False
|
|
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
|
if proc and proc.poll() is None:
|
|
try:
|
|
proc.terminate()
|
|
proc.wait(timeout=2)
|
|
except Exception:
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
if proc:
|
|
unregister_process(proc)
|
|
dmr_rtl_process = None
|
|
dmr_dsd_process = None
|
|
if dmr_active_device is not None:
|
|
app_module.release_sdr_device(dmr_active_device)
|
|
dmr_active_device = None
|
|
# 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,
|
|
'has_audio': dmr_has_audio,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to start DMR: {e}")
|
|
dmr_running = False
|
|
dmr_has_audio = False
|
|
if dmr_active_device is not None:
|
|
app_module.release_sdr_device(dmr_active_device)
|
|
dmr_active_device = None
|
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
|
|
|
|
|
@dmr_bp.route('/stop', methods=['POST'])
|
|
def stop_dmr() -> Response:
|
|
"""Stop digital voice decoding."""
|
|
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:
|
|
try:
|
|
proc.terminate()
|
|
proc.wait(timeout=2)
|
|
except Exception:
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
if proc:
|
|
unregister_process(proc)
|
|
|
|
dmr_rtl_process = None
|
|
dmr_dsd_process = None
|
|
|
|
if dmr_active_device is not None:
|
|
app_module.release_sdr_device(dmr_active_device)
|
|
dmr_active_device = None
|
|
|
|
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).
|
|
"""
|
|
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."""
|
|
def generate() -> Generator[str, None, None]:
|
|
last_keepalive = time.time()
|
|
while True:
|
|
try:
|
|
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
|
last_keepalive = time.time()
|
|
try:
|
|
process_event('dmr', msg, msg.get('type'))
|
|
except Exception:
|
|
pass
|
|
yield format_sse(msg)
|
|
except queue.Empty:
|
|
now = time.time()
|
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
|
yield format_sse({'type': 'keepalive'})
|
|
last_keepalive = now
|
|
|
|
response = Response(generate(), mimetype='text/event-stream')
|
|
response.headers['Cache-Control'] = 'no-cache'
|
|
response.headers['X-Accel-Buffering'] = 'no'
|
|
return response
|