From 3b205db32935e8796303faef476de3d20fc51a3d Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 7 Feb 2026 12:41:15 +0000 Subject: [PATCH] Fix DMR decoder signal activity and parsing for dsd-fme compatibility The DSD stderr parser had regex ordering bugs that swallowed voice and call events as bare slot events, and only matched classic dsd output format (not dsd-fme). Unmatched lines were silently dropped, leaving the signal activity panel with nothing to display. - Reorder regex checks: TG/Src before voice before slot - Support dsd-fme comma-separated format (TG: x, Src: y) - Make bare slot regex strict (only standalone "Slot N" lines) - Forward unmatched DSD lines as raw events for diagnostics - Add LISTENING state to signal activity panel for raw output - Show raw decoder output text below synthesizer canvas - Fix test mocks for find_dsd() tuple return value Co-Authored-By: Claude Opus 4.6 --- routes/dmr.py | 83 ++++++++++++++++++++++++++++-------------- static/js/modes/dmr.js | 17 ++++++++- templates/index.html | 1 + tests/test_dmr.py | 40 +++++++++++++++++--- 4 files changed, 108 insertions(+), 33 deletions(-) diff --git a/routes/dmr.py b/routes/dmr.py index 210c848..e481e38 100644 --- a/routes/dmr.py +++ b/routes/dmr.py @@ -87,57 +87,86 @@ def find_rtl_fm() -> str | None: def parse_dsd_output(line: str) -> dict | None: - """Parse a line of DSD stderr output into a structured event.""" + """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') + # 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': datetime.now().strftime('%H:%M:%S'), + 'timestamp': ts, } - # Talkgroup and Source: "TG: 12345 Src: 67890" - tg_match = re.match(r'.*TG:\s*(\d+)\s+Src:\s*(\d+)', line) + # 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" + tg_match = re.search( + r'(?:TG|Talkgroup)[:\s]+(\d+)[,\s]+(?:Src|Source)[:\s]+(\d+)', line, re.IGNORECASE + ) if tg_match: - return { + result = { 'type': 'call', 'talkgroup': int(tg_match.group(1)), 'source_id': int(tg_match.group(2)), - 'timestamp': datetime.now().strftime('%H:%M:%S'), + '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 - # Slot info: "Slot 1" or "Slot 2" - slot_match = re.match(r'.*Slot\s*(\d+)', line) - if slot_match: - return { - 'type': 'slot', - 'slot': int(slot_match.group(1)), - 'timestamp': datetime.now().strftime('%H:%M:%S'), - } - - # DMR voice frame - if 'Voice' in line or 'voice' in line: - return { - 'type': 'voice', - 'detail': line, - 'timestamp': datetime.now().strftime('%H:%M:%S'), - } - - # P25 NAC (Network Access Code) - nac_match = re.match(r'.*NAC:\s*(\w+)', line) + # 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': datetime.now().strftime('%H:%M:%S'), + 'timestamp': ts, } - return None + # 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, + } def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen): diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js index a665470..511210b 100644 --- a/static/js/modes/dmr.js +++ b/static/js/modes/dmr.js @@ -146,6 +146,11 @@ function handleDmrMessage(msg) { if (mainCountEl) mainCountEl.textContent = dmrCallCount; // Update current call display + const slotInfo = msg.slot != null ? ` +
+ Slot + ${msg.slot} +
` : ''; const callEl = document.getElementById('dmrCurrentCall'); if (callEl) { callEl.innerHTML = ` @@ -156,7 +161,7 @@ function handleDmrMessage(msg) {
Source ID ${msg.source_id} -
+ ${slotInfo}
Time ${msg.timestamp} @@ -176,6 +181,10 @@ function handleDmrMessage(msg) { } else if (msg.type === 'slot') { // Update slot info in current call + } else if (msg.type === 'raw') { + // Raw DSD output — update last line display for diagnostics + const rawEl = document.getElementById('dmrRawOutput'); + if (rawEl) rawEl.textContent = msg.text || ''; } else if (msg.type === 'status') { const statusEl = document.getElementById('dmrStatus'); if (statusEl) { @@ -399,6 +408,10 @@ function dmrSynthPulse(type) { 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 @@ -412,6 +425,7 @@ function updateDmrSynthStatus() { const labels = { stopped: 'STOPPED', idle: 'IDLE', + raw: 'LISTENING', sync: 'SYNC', call: 'CALL', voice: 'VOICE' @@ -419,6 +433,7 @@ function updateDmrSynthStatus() { const colors = { stopped: 'var(--text-muted)', idle: 'var(--text-muted)', + raw: '#607d8b', sync: '#00e5ff', call: '#4caf50', voice: '#ff9800' diff --git a/templates/index.html b/templates/index.html index 661dc9c..6d51262 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1623,6 +1623,7 @@ IDLE
+
diff --git a/tests/test_dmr.py b/tests/test_dmr.py index f389cd5..953d661 100644 --- a/tests/test_dmr.py +++ b/tests/test_dmr.py @@ -57,6 +57,33 @@ def test_parse_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_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 @@ -64,8 +91,11 @@ def test_parse_empty_line(): def test_parse_unrecognized(): - """Unrecognized lines should return None.""" - assert parse_dsd_output('some random text') is None + """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' # ============================================ @@ -100,7 +130,7 @@ def test_dmr_status(auth_client): 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): + with patch('routes.dmr.find_dsd', return_value=(None, False)): resp = auth_client.post('/dmr/start', json={ 'frequency': 462.5625, 'protocol': 'auto', @@ -112,7 +142,7 @@ def test_dmr_start_no_dsd(auth_client): 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'), \ + 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, @@ -122,7 +152,7 @@ def test_dmr_start_no_rtl_fm(auth_client): def test_dmr_start_invalid_protocol(auth_client): """Start should reject invalid protocol.""" - with patch('routes.dmr.find_dsd', return_value='/usr/bin/dsd'), \ + 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,