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
@@ -31,6 +31,10 @@ RUN ./install.sh -f
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# DRB's trunking module for P25 conventional channels (node-26#24): reports
|
||||
# encryption + NAC and applies the NAC filter, which stock tk_p25 does not.
|
||||
COPY op25_addons/tk_drb_conv.py /op25/op25/gr-op25_repeater/apps/tk_drb_conv.py
|
||||
|
||||
# Create the run_multi-rx_service.sh script
|
||||
COPY run_multi-rx_service.sh /op25/op25/gr-op25_repeater/apps/run_multi-rx_service.sh
|
||||
RUN chmod +x /op25/op25/gr-op25_repeater/apps/run_multi-rx_service.sh
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
P25 conventional OP25 config (node-26#24).
|
||||
|
||||
A conventional P25 channel is a fixed voice frequency with no control channel:
|
||||
radios key up on it directly, C4FM (fsk4), and identify themselves in the link
|
||||
control of each LDU1 (talkgroup + unit id) and the ESS of each LDU2
|
||||
(encryption algorithm + key id). Building a `tk_p25.py` trunking config for one
|
||||
— what "P25" always meant before — makes OP25 hunt forever for a control
|
||||
channel that does not exist, so nothing is ever decoded.
|
||||
|
||||
Layout this produces, per boatbod OP25 gr310 `p25_conventional_example.json`:
|
||||
|
||||
* one non-tunable device parked so every channel sits inside its passband;
|
||||
* one channel per frequency, `trunking_sysname: ""` (so the trunking module
|
||||
treats it as conventional), `demod_type: fsk4`;
|
||||
* trunking module `tk_drb_conv.py` with no chans — our thin subclass of
|
||||
tk_p25.rx_ctl that reports encryption and the NAC for conventional
|
||||
channels and applies the per-channel NAC filter (tk_p25 itself does
|
||||
neither for FDMA conventional);
|
||||
* channel N's audio to UDP 23456+N; with two channels audio.py runs in
|
||||
two-channel mode (left = first channel, right = second).
|
||||
|
||||
Pure: no file I/O, so it is unit-testable outside the container.
|
||||
"""
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from models import ChannelConfig, ConventionalChannel, DeviceConfig, TerminalConfig
|
||||
|
||||
# Two is what one audio.py instance can carry (its A and B sockets). A third
|
||||
# channel would need a second audio pipeline into PulseAudio.
|
||||
MAX_CONVENTIONAL_CHANNELS = 2
|
||||
|
||||
AUDIO_UDP_BASE_PORT = 23456
|
||||
|
||||
DEVICE_RATE = 1_920_000
|
||||
DEVICE_USABLE_BW_PCT = 0.85
|
||||
# Half the occupied bandwidth of a 12.5 kHz P25 channel.
|
||||
CHANNEL_HALF_BW_HZ = 6_250
|
||||
# Keep the RTL-SDR's DC spike off the channels. A single channel (or two very
|
||||
# close together) is offset from the centre; channels further apart than
|
||||
# 2x this already straddle the spike, so the centre goes between them.
|
||||
DC_SPIKE_OFFSET_HZ = 250_000
|
||||
DC_SPIKE_CLEARANCE_HZ = 50_000
|
||||
|
||||
CONVENTIONAL_TRUNKING_MODULE = "tk_drb_conv.py"
|
||||
|
||||
|
||||
def device_center(freqs: List[int]) -> int:
|
||||
lo, hi = min(freqs), max(freqs)
|
||||
mid = (lo + hi) // 2
|
||||
if (hi - lo) // 2 >= DC_SPIKE_CLEARANCE_HZ:
|
||||
return mid
|
||||
return mid + DC_SPIKE_OFFSET_HZ
|
||||
|
||||
|
||||
def validate_channels(channels: List[ConventionalChannel]) -> None:
|
||||
"""Raise ValueError with an operator-readable reason."""
|
||||
if not channels:
|
||||
raise ValueError("P25 conventional needs at least one channel frequency.")
|
||||
if len(channels) > MAX_CONVENTIONAL_CHANNELS:
|
||||
raise ValueError(
|
||||
f"P25 conventional supports up to {MAX_CONVENTIONAL_CHANNELS} channels on one SDR "
|
||||
f"(got {len(channels)})."
|
||||
)
|
||||
freqs = [c.frequency for c in channels]
|
||||
if len(set(freqs)) != len(freqs):
|
||||
raise ValueError("The same conventional frequency is listed twice.")
|
||||
for c in channels:
|
||||
if c.nac is not None and not (0 < c.nac <= 0xFFF):
|
||||
raise ValueError(f"NAC {c.nac:#x} on {c.frequency / 1e6:.5f} MHz is outside 0x001-0xFFF.")
|
||||
center = device_center(freqs)
|
||||
half = DEVICE_RATE * DEVICE_USABLE_BW_PCT / 2
|
||||
for f in freqs:
|
||||
if abs(f - center) + CHANNEL_HALF_BW_HZ > half:
|
||||
span = (max(freqs) - min(freqs)) / 1e6
|
||||
raise ValueError(
|
||||
f"These channels are {span:.3f} MHz apart — too far for one SDR "
|
||||
f"(max ~{(2 * (half - CHANNEL_HALF_BW_HZ)) / 1e6:.2f} MHz)."
|
||||
)
|
||||
|
||||
|
||||
def build_conventional_config(
|
||||
system_name: str,
|
||||
channels: List[ConventionalChannel],
|
||||
gains: str,
|
||||
ppm: float,
|
||||
) -> Tuple[Dict[str, Any], bool]:
|
||||
"""Return (OP25 config dict, two_channel_audio)."""
|
||||
validate_channels(channels)
|
||||
freqs = [c.frequency for c in channels]
|
||||
center = device_center(freqs)
|
||||
|
||||
chans = []
|
||||
for i, c in enumerate(channels):
|
||||
chans.append(ChannelConfig(
|
||||
name=c.name or f"{system_name} {c.frequency / 1e6:.4f}",
|
||||
trunking_sysname="",
|
||||
enable_analog="off",
|
||||
demod_type="fsk4",
|
||||
filter_type="rc",
|
||||
frequency=c.frequency,
|
||||
meta_stream_name="",
|
||||
destination=f"udp://127.0.0.1:{AUDIO_UDP_BASE_PORT + i}",
|
||||
nac=c.nac,
|
||||
crypt_behavior=2,
|
||||
))
|
||||
|
||||
device = DeviceConfig(
|
||||
gains=gains, ppm=ppm, tunable=False, frequency=center,
|
||||
rate=DEVICE_RATE, usable_bw_pct=DEVICE_USABLE_BW_PCT,
|
||||
)
|
||||
config = {
|
||||
"channels": [ch.model_dump() for ch in chans],
|
||||
"devices": [device.model_dump()],
|
||||
"trunking": {"module": CONVENTIONAL_TRUNKING_MODULE, "chans": []},
|
||||
"terminal": TerminalConfig().model_dump(),
|
||||
}
|
||||
return config, len(chans) > 1
|
||||
@@ -2,7 +2,26 @@ import os
|
||||
from internal.op25_liq_template import liquidsoap_config_template
|
||||
from models import IcecastConfig
|
||||
|
||||
def generate_liquid_script(config: IcecastConfig):
|
||||
# P25 conventional with two channels (node-26#24): OP25 sends channel A's
|
||||
# audio to UDP 23456 and channel B's to 23457, and audio.py -2 puts them on the
|
||||
# left and right of the stereo stream. Every output below is mean(input) — mono
|
||||
# — so a lone talker on one side would come out 6 dB down; doubling first keeps
|
||||
# the common case (one channel keyed) at the same level as single-channel mode.
|
||||
# Liquidsoap mixes in float, so the doubling cannot clip before the output.
|
||||
_TWO_CHANNEL_AUDIO_ARGS = " -2"
|
||||
_TWO_CHANNEL_MIX = "input = amplify(2.0, input) # two conventional channels, L=A R=B"
|
||||
|
||||
|
||||
def render_liquid_script(config: IcecastConfig, two_channel: bool = False) -> str:
|
||||
content = liquidsoap_config_template
|
||||
for key, value in config.model_dump().items():
|
||||
content = content.replace(f"${{{key}}}", str(value))
|
||||
content = content.replace("${audio_py_args}", _TWO_CHANNEL_AUDIO_ARGS if two_channel else "")
|
||||
content = content.replace("${two_channel_mix}", _TWO_CHANNEL_MIX if two_channel else "")
|
||||
return content
|
||||
|
||||
|
||||
def generate_liquid_script(config: IcecastConfig, two_channel: bool = False):
|
||||
"""
|
||||
Generates the "*.liq" file that's run by OP25 on startup.
|
||||
|
||||
@@ -13,13 +32,7 @@ def generate_liquid_script(config: IcecastConfig):
|
||||
Keys should match the variable names in the template (e.g., 'icecast_host').
|
||||
"""
|
||||
try:
|
||||
content = liquidsoap_config_template
|
||||
# Replace variables
|
||||
for key, value in config.model_dump().items():
|
||||
placeholder = f"${{{key}}}"
|
||||
# Ensure the value is converted to string for replacement
|
||||
content = content.replace(placeholder, str(value))
|
||||
print(f" - Replaced placeholder {placeholder}")
|
||||
content = render_liquid_script(config, two_channel)
|
||||
|
||||
# Write the processed content to the output path
|
||||
output_path = "/configs/op25.liq"
|
||||
|
||||
@@ -17,8 +17,9 @@ ICE_DESCRIPTION = "${icecast_description}"
|
||||
ICE_GENRE = "${icecast_genre}"
|
||||
# ==========================================================
|
||||
|
||||
input = mksafe(input.external(buffer=0.25, channels=2, samplerate=8000, restart_on_error=false, "./audio.py -x 2.5 -s"))
|
||||
input = mksafe(input.external(buffer=0.25, channels=2, samplerate=8000, restart_on_error=false, "./audio.py -x 2.5 -s${audio_py_args}"))
|
||||
# Consider increasing the buffer value on slow systems such as RPi3. e.g. buffer=0.25
|
||||
${two_channel_mix}
|
||||
|
||||
# Compression
|
||||
input = compress(input, attack = 2.0, gain = 0.0, knee = 13.0, ratio = 2.0, release = 12.3, threshold = -18.0)
|
||||
|
||||
@@ -49,12 +49,28 @@ class IcecastConfig(BaseModel):
|
||||
icecast_genre: Optional[str] = "Public Safety"
|
||||
|
||||
|
||||
class P25Mode(str, Enum):
|
||||
TRUNKED = "trunked"
|
||||
CONVENTIONAL = "conventional"
|
||||
|
||||
|
||||
class ConventionalChannel(BaseModel):
|
||||
"""One fixed P25 conventional voice channel (node-26#24)."""
|
||||
frequency: int # Hz
|
||||
name: str = ""
|
||||
nac: Optional[int] = None # 0x001-0xFFF; None = accept any NAC
|
||||
|
||||
|
||||
class ConfigGenerator(BaseModel):
|
||||
type: DecodeMode
|
||||
systemName: str
|
||||
# P25: control channel list (Hz). NBFM: channels[0] is the one analog
|
||||
# frequency to listen to (Hz).
|
||||
channels: List[Union[int, str]]
|
||||
# P25 trunked: control channel list (Hz). NBFM: channels[0] is the one
|
||||
# analog frequency to listen to (Hz). P25 conventional: unused — see
|
||||
# conv_channels.
|
||||
channels: List[Union[int, str]] = []
|
||||
# P25 only. Missing = trunked, so every existing caller is unchanged.
|
||||
p25_mode: P25Mode = P25Mode.TRUNKED
|
||||
conv_channels: List[ConventionalChannel] = []
|
||||
tags: Optional[List[TalkgroupTag]] = None
|
||||
whitelist: Optional[List[int]] = None
|
||||
icecastConfig: Optional[IcecastConfig] = None
|
||||
@@ -84,6 +100,13 @@ class ChannelConfig(BaseModel):
|
||||
# Key name op25_nbfm.py actually reads (from_dict(config, 'nbfm_squelch_threshold', -60)).
|
||||
nbfm_squelch_threshold: Optional[float] = None
|
||||
destination: Optional[str] = "udp://127.0.0.1:23456"
|
||||
# P25 conventional only, read by tk_drb_conv.py (not by multi_rx): the NAC
|
||||
# to lock the frame assembler to. multi_rx passes the whole channel dict
|
||||
# through, so an extra key is harmless.
|
||||
nac: Optional[int] = None
|
||||
# Set on the channel itself for conventional (multi_rx forwards it to the
|
||||
# frame assembler). 2 = drop encrypted voice instead of decoding noise.
|
||||
crypt_behavior: Optional[int] = None
|
||||
tracking_threshold: Optional[int] = 120
|
||||
tracking_feedback: Optional[float] = 0.75
|
||||
excess_bw: Optional[float] = 0.2
|
||||
|
||||
@@ -8,7 +8,8 @@ import json
|
||||
import threading
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig, HARDWARE_PRESETS
|
||||
from models import ConfigGenerator, DecodeMode, P25Mode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig, HARDWARE_PRESETS
|
||||
from internal.conventional_config import build_conventional_config, CONVENTIONAL_TRUNKING_MODULE
|
||||
from internal.logger import create_logger
|
||||
from internal.op25_config_utls import save_talkgroup_tags, save_whitelist, del_none_in_dict, get_current_system_from_config
|
||||
from internal.liquidsoap_config_utils import generate_liquid_script
|
||||
@@ -178,7 +179,20 @@ def create_op25_router():
|
||||
@router.post("/generate-config")
|
||||
async def generate_config(generator: ConfigGenerator):
|
||||
try:
|
||||
if generator.type == DecodeMode.P25:
|
||||
if generator.type == DecodeMode.P25 and generator.p25_mode == P25Mode.CONVENTIONAL:
|
||||
# node-26#24: fixed voice channels, no control channel to hunt.
|
||||
preset = HARDWARE_PRESETS.get(generator.hardware_preset, HARDWARE_PRESETS["other"])
|
||||
ppm = generator.ppm_override if generator.ppm_override is not None else preset["ppm"]
|
||||
try:
|
||||
config_dict, two_channel = build_conventional_config(
|
||||
generator.systemName, generator.conv_channels, preset["gains"], ppm,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if generator.icecastConfig:
|
||||
generate_liquid_script(generator.icecastConfig, two_channel=two_channel)
|
||||
|
||||
elif generator.type == DecodeMode.P25:
|
||||
preset = HARDWARE_PRESETS.get(generator.hardware_preset, HARDWARE_PRESETS["other"])
|
||||
ppm = generator.ppm_override if generator.ppm_override is not None else preset["ppm"]
|
||||
channels = [ChannelConfig(
|
||||
@@ -236,9 +250,9 @@ def create_op25_router():
|
||||
preset = HARDWARE_PRESETS.get(generator.hardware_preset, HARDWARE_PRESETS["other"])
|
||||
ppm = generator.ppm_override if generator.ppm_override is not None else preset["ppm"]
|
||||
freq = int(float(generator.channels[0]))
|
||||
# One fixed conventional channel: no trunking section, so
|
||||
# multi_rx adds its default tk_p25 with no chans and reports
|
||||
# the channel as `conventional` on the HTTP terminal.
|
||||
# One fixed conventional channel: a trunking module with no
|
||||
# chans, so the channel is reported as `conventional` on the
|
||||
# HTTP terminal.
|
||||
channels = [ChannelConfig(
|
||||
name=generator.systemName,
|
||||
trunking_sysname="",
|
||||
@@ -259,6 +273,11 @@ def create_op25_router():
|
||||
config_dict = {
|
||||
"channels": [channel.dict() for channel in channels],
|
||||
"devices": [device.dict() for device in devices],
|
||||
# Same module as P25 conventional (node-26#24): it reports
|
||||
# the channel as `conventional` whatever tk_p25 version the
|
||||
# image was built with, which is what the edge node's
|
||||
# single-channel attribution keys on (node-26#20).
|
||||
"trunking": {"module": CONVENTIONAL_TRUNKING_MODULE, "chans": []},
|
||||
"terminal": TerminalConfig().dict(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
P25 conventional (node-26#24): config generation, the liquidsoap two-channel
|
||||
variant, and the tk_drb_conv state machine — all without OP25/GNU Radio.
|
||||
|
||||
tk_drb_conv imports tk_p25/helper_funcs/log_ts from OP25's apps dir; those are
|
||||
stubbed here with the minimum of tk_p25.rx_ctl it relies on. The message
|
||||
shapes fed in are the ones boatbod gr310 p25p1_fdma.cc sends: LCW = 2-byte NAC
|
||||
+ 9 LC bytes (type 19), LDU2 ESS = JSON (type -3), TDU = 2-byte NAC (type 3/15),
|
||||
sync timeout = type -1.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "app"))
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "op25_addons"))
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
|
||||
from models import ConventionalChannel # noqa: E402
|
||||
from internal import conventional_config as cc # noqa: E402
|
||||
from internal.liquidsoap_config_utils import render_liquid_script # noqa: E402
|
||||
from models import IcecastConfig # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WP = [
|
||||
ConventionalChannel(frequency=460_400_000, name="PD1 Dispatch"),
|
||||
ConventionalChannel(frequency=460_425_000, name="PD3 Tac", nac=0x293),
|
||||
]
|
||||
|
||||
|
||||
def test_white_plains_two_channels_one_sdr():
|
||||
config, two = cc.build_conventional_config("White Plains", WP, "lna:34", 0.0)
|
||||
assert two is True
|
||||
assert "trunking" in config and config["trunking"]["module"] == "tk_drb_conv.py"
|
||||
assert config["trunking"]["chans"] == [] # nothing for tk_p25 to hunt
|
||||
a, b = config["channels"]
|
||||
for ch in (a, b):
|
||||
assert ch["demod_type"] == "fsk4"
|
||||
assert ch["trunking_sysname"] == ""
|
||||
assert ch["enable_analog"] == "off"
|
||||
assert ch["crypt_behavior"] == 2
|
||||
assert "control_channel_list" not in ch
|
||||
assert (a["frequency"], b["frequency"]) == (460_400_000, 460_425_000)
|
||||
assert a["destination"].endswith(":23456") and b["destination"].endswith(":23457")
|
||||
assert a["name"] == "PD1 Dispatch" and b["nac"] == 0x293
|
||||
dev = config["devices"][0]
|
||||
assert dev["tunable"] is False
|
||||
# 25 kHz apart: centre is offset off both channels (DC spike), and both
|
||||
# still sit inside the usable passband.
|
||||
half = dev["rate"] * dev["usable_bw_pct"] / 2
|
||||
for f in (460_400_000, 460_425_000):
|
||||
assert 100_000 < abs(f - dev["frequency"]) < half
|
||||
|
||||
|
||||
def test_single_channel_is_not_two_channel_audio():
|
||||
config, two = cc.build_conventional_config("X", WP[:1], "lna:34", 0.0)
|
||||
assert two is False
|
||||
assert len(config["channels"]) == 1
|
||||
|
||||
|
||||
def test_wide_pair_centres_between_channels():
|
||||
chans = [ConventionalChannel(frequency=453_000_000), ConventionalChannel(frequency=454_000_000)]
|
||||
config, _ = cc.build_conventional_config("X", chans, "lna:34", 0.0)
|
||||
assert config["devices"][0]["frequency"] == 453_500_000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chans, needle", [
|
||||
([], "at least one"),
|
||||
([ConventionalChannel(frequency=f) for f in (460_000_000, 460_025_000, 460_050_000)], "up to 2"),
|
||||
([ConventionalChannel(frequency=460_000_000), ConventionalChannel(frequency=462_000_000)], "too far"),
|
||||
([ConventionalChannel(frequency=460_000_000), ConventionalChannel(frequency=460_000_000)], "twice"),
|
||||
([ConventionalChannel(frequency=460_000_000, nac=0x1000)], "NAC"),
|
||||
])
|
||||
def test_rejects_bad_channel_sets(chans, needle):
|
||||
with pytest.raises(ValueError, match=needle):
|
||||
cc.build_conventional_config("X", chans, "lna:34", 0.0)
|
||||
|
||||
|
||||
ICE = IcecastConfig(icecast_host="localhost", icecast_port=8000, icecast_mountpoint="/n", icecast_password="p")
|
||||
|
||||
|
||||
def test_liquidsoap_single_channel_unchanged_command():
|
||||
liq = render_liquid_script(ICE, two_channel=False)
|
||||
assert '"./audio.py -x 2.5 -s"' in liq
|
||||
assert "amplify" not in liq and "${" not in liq
|
||||
|
||||
|
||||
def test_liquidsoap_two_channel():
|
||||
liq = render_liquid_script(ICE, two_channel=True)
|
||||
assert '"./audio.py -x 2.5 -s -2"' in liq
|
||||
assert "input = amplify(2.0, input)" in liq
|
||||
assert "${" not in liq
|
||||
|
||||
|
||||
def test_generate_config_route(tmp_path, monkeypatch):
|
||||
from routers import op25_controller
|
||||
monkeypatch.setattr(op25_controller, "_ACTIVE_CFG", str(tmp_path / "active.cfg.json"))
|
||||
written = {}
|
||||
monkeypatch.setattr(op25_controller, "generate_liquid_script",
|
||||
lambda ice, two_channel=False: written.setdefault("two", two_channel))
|
||||
monkeypatch.setattr(op25_controller, "save_talkgroup_tags", lambda tags: None)
|
||||
monkeypatch.setattr(op25_controller, "save_whitelist", lambda wl: None)
|
||||
app = FastAPI()
|
||||
app.include_router(op25_controller.create_op25_router(), prefix="/op25")
|
||||
client = TestClient(app)
|
||||
ice = ICE.model_dump()
|
||||
r = client.post("/op25/generate-config", json={
|
||||
"type": "P25", "systemName": "White Plains", "p25_mode": "conventional",
|
||||
"conv_channels": [{"frequency": 460400000, "name": "PD1"}, {"frequency": 460425000, "name": "PD3"}],
|
||||
"icecastConfig": ice,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
cfg = json.loads((tmp_path / "active.cfg.json").read_text())
|
||||
assert cfg["trunking"]["module"] == "tk_drb_conv.py"
|
||||
assert [c["frequency"] for c in cfg["channels"]] == [460400000, 460425000]
|
||||
assert written["two"] is True
|
||||
|
||||
# Trunked P25 without p25_mode is exactly what it was.
|
||||
r = client.post("/op25/generate-config", json={
|
||||
"type": "P25", "systemName": "County", "channels": [851012500], "icecastConfig": ice,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
cfg = json.loads((tmp_path / "active.cfg.json").read_text())
|
||||
assert cfg["trunking"]["module"] == "tk_p25.py"
|
||||
assert cfg["trunking"]["chans"][0]["control_channel_list"] == "851012500"
|
||||
assert cfg["channels"][0]["demod_type"] == "cqpsk"
|
||||
|
||||
r = client.post("/op25/generate-config", json={
|
||||
"type": "P25", "systemName": "X", "p25_mode": "conventional", "conv_channels": [],
|
||||
})
|
||||
assert r.status_code == 400
|
||||
|
||||
# Analog uses the same module, so the edge node sees a `conventional` row
|
||||
# whatever tk_p25 the image was built with (node-26#20).
|
||||
r = client.post("/op25/generate-config", json={
|
||||
"type": "NBFM", "systemName": "Fire", "channels": [154430000], "icecastConfig": ice,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
cfg = json.loads((tmp_path / "active.cfg.json").read_text())
|
||||
assert cfg["trunking"]["module"] == "tk_drb_conv.py"
|
||||
assert cfg["channels"][0]["frequency"] == 154430000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tk_drb_conv state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _BaseRxCtl:
|
||||
"""The slice of tk_p25.rx_ctl that tk_drb_conv relies on."""
|
||||
|
||||
def __init__(self, debug=0, frequency_set=None, nbfm_ctrl=None, fa_ctrl=None, chans={}):
|
||||
self.fa_ctrl = fa_ctrl
|
||||
self.receivers = {}
|
||||
self.call_log = []
|
||||
|
||||
def add_receiver(self, msgq_id, config, meta_q=None, freq=0):
|
||||
self.receivers[msgq_id] = {'msgq_id': msgq_id, 'config': config, 'rx_rcvr': None,
|
||||
'conv_state': {'tgid': None}}
|
||||
|
||||
def post_init(self):
|
||||
pass
|
||||
|
||||
def process_qmsg(self, msg):
|
||||
self.base_saw = msg
|
||||
|
||||
def log_call(self, sysid, rcvr, freq, slot, prio, tgid, tgtag, rid, rtag):
|
||||
self.call_log.append({"rcvr": rcvr, "freq": freq, "tgid": tgid, "tgtag": tgtag, "rid": rid})
|
||||
|
||||
def get_chan_status(self):
|
||||
return json.dumps({'json_type': 'channel_update', 'channels': []})
|
||||
|
||||
|
||||
def _stub_op25_modules():
|
||||
tk = types.ModuleType("tk_p25")
|
||||
tk.rx_ctl = _BaseRxCtl
|
||||
hf = types.ModuleType("helper_funcs")
|
||||
|
||||
def from_dict(d, key, def_val):
|
||||
return d[key] if key in d and d[key] != "" else def_val
|
||||
|
||||
def get_ordinals(s):
|
||||
t = 0
|
||||
for c in s:
|
||||
t = (t << 8) + c
|
||||
return t
|
||||
hf.from_dict = from_dict
|
||||
hf.get_ordinals = get_ordinals
|
||||
lt = types.ModuleType("log_ts")
|
||||
lt.log_ts = types.SimpleNamespace(get=lambda *a: "ts")
|
||||
sys.modules.update({"tk_p25": tk, "helper_funcs": hf, "log_ts": lt})
|
||||
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, m_type, rxid, payload=b""):
|
||||
self._type = m_type & 0xffff # proto 0 in the upper 16 bits
|
||||
self._arg1 = rxid << 1
|
||||
self._payload = payload
|
||||
|
||||
def type(self):
|
||||
return self._type
|
||||
|
||||
def arg1(self):
|
||||
return self._arg1
|
||||
|
||||
def to_string(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _lcw(nac, tgid, src, svcopts=0, lco=0x00):
|
||||
lc = bytes([lco, 0x00, svcopts, 0x00]) + tgid.to_bytes(2, "big") + src.to_bytes(3, "big")
|
||||
return nac.to_bytes(2, "big") + lc
|
||||
|
||||
|
||||
def _ess(encrypted, algid, keyid):
|
||||
return json.dumps({"encrypted": encrypted, "algid": algid, "keyid": keyid}).encode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tk():
|
||||
_stub_op25_modules()
|
||||
sys.modules.pop("tk_drb_conv", None)
|
||||
import tk_drb_conv
|
||||
sent = []
|
||||
rx = tk_drb_conv.rx_ctl(fa_ctrl=sent.append, chans=[])
|
||||
rx.add_receiver(0, {"name": "PD1 Dispatch", "nac": 0x293}, freq=460_400_000)
|
||||
rx.add_receiver(1, {"name": "PD3 Tac"}, freq=460_425_000)
|
||||
rx.post_init()
|
||||
rx.sent = sent
|
||||
return rx
|
||||
|
||||
|
||||
def _rows(rx):
|
||||
d = json.loads(rx.get_chan_status())
|
||||
return {cid: d[cid] for cid in d["channels"]}
|
||||
|
||||
|
||||
def test_nac_filter_applied_only_where_configured(tk):
|
||||
assert tk.sent == [{'tuner': 0, 'cmd': 'set_nac', 'nac': 0x293}]
|
||||
|
||||
|
||||
def test_clear_call_reports_tgid_unit_nac_and_logs_once(tk):
|
||||
tk.process_qmsg(_Msg(0, 0, b"\x02\x93")) # HDU
|
||||
tk.process_qmsg(_Msg(19, 0, _lcw(0x293, 101, 4242))) # LDU1
|
||||
tk.process_qmsg(_Msg(-3, 0, _ess(0, 0x80, 0))) # LDU2 clear
|
||||
tk.process_qmsg(_Msg(19, 0, _lcw(0x293, 101, 4242)))
|
||||
row = _rows(tk)["0"]
|
||||
assert row["conventional"] and row["voice"]
|
||||
assert (row["tgid"], row["srcaddr"], row["nac"], row["encrypted"]) == (101, 4242, 0x293, 0)
|
||||
assert row["algid"] is None
|
||||
assert [e["tgid"] for e in tk.call_log] == [101]
|
||||
assert tk.call_log[0]["tgtag"] == "PD1 Dispatch"
|
||||
# The other channel is idle.
|
||||
assert _rows(tk)["1"]["voice"] is False
|
||||
|
||||
tk.process_qmsg(_Msg(3, 0, b"\x02\x93")) # TDU
|
||||
row = _rows(tk)["0"]
|
||||
assert row["voice"] is False and row["tgid"] is None
|
||||
assert (row["clear_calls"], row["enc_calls"]) == (1, 0)
|
||||
|
||||
|
||||
def test_tdulc_link_control_does_not_reopen_the_call(tk):
|
||||
tk.process_qmsg(_Msg(19, 1, _lcw(0x111, 7, 9)))
|
||||
tk.process_qmsg(_Msg(15, 1, b"\x01\x11")) # TDULC
|
||||
tk.process_qmsg(_Msg(19, 1, _lcw(0x111, 7, 9))) # its LC
|
||||
assert _rows(tk)["1"]["voice"] is False
|
||||
|
||||
|
||||
def test_encrypted_call_is_reported_and_never_logged(tk):
|
||||
tk.process_qmsg(_Msg(19, 1, _lcw(0x111, 7, 9, svcopts=0x40))) # E bit
|
||||
tk.process_qmsg(_Msg(-3, 1, _ess(1, 0x84, 0x1234)))
|
||||
row = _rows(tk)["1"]
|
||||
assert row["encrypted"] == 1 and row["algid"] == 0x84 and row["keyid"] == 0x1234
|
||||
assert tk.call_log == []
|
||||
tk.process_qmsg(_Msg(-1, 1)) # sync timeout
|
||||
row = _rows(tk)["1"]
|
||||
assert row["encrypted"] == 0 and row["voice"] is False
|
||||
assert (row["enc_calls"], row["last_enc_algid"], row["last_enc_keyid"]) == (1, 0x84, 0x1234)
|
||||
|
||||
|
||||
def test_encryption_seen_only_in_ess_still_counts(tk):
|
||||
tk.process_qmsg(_Msg(-3, 0, _ess(1, 0x81, 0x0001)))
|
||||
assert _rows(tk)["0"]["encrypted"] == 1
|
||||
tk.process_qmsg(_Msg(3, 0, b"\x02\x93"))
|
||||
assert _rows(tk)["0"]["enc_calls"] == 1
|
||||
|
||||
|
||||
def test_protected_link_control_marks_encrypted(tk):
|
||||
tk.process_qmsg(_Msg(19, 0, _lcw(0x293, 0, 0, lco=0x80)))
|
||||
assert _rows(tk)["0"]["encrypted"] == 1
|
||||
|
||||
|
||||
def test_trunked_receivers_and_other_protocols_go_to_base(tk):
|
||||
msg = _Msg(7, 5)
|
||||
tk.process_qmsg(msg)
|
||||
assert tk.base_saw is msg
|
||||
Reference in New Issue
Block a user