mirror of
https://github.com/smittix/intercept.git
synced 2026-07-09 01:58:13 -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:
+56
-27
@@ -87,57 +87,86 @@ def find_rtl_fm() -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def parse_dsd_output(line: str) -> dict | 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()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
ts = datetime.now().strftime('%H:%M:%S')
|
||||||
|
|
||||||
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
||||||
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
|
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
|
||||||
if sync_match:
|
if sync_match:
|
||||||
return {
|
return {
|
||||||
'type': 'sync',
|
'type': 'sync',
|
||||||
'protocol': sync_match.group(1).strip(),
|
'protocol': sync_match.group(1).strip(),
|
||||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
'timestamp': ts,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Talkgroup and Source: "TG: 12345 Src: 67890"
|
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
|
||||||
tg_match = re.match(r'.*TG:\s*(\d+)\s+Src:\s*(\d+)', line)
|
# 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:
|
if tg_match:
|
||||||
return {
|
result = {
|
||||||
'type': 'call',
|
'type': 'call',
|
||||||
'talkgroup': int(tg_match.group(1)),
|
'talkgroup': int(tg_match.group(1)),
|
||||||
'source_id': int(tg_match.group(2)),
|
'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"
|
# P25 NAC (Network Access Code) — check before voice/slot
|
||||||
slot_match = re.match(r'.*Slot\s*(\d+)', line)
|
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', 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)
|
|
||||||
if nac_match:
|
if nac_match:
|
||||||
return {
|
return {
|
||||||
'type': 'nac',
|
'type': 'nac',
|
||||||
'nac': nac_match.group(1),
|
'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):
|
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
|
||||||
|
|||||||
+16
-1
@@ -146,6 +146,11 @@ function handleDmrMessage(msg) {
|
|||||||
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
||||||
|
|
||||||
// Update current call display
|
// 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');
|
const callEl = document.getElementById('dmrCurrentCall');
|
||||||
if (callEl) {
|
if (callEl) {
|
||||||
callEl.innerHTML = `
|
callEl.innerHTML = `
|
||||||
@@ -156,7 +161,7 @@ function handleDmrMessage(msg) {
|
|||||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||||
<span style="color: var(--text-muted);">Source ID</span>
|
<span style="color: var(--text-muted);">Source ID</span>
|
||||||
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.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;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<span style="color: var(--text-muted);">Time</span>
|
<span style="color: var(--text-muted);">Time</span>
|
||||||
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
||||||
@@ -176,6 +181,10 @@ function handleDmrMessage(msg) {
|
|||||||
|
|
||||||
} else if (msg.type === 'slot') {
|
} else if (msg.type === 'slot') {
|
||||||
// Update slot info in current call
|
// 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') {
|
} else if (msg.type === 'status') {
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
@@ -399,6 +408,10 @@ function dmrSynthPulse(type) {
|
|||||||
dmrEventType = 'voice';
|
dmrEventType = 'voice';
|
||||||
} else if (type === 'slot' || type === 'nac') {
|
} else if (type === 'slot' || type === 'nac') {
|
||||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
|
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
|
// keepalive and status don't change visuals
|
||||||
|
|
||||||
@@ -412,6 +425,7 @@ function updateDmrSynthStatus() {
|
|||||||
const labels = {
|
const labels = {
|
||||||
stopped: 'STOPPED',
|
stopped: 'STOPPED',
|
||||||
idle: 'IDLE',
|
idle: 'IDLE',
|
||||||
|
raw: 'LISTENING',
|
||||||
sync: 'SYNC',
|
sync: 'SYNC',
|
||||||
call: 'CALL',
|
call: 'CALL',
|
||||||
voice: 'VOICE'
|
voice: 'VOICE'
|
||||||
@@ -419,6 +433,7 @@ function updateDmrSynthStatus() {
|
|||||||
const colors = {
|
const colors = {
|
||||||
stopped: 'var(--text-muted)',
|
stopped: 'var(--text-muted)',
|
||||||
idle: 'var(--text-muted)',
|
idle: 'var(--text-muted)',
|
||||||
|
raw: '#607d8b',
|
||||||
sync: '#00e5ff',
|
sync: '#00e5ff',
|
||||||
call: '#4caf50',
|
call: '#4caf50',
|
||||||
voice: '#ff9800'
|
voice: '#ff9800'
|
||||||
|
|||||||
@@ -1623,6 +1623,7 @@
|
|||||||
<span id="dmrSynthStatus" style="color: var(--text-muted); font-size: 9px; font-family: var(--font-mono);">IDLE</span>
|
<span id="dmrSynthStatus" style="color: var(--text-muted); font-size: 9px; font-family: var(--font-mono);">IDLE</span>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="dmrSynthCanvas" style="width: 100%; height: 70px; background: rgba(0,0,0,0.4); border-radius: 4px; display: block;"></canvas>
|
<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>
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||||
<!-- Call History Panel -->
|
<!-- Call History Panel -->
|
||||||
|
|||||||
+35
-5
@@ -57,6 +57,33 @@ def test_parse_nac():
|
|||||||
assert result['nac'] == '293'
|
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():
|
def test_parse_empty_line():
|
||||||
"""Empty lines should return None."""
|
"""Empty lines should return None."""
|
||||||
assert parse_dsd_output('') is None
|
assert parse_dsd_output('') is None
|
||||||
@@ -64,8 +91,11 @@ def test_parse_empty_line():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_unrecognized():
|
def test_parse_unrecognized():
|
||||||
"""Unrecognized lines should return None."""
|
"""Unrecognized lines should return raw event for diagnostics."""
|
||||||
assert parse_dsd_output('some random text') is None
|
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):
|
def test_dmr_start_no_dsd(auth_client):
|
||||||
"""Start should fail gracefully when dsd is not installed."""
|
"""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={
|
resp = auth_client.post('/dmr/start', json={
|
||||||
'frequency': 462.5625,
|
'frequency': 462.5625,
|
||||||
'protocol': 'auto',
|
'protocol': 'auto',
|
||||||
@@ -112,7 +142,7 @@ def test_dmr_start_no_dsd(auth_client):
|
|||||||
|
|
||||||
def test_dmr_start_no_rtl_fm(auth_client):
|
def test_dmr_start_no_rtl_fm(auth_client):
|
||||||
"""Start should fail when rtl_fm is missing."""
|
"""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):
|
patch('routes.dmr.find_rtl_fm', return_value=None):
|
||||||
resp = auth_client.post('/dmr/start', json={
|
resp = auth_client.post('/dmr/start', json={
|
||||||
'frequency': 462.5625,
|
'frequency': 462.5625,
|
||||||
@@ -122,7 +152,7 @@ def test_dmr_start_no_rtl_fm(auth_client):
|
|||||||
|
|
||||||
def test_dmr_start_invalid_protocol(auth_client):
|
def test_dmr_start_invalid_protocol(auth_client):
|
||||||
"""Start should reject invalid protocol."""
|
"""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'):
|
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'):
|
||||||
resp = auth_client.post('/dmr/start', json={
|
resp = auth_client.post('/dmr/start', json={
|
||||||
'frequency': 462.5625,
|
'frequency': 462.5625,
|
||||||
|
|||||||
Reference in New Issue
Block a user