From c0eda846448cfae4f4f918030e8589abb6bb6148 Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 7 Feb 2026 12:49:35 +0000 Subject: [PATCH] Fix signal activity panel dying after DSD startup banner The stream thread used a blocking readline() with no timeout, so once DSD finished outputting its startup banner there were no more events until actual signal activity. The frontend decayed to zero and appeared dead. If DSD crashed, the synthesizer state never transitioned to 'stopped' so there was no visual or textual indication of failure. - Use select() with 1s timeout on DSD stderr to avoid indefinite block - Send heartbeat events every 3s while decoder is alive but idle - Detect DSD crashes: capture exit code and remaining stderr, send as 'crashed' status with details and show notification to user - Frontend properly transitions synthesizer to 'stopped' on process death (was only happening on user-initiated stop) - Increase idle breathing amplitude so LISTENING state is clearly visible (0.12 +/- 0.06 vs old 0.05 +/- 0.035) - Release device reservation on crash, not just user stop Co-Authored-By: Claude Opus 4.6 --- routes/dmr.py | 94 ++++++++++++++++++++++++++++++------------ static/js/modes/dmr.js | 44 ++++++++++++++++---- 2 files changed, 103 insertions(+), 35 deletions(-) diff --git a/routes/dmr.py b/routes/dmr.py index 5ce9fcf..634bf06 100644 --- a/routes/dmr.py +++ b/routes/dmr.py @@ -5,6 +5,7 @@ from __future__ import annotations import os import queue import re +import select import shutil import subprocess import threading @@ -169,49 +170,88 @@ def parse_dsd_output(line: str) -> dict | None: } +_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle + + +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.""" + """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: - dmr_queue.put_nowait({'type': 'status', 'text': 'started'}) + _queue_put({'type': 'status', 'text': 'started'}) + last_heartbeat = time.time() while dmr_running: if dsd_process.poll() is not None: break - line = dsd_process.stderr.readline() - if not line: - if dsd_process.poll() is not None: - break - continue + # Wait up to 1s for data on stderr instead of blocking forever + ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0) - text = line.decode('utf-8', errors='replace').strip() - if not text: - continue + if ready: + line = dsd_process.stderr.readline() + if not line: + if dsd_process.poll() is not None: + break + continue - parsed = parse_dsd_output(text) - if parsed: - try: - dmr_queue.put_nowait(parsed) - except queue.Full: - try: - dmr_queue.get_nowait() - except queue.Empty: - pass - try: - dmr_queue.put_nowait(parsed) - except queue.Full: - pass + text = line.decode('utf-8', errors='replace').strip() + if not text: + continue + + 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: dmr_running = False - try: - dmr_queue.put_nowait({'type': 'status', 'text': 'stopped'}) - except queue.Full: - pass + # 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}") + _queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail}) logger.info("DSD stream thread stopped") diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js index bb02ac8..924609e 100644 --- a/static/js/modes/dmr.js +++ b/static/js/modes/dmr.js @@ -196,14 +196,42 @@ function handleDmrMessage(msg) { // Raw DSD output — update last line display for diagnostics const rawEl = document.getElementById('dmrRawOutput'); if (rawEl) rawEl.textContent = msg.text || ''; + } 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 (statusEl) { - statusEl.textContent = msg.text === 'started' ? 'DECODING' : 'IDLE'; - } - if (msg.text === 'stopped') { + if (msg.text === 'started') { + if (statusEl) statusEl.textContent = 'DECODING'; + } else if (msg.text === 'crashed') { isDmrRunning = false; updateDmrUI(); + dmrEventType = 'stopped'; + dmrActivityTarget = 0; + updateDmrSynthStatus(); + if (statusEl) statusEl.textContent = 'CRASHED'; + if (typeof releaseDevice === 'function') releaseDevice('dmr'); + const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`; + if (typeof showNotification === 'function') { + showNotification('DMR Error', detail); + } + const rawEl = document.getElementById('dmrRawOutput'); + if (rawEl) rawEl.textContent = detail; + } else if (msg.text === 'stopped') { + isDmrRunning = false; + updateDmrUI(); + dmrEventType = 'stopped'; + dmrActivityTarget = 0; + updateDmrSynthStatus(); + if (statusEl) statusEl.textContent = 'STOPPED'; + if (typeof releaseDevice === 'function') releaseDevice('dmr'); } } } @@ -287,7 +315,7 @@ function drawDmrSynthesizer() { if (timeSinceEvent > 2000) { // No events for 2s — decay target toward idle dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE); - if (dmrActivityTarget < 0.05 && dmrEventType !== 'stopped') { + if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') { dmrEventType = 'idle'; updateDmrSynthStatus(); } @@ -300,9 +328,9 @@ function drawDmrSynthesizer() { let effectiveActivity = dmrActivityLevel; if (dmrEventType === 'stopped') { effectiveActivity = 0; - } else if (effectiveActivity < 0.05 && isDmrRunning) { - // Gentle idle breathing - effectiveActivity = 0.05 + Math.sin(now / 800) * 0.035; + } 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