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,243 @@
|
||||
"""
|
||||
Attribution for CONVENTIONAL channels — P25 conventional (node-26#24) and
|
||||
analog NBFM (node-26#20) share this one path.
|
||||
|
||||
metadata_watcher labels audio with whatever the OP25 console says was talking.
|
||||
On a trunked system that is a talkgroup from a control-channel grant. On a
|
||||
conventional channel there is no control channel, so:
|
||||
|
||||
* P25 conventional: link control in each LDU1 usually carries the talkgroup
|
||||
and unit id, and our tk_drb_conv module adds `voice` (frames arriving) and
|
||||
encryption. The talkgroup is often the P25 default (1) or "everyone"
|
||||
(0xFFFF) and identical on every channel, which says nothing about WHICH
|
||||
channel was keyed, and sometimes it is missing altogether.
|
||||
* Analog: OP25 reports the channel with tgid None and nothing else — the
|
||||
console never says anyone is talking.
|
||||
|
||||
So a conventional channel gets a stable SYNTHETIC talkgroup id derived from its
|
||||
frequency (channel_tgid), used whenever link control gives nothing that
|
||||
distinguishes the channel. A real, distinct link-control talkgroup is still
|
||||
used when present. normalize() rewrites the console update so the rest of the
|
||||
watcher sees ordinary "talkgroup X is active" rows and grants and needs no
|
||||
conventional special cases, except one: with exactly ONE conventional channel
|
||||
configured, every sound OP25 puts on drb_sink came from that channel, so audio
|
||||
the console said nothing about is attributed to it (sole_channel) instead of
|
||||
being discarded as orphan audio. With two channels there is no such certainty
|
||||
and orphan audio is still discarded (DEFERRED.md, metadata_watcher row).
|
||||
|
||||
Encrypted conventional traffic is never a talker: the decoder mutes it, so no
|
||||
audio follows, and letting it compete for attribution could mislabel a clear
|
||||
call on the other channel. It is reported instead (encryption_report).
|
||||
"""
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
# P25 talkgroups that identify no particular group: TIA-102 default (1) and
|
||||
# "everyone" (0xFFFF). 0 is "none" and already rejected by _as_int.
|
||||
NON_DISTINCT_TGIDS = frozenset({1, 0xFFFF})
|
||||
|
||||
# Synthetic ids are frequency in 100 Hz units: 460.400 MHz -> 4_604_000. Any
|
||||
# RTL-SDR frequency (>= 24 MHz) gives >= 240_000, clear of the 16-bit P25
|
||||
# talkgroup space, and 6.25 kHz channel steps stay distinct. C2 resolves the
|
||||
# same number back to the channel name (server talkgroups.name_from_system).
|
||||
SYNTHETIC_TGID_MIN = 240_000
|
||||
|
||||
# How long a channel's last real talkgroup is remembered for a transmission
|
||||
# whose link control hasn't decoded (yet). Covers the back-and-forth of one
|
||||
# conversation; past it, an undecoded call is labelled with the channel.
|
||||
REMEMBER_TGID_SECONDS = 30.0
|
||||
|
||||
|
||||
def channel_tgid(freq: Any) -> Optional[int]:
|
||||
"""Stable synthetic talkgroup id for a conventional channel frequency (Hz or MHz)."""
|
||||
try:
|
||||
f = float(freq)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f <= 0:
|
||||
return None
|
||||
hz = f * 1_000_000 if f < 1_000_000 else f
|
||||
return int(round(hz / 100))
|
||||
|
||||
|
||||
def _as_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return n if n > 0 else None
|
||||
|
||||
|
||||
def _truthy(value: Any) -> bool:
|
||||
return bool(value) and value not in ("0", "false", "False")
|
||||
|
||||
|
||||
ALGID_NAMES = {
|
||||
0x81: "DES-OFB", 0x83: "Triple DES", 0x84: "AES-256", 0x85: "AES-128",
|
||||
0x9F: "DES-XL", 0xA0: "DVI-XL", 0xA1: "DVP-XL", 0xAA: "ADP (RC4)",
|
||||
}
|
||||
|
||||
|
||||
def algid_name(algid: Any) -> str:
|
||||
a = _as_int(algid)
|
||||
if a is None:
|
||||
return "unknown algorithm"
|
||||
return f"{ALGID_NAMES.get(a, 'algorithm')} (ALGID 0x{a:02X})"
|
||||
|
||||
|
||||
class ConventionalChannels:
|
||||
"""Per-poll normalizer plus what it has learned about the channels."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# msgqid -> latest raw conventional row. channel_update is not on every
|
||||
# poll, so this outlives a single update.
|
||||
self._rows: Dict[str, Dict[str, Any]] = {}
|
||||
self._known = False # have we seen a channel_update at all
|
||||
# synthetic channel id -> the talkgroup that channel last carried. A
|
||||
# transmission's header frame sets `voice` ~180 ms before its first
|
||||
# link control names the talkgroup; without this, every such poll
|
||||
# would label the call with the synthetic id and the real talkgroup
|
||||
# a poll later would force a bogus split (drb-audio-review, #24).
|
||||
self._last_identity: Dict[int, Tuple[int, float]] = {}
|
||||
self._clock: Callable[[], float] = time.time
|
||||
|
||||
# -- identity --------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _name(row: Dict[str, Any]) -> str:
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name and not name.startswith("["):
|
||||
return name
|
||||
f = row.get("freq")
|
||||
try:
|
||||
return f"{float(f) / 1e6:.4f} MHz"
|
||||
except (TypeError, ValueError):
|
||||
return "Conventional"
|
||||
|
||||
def identity(self, row: Dict[str, Any], lc_tgid: Optional[int]) -> Optional[int]:
|
||||
"""Distinctive link-control talkgroup; else (no link control decoded
|
||||
yet) what this channel last carried; else the channel's synthetic id."""
|
||||
synthetic = channel_tgid(row.get("freq"))
|
||||
now = self._clock()
|
||||
if lc_tgid is not None and lc_tgid not in NON_DISTINCT_TGIDS:
|
||||
if synthetic is not None:
|
||||
self._last_identity[synthetic] = (lc_tgid, now)
|
||||
return lc_tgid
|
||||
remembered = self._last_identity.get(synthetic) if synthetic is not None else None
|
||||
if lc_tgid is None and remembered and now - remembered[1] <= REMEMBER_TGID_SECONDS:
|
||||
self._last_identity[synthetic] = (remembered[0], now) # still the same conversation
|
||||
return remembered[0]
|
||||
if synthetic is not None and lc_tgid is not None:
|
||||
self._last_identity[synthetic] = (synthetic, now) # explicit default TG: channel identity
|
||||
return synthetic
|
||||
|
||||
# -- normalization ---------------------------------------------------
|
||||
|
||||
def normalize(self, update: Any) -> Any:
|
||||
"""Rewrite conventional rows and grants in place; trunked ones untouched."""
|
||||
if update.channels:
|
||||
self._known = True
|
||||
self._rows = {}
|
||||
for i, row in enumerate(update.channels):
|
||||
if not _truthy(row.get("conventional")):
|
||||
continue
|
||||
key = str(row.get("msgqid", i))
|
||||
self._rows[key] = row
|
||||
update.channels[i] = self._normalize_row(row)
|
||||
|
||||
for i, entry in enumerate(update.call_log):
|
||||
row = self._rows.get(str(entry.get("rcvr")))
|
||||
if row is None:
|
||||
continue
|
||||
lc = _as_int(entry.get("tgid"))
|
||||
tgid = self.identity(row, lc)
|
||||
fixed = dict(entry)
|
||||
fixed["lc_tgid"] = lc
|
||||
fixed["tgid"] = tgid
|
||||
fixed["tgtag"] = self._label(row, lc, entry.get("tgtag"))
|
||||
fixed["freq"] = entry.get("freq") or row.get("freq")
|
||||
update.call_log[i] = fixed
|
||||
return update
|
||||
|
||||
def _label(self, row: Dict[str, Any], lc: Optional[int], tag: Any) -> str:
|
||||
name = self._name(row)
|
||||
tag = str(tag or "").strip()
|
||||
if lc is not None and lc not in NON_DISTINCT_TGIDS and tag and tag not in ("Conventional", name):
|
||||
return tag
|
||||
return name
|
||||
|
||||
def _normalize_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
lc = _as_int(row.get("tgid"))
|
||||
encrypted = _truthy(row.get("encrypted"))
|
||||
# tgid is only ever set while a call is up (tk_p25 / tk_drb_conv clear
|
||||
# it on TDU and sync timeout); `voice` is tk_drb_conv's own signal.
|
||||
active = (lc is not None or _truthy(row.get("voice"))) and not encrypted
|
||||
out = dict(row)
|
||||
out["lc_tgid"] = lc
|
||||
out["tgid"] = self.identity(row, lc) if active else None
|
||||
out["tag"] = self._label(row, lc, row.get("tag"))
|
||||
out["drb_conventional"] = True
|
||||
out["drb_active"] = active
|
||||
return out
|
||||
|
||||
# -- what the watcher asks -------------------------------------------
|
||||
|
||||
def same_channel_relabel(self, current_tgid: Optional[int], current_freq: Any,
|
||||
new_tgid: Optional[int], new_freq: Any) -> Optional[str]:
|
||||
"""Two labels for ONE conventional channel are not a talkgroup change.
|
||||
|
||||
"adopt": the open segment holds the channel's synthetic id and the
|
||||
console now names the real talkgroup — relabel, don't split.
|
||||
"keep": the segment holds a real talkgroup and the console offers the
|
||||
synthetic id (link control not decoded on this frame) — ignore.
|
||||
None: not the same conventional channel; the normal split rules apply.
|
||||
"""
|
||||
if current_tgid is None or new_tgid is None or current_tgid == new_tgid:
|
||||
return None
|
||||
synthetic = channel_tgid(new_freq)
|
||||
if synthetic is None or channel_tgid(current_freq) != synthetic:
|
||||
return None
|
||||
if not any(channel_tgid(r.get("freq")) == synthetic for r in self._rows.values()):
|
||||
return None # trunked: freq says nothing about identity
|
||||
if current_tgid == synthetic:
|
||||
return "adopt"
|
||||
if new_tgid == synthetic:
|
||||
return "keep"
|
||||
return None
|
||||
|
||||
def sole_channel(self) -> Optional[Dict[str, Any]]:
|
||||
"""The one conventional channel this node listens to, if exactly one
|
||||
(and it is not encrypted). None on any trunked or multi-channel setup."""
|
||||
if len(self._rows) != 1:
|
||||
return None
|
||||
row = next(iter(self._rows.values()))
|
||||
tgid = channel_tgid(row.get("freq"))
|
||||
if tgid is None:
|
||||
return None
|
||||
if _truthy(row.get("encrypted")):
|
||||
return None
|
||||
return {"tgid": tgid, "name": self._name(row), "freq": row.get("freq")}
|
||||
|
||||
def encryption_report(self) -> List[Dict[str, Any]]:
|
||||
"""Per conventional channel: is it (or was its last call) encrypted."""
|
||||
out = []
|
||||
for row in self._rows.values():
|
||||
enc_calls = _as_int(row.get("enc_calls")) or 0
|
||||
clear_calls = _as_int(row.get("clear_calls")) or 0
|
||||
now_enc = _truthy(row.get("encrypted"))
|
||||
last_enc = row.get("last_enc_time") or 0
|
||||
last_clear = row.get("last_clear_time") or 0
|
||||
out.append({
|
||||
"name": self._name(row),
|
||||
"freq": row.get("freq"),
|
||||
"nac": _as_int(row.get("nac")),
|
||||
"encrypted_now": now_enc,
|
||||
"enc_calls": enc_calls,
|
||||
"clear_calls": clear_calls,
|
||||
# The channel "is encrypted" when its most recent call was.
|
||||
"encrypted": now_enc or (enc_calls > 0 and float(last_enc) >= float(last_clear)),
|
||||
"algid": _as_int(row.get("algid")) or _as_int(row.get("last_enc_algid")),
|
||||
"keyid": _as_int(row.get("keyid")) or _as_int(row.get("last_enc_keyid")),
|
||||
"voice": _truthy(row.get("voice")),
|
||||
})
|
||||
return out
|
||||
Reference in New Issue
Block a user