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,