P25 conventional decode mode + conventional-channel attribution (node-26#24, #20)
CI / lint (push) Successful in 6s
CI / test-op25 (push) Successful in 13s
CI / lint (pull_request) Successful in 7s
CI / test-op25 (pull_request) Successful in 13s
CI / test (push) Successful in 44s
CI / test (pull_request) Successful in 42s

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:
Logan Cusano
2026-10-04 15:57:15 -04:00
co-authored by Claude Opus 5.5
parent 1e1dab66b1
commit d07d312e92
19 changed files with 1865 additions and 79 deletions
@@ -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)
+26 -3
View File
@@ -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
+24 -5
View File
@@ -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(),
}