mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -146,6 +146,11 @@ function handleDmrMessage(msg) {
|
||||
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
||||
|
||||
// Update current call display
|
||||
const slotInfo = msg.slot != null ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Slot</span>
|
||||
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
|
||||
</div>` : '';
|
||||
const callEl = document.getElementById('dmrCurrentCall');
|
||||
if (callEl) {
|
||||
callEl.innerHTML = `
|
||||
@@ -156,7 +161,7 @@ function handleDmrMessage(msg) {
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Source ID</span>
|
||||
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
||||
</div>
|
||||
</div>${slotInfo}
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-muted);">Time</span>
|
||||
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
||||
@@ -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'
|
||||
|
||||
@@ -1623,6 +1623,7 @@
|
||||
<span id="dmrSynthStatus" style="color: var(--text-muted); font-size: 9px; font-family: var(--font-mono);">IDLE</span>
|
||||
</div>
|
||||
<canvas id="dmrSynthCanvas" style="width: 100%; height: 70px; background: rgba(0,0,0,0.4); border-radius: 4px; display: block;"></canvas>
|
||||
<div id="dmrRawOutput" style="font-size: 9px; font-family: var(--font-mono); color: var(--text-muted); margin-top: 4px; height: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||
<!-- Call History Panel -->
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user