P25 conventional decode mode + conventional-channel attribution (node-26#24, #20)
op25-container
- generate-config takes p25_mode "conventional" + conv_channels [{frequency,
name, nac}]. It builds fixed fsk4 channels (up to 2 on one non-tunable SDR,
DC spike kept off the channels) with no control channel to hunt. Two
channels send audio to UDP 23456/23457, and audio.py -2 + amplify(2) keeps
levels.
- tk_drb_conv.py (a tk_p25.rx_ctl subclass, copied into OP25 apps) reports
voice / NAC / encryption (LC service options + LDU2 ESS algid/keyid) for
conventional channels and applies the per-channel NAC filter, which stock
tk_p25 does not. Analog uses it too.
- Trunked P25 config is unchanged when p25_mode is absent.
drb-edge-node
- radio_config: system kind from type + config.mode (missing = trunked).
- conventional.py normalizes the console: a distinctive LC TGID, else the
channel's last TGID (30 s), else a synthetic per-channel id (freq/100 Hz).
Encrypted rows are never talkers. With exactly one conventional channel,
orphan audio is attributed to it at close, which fixes analog recording
(#20). The synthetic id and the real TGID on one channel never split.
- Health/dashboard: P25 conventional listen tab (/api/listen p25conv), and
conventional decode + encryption checks. The checkin carries radio_channels.
- Tests: edge 296 pass; op25-container 18 (new CI job). Reviewed by
drb-audio-review (findings fixed; simultaneous two-channel mixdown filed
as #26) and drb-qa-review.
Not verified on SDR hardware.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1e1dab66b1
commit
d07d312e92
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
tk_drb_conv — DRB's trunking module for P25 CONVENTIONAL channels (node-26#24).
|
||||
|
||||
Copied into OP25's apps directory by the Dockerfile and named as the trunking
|
||||
module by op25-container's conventional config, so multi_rx imports it in place
|
||||
of tk_p25. It is tk_p25.rx_ctl with the conventional-channel handling replaced;
|
||||
trunked receivers (none in a conventional config) still go to the base class.
|
||||
|
||||
Why not stock tk_p25: for an FDMA conventional channel it reports the talkgroup
|
||||
and unit id from LDU1 link control, but
|
||||
|
||||
* never reports encryption — the encrypted/algid/keyid JSON OP25 sends after
|
||||
every LDU2 carries no grpaddr, and tk_p25 only records `encrypted` when
|
||||
grpaddr != 0 — so an encrypted channel looks exactly like a quiet one;
|
||||
* never reports the NAC it is hearing, and has no per-channel NAC filter;
|
||||
* has no "voice frames are arriving" signal of its own, so a transmission
|
||||
whose link control is unreadable is invisible to the console.
|
||||
|
||||
All three matter to the edge node: it attributes audio to a channel from what
|
||||
this module reports, and must tell the owner when a channel is encrypted rather
|
||||
than silently recording nothing.
|
||||
|
||||
Each conventional channel row in `channel_update` keeps every key tk_p25 sends
|
||||
(freq, tgid, srcaddr, tag, encrypted, conventional, ...) and adds:
|
||||
|
||||
voice True while voice frames are arriving (HDU/LDU1/LDU2 seen,
|
||||
no TDU or sync timeout since)
|
||||
nac last NAC heard on the channel (int) or None
|
||||
nac_filter the configured NAC filter (int) or None
|
||||
algid, keyid ALGID/key id of the current call (None when clear/idle)
|
||||
enc_calls, clear_calls calls heard since start, by encryption
|
||||
last_enc_algid, last_enc_keyid, last_enc_time, last_clear_time
|
||||
|
||||
Message types handled (lower 16 bits of msg.type(), P25 protocol only):
|
||||
0 HDU -> voice start
|
||||
3, 15 TDU / TDULC -> transmission end
|
||||
19 LDU1 link control -> tgid, srcaddr, service options (E bit)
|
||||
-3 LDU2 ESS JSON -> encrypted / algid / keyid
|
||||
-1 sync timeout -> transmission end
|
||||
"""
|
||||
import ctypes
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import tk_p25
|
||||
from helper_funcs import from_dict, get_ordinals
|
||||
from log_ts import log_ts
|
||||
|
||||
ALGID_CLEAR = 0x80
|
||||
SVCOPT_ENCRYPTED = 0x40
|
||||
LCW_PROTECTED = 0x80
|
||||
LCO_GROUP_VOICE = 0x00
|
||||
DUID_HDU, DUID_TDU, DUID_TDULC, DUID_LCW = 0, 3, 15, 19
|
||||
M_TIMEOUT, M_JSON_DATA = -1, -3
|
||||
|
||||
|
||||
def _parse_nac(value):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
nac = int(value, 16) if isinstance(value, str) else int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return nac if 0 < nac <= 0xFFF else None
|
||||
|
||||
|
||||
class rx_ctl(tk_p25.rx_ctl):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.drb_conv = {} # msgq_id -> state dict
|
||||
|
||||
def add_receiver(self, msgq_id, config, meta_q=None, freq=0):
|
||||
super().add_receiver(msgq_id, config, meta_q=meta_q, freq=freq)
|
||||
rcvr = self.receivers.get(msgq_id)
|
||||
if rcvr is not None and rcvr.get('rx_rcvr') is not None:
|
||||
return # trunked receiver: base class owns it
|
||||
self.drb_conv[msgq_id] = {
|
||||
'name': from_dict(config, 'name', str(msgq_id)),
|
||||
'freq': freq,
|
||||
'nac_filter': _parse_nac(config.get('nac')),
|
||||
'nac': None,
|
||||
'voice': False,
|
||||
'tgid': None,
|
||||
'srcaddr': 0,
|
||||
'svc_encrypted': False,
|
||||
'ess_encrypted': False,
|
||||
'algid': None,
|
||||
'keyid': None,
|
||||
'call_start': None,
|
||||
'call_encrypted': False,
|
||||
'logged_tgid': None,
|
||||
'prev_type': None,
|
||||
'enc_calls': 0,
|
||||
'clear_calls': 0,
|
||||
'last_enc_algid': None,
|
||||
'last_enc_keyid': None,
|
||||
'last_enc_time': None,
|
||||
'last_clear_time': None,
|
||||
'last_voice_time': None,
|
||||
}
|
||||
|
||||
def post_init(self):
|
||||
super().post_init()
|
||||
for msgq_id, cs in self.drb_conv.items():
|
||||
if cs['nac_filter'] is not None and self.fa_ctrl is not None:
|
||||
self.fa_ctrl({'tuner': msgq_id, 'cmd': 'set_nac', 'nac': cs['nac_filter']})
|
||||
sys.stderr.write("%s [%d] conventional NAC filter 0x%03x\n" % (log_ts.get(), msgq_id, cs['nac_filter']))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _encrypted(self, cs):
|
||||
return cs['svc_encrypted'] or cs['ess_encrypted']
|
||||
|
||||
def _voice_start(self, cs, now):
|
||||
if not cs['voice']:
|
||||
cs['voice'] = True
|
||||
cs['call_start'] = now
|
||||
cs['call_encrypted'] = False
|
||||
cs['logged_tgid'] = None
|
||||
cs['last_voice_time'] = now
|
||||
|
||||
def _voice_end(self, cs, now):
|
||||
if cs['voice']:
|
||||
if cs['call_encrypted']:
|
||||
cs['enc_calls'] += 1
|
||||
cs['last_enc_time'] = now
|
||||
else:
|
||||
cs['clear_calls'] += 1
|
||||
cs['last_clear_time'] = now
|
||||
cs['voice'] = False
|
||||
cs['tgid'] = None
|
||||
cs['srcaddr'] = 0
|
||||
cs['svc_encrypted'] = False
|
||||
cs['ess_encrypted'] = False
|
||||
cs['algid'] = None
|
||||
cs['keyid'] = None
|
||||
cs['call_start'] = None
|
||||
cs['logged_tgid'] = None
|
||||
|
||||
def _note_encryption(self, cs, now):
|
||||
if self._encrypted(cs):
|
||||
cs['call_encrypted'] = True
|
||||
if cs['algid'] is not None:
|
||||
cs['last_enc_algid'] = cs['algid']
|
||||
cs['last_enc_keyid'] = cs['keyid']
|
||||
cs['last_enc_time'] = now
|
||||
|
||||
def _maybe_log_call(self, msgq_id, cs):
|
||||
# One call_log entry per (call, talkgroup), and none for an encrypted
|
||||
# call: it will produce no audio, so a "grant" would only mislead the
|
||||
# edge node's attribution.
|
||||
if cs['tgid'] is None or self._encrypted(cs) or cs['logged_tgid'] == cs['tgid']:
|
||||
return
|
||||
cs['logged_tgid'] = cs['tgid']
|
||||
try:
|
||||
self.log_call(0, msgq_id, cs['freq'], 0, 0, cs['tgid'], cs['name'], cs['srcaddr'], '')
|
||||
except Exception as e: # older/newer tk_p25 log_call signature
|
||||
sys.stderr.write("%s [%d] conventional call_log failed: %s\n" % (log_ts.get(), msgq_id, e))
|
||||
|
||||
def _process_conv(self, msgq_id, cs, m_type, msg, now):
|
||||
if m_type == M_TIMEOUT or m_type in (DUID_TDU, DUID_TDULC):
|
||||
self._voice_end(cs, now)
|
||||
elif m_type == DUID_HDU:
|
||||
s = msg.to_string()
|
||||
cs['nac'] = get_ordinals(s[0:2]) if len(s) >= 2 else cs['nac']
|
||||
self._voice_start(cs, now)
|
||||
elif m_type == DUID_LCW:
|
||||
s = msg.to_string()
|
||||
if len(s) < 11:
|
||||
return
|
||||
cs['nac'] = get_ordinals(s[0:2])
|
||||
if cs['prev_type'] in (DUID_TDU, DUID_TDULC):
|
||||
return # TDULC's link control follows its TDU: the call is over
|
||||
lcw = s[2:]
|
||||
self._voice_start(cs, now)
|
||||
pb_sf_lco = get_ordinals(lcw[0:1])
|
||||
if pb_sf_lco & LCW_PROTECTED:
|
||||
# Link control itself is encrypted — the call certainly is.
|
||||
cs['svc_encrypted'] = True
|
||||
elif (pb_sf_lco & 0x3f) == LCO_GROUP_VOICE:
|
||||
svcopts = get_ordinals(lcw[2:3])
|
||||
ga = get_ordinals(lcw[4:6])
|
||||
sa = get_ordinals(lcw[6:9])
|
||||
cs['svc_encrypted'] = bool(svcopts & SVCOPT_ENCRYPTED)
|
||||
if ga:
|
||||
cs['tgid'] = ga
|
||||
if sa:
|
||||
cs['srcaddr'] = sa
|
||||
self._note_encryption(cs, now)
|
||||
self._maybe_log_call(msgq_id, cs)
|
||||
elif m_type == M_JSON_DATA:
|
||||
try:
|
||||
js = json.loads(msg.to_string())
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if 'encrypted' in js:
|
||||
self._voice_start(cs, now)
|
||||
algid = from_dict(js, 'algid', None)
|
||||
cs['ess_encrypted'] = bool(js.get('encrypted')) and algid != ALGID_CLEAR
|
||||
cs['algid'] = algid if cs['ess_encrypted'] else None
|
||||
cs['keyid'] = from_dict(js, 'keyid', None) if cs['ess_encrypted'] else None
|
||||
self._note_encryption(cs, now)
|
||||
grpaddr = from_dict(js, 'grpaddr', 0)
|
||||
if grpaddr:
|
||||
cs['tgid'] = grpaddr
|
||||
cs['srcaddr'] = from_dict(js, 'srcaddr', 0) or cs['srcaddr']
|
||||
self._maybe_log_call(msgq_id, cs)
|
||||
|
||||
def process_qmsg(self, msg):
|
||||
m_proto = ctypes.c_int16(msg.type() >> 16).value
|
||||
m_rxid = int(msg.arg1()) >> 1
|
||||
if m_proto != 0 or m_rxid not in self.drb_conv:
|
||||
return super().process_qmsg(msg)
|
||||
m_type = ctypes.c_int16(msg.type() & 0xffff).value
|
||||
cs = self.drb_conv[m_rxid]
|
||||
try:
|
||||
self._process_conv(m_rxid, cs, m_type, msg, time.time())
|
||||
except Exception as e: # never take multi_rx down over a bad frame
|
||||
sys.stderr.write("%s [%d] conventional message type %d failed: %s\n" % (log_ts.get(), m_rxid, m_type, e))
|
||||
cs['prev_type'] = m_type
|
||||
|
||||
def get_chan_status(self):
|
||||
d = json.loads(super().get_chan_status())
|
||||
ids = list(d.get('channels') or [])
|
||||
for msgq_id, cs in self.drb_conv.items():
|
||||
key = str(msgq_id)
|
||||
row = d.get(key) or {}
|
||||
row.update({
|
||||
'freq': cs['freq'],
|
||||
'name': cs['name'],
|
||||
'system': cs['name'],
|
||||
'tdma': 0,
|
||||
'tgid': cs['tgid'],
|
||||
'tag': cs['name'],
|
||||
'srcaddr': cs['srcaddr'],
|
||||
'srctag': '',
|
||||
'svcopts': 0,
|
||||
'encrypted': 1 if self._encrypted(cs) else 0,
|
||||
'emergency': 0,
|
||||
'hold_tgid': 0,
|
||||
'mode': None,
|
||||
'stream': '',
|
||||
'msgqid': msgq_id,
|
||||
'conventional': True,
|
||||
'voice': cs['voice'],
|
||||
'nac': cs['nac'],
|
||||
'nac_filter': cs['nac_filter'],
|
||||
'algid': cs['algid'],
|
||||
'keyid': cs['keyid'],
|
||||
'enc_calls': cs['enc_calls'],
|
||||
'clear_calls': cs['clear_calls'],
|
||||
'last_enc_algid': cs['last_enc_algid'],
|
||||
'last_enc_keyid': cs['last_enc_keyid'],
|
||||
'last_enc_time': cs['last_enc_time'],
|
||||
'last_clear_time': cs['last_clear_time'],
|
||||
'last_voice_time': cs['last_voice_time'],
|
||||
})
|
||||
d[key] = row
|
||||
if key not in ids:
|
||||
ids.append(key)
|
||||
d['channels'] = ids
|
||||
return json.dumps(d)
|
||||
Reference in New Issue
Block a user