op25: pin gr310 to 5b58335; analog defaults to OP25 noise squelch (node-26#46, node-26#29)
- op25-container/Dockerfile: clone gr310 then check out 5b58335 (OP25_COMMIT build arg). That is the gr310 merge tk_drb_conv was verified against for #29 and the first carrying PR #296's NBFM noise/voice squelch. Changing the RUN line also busts CI's cached unpinned clone. - Keys verified from op25_nbfm.py at that commit: nbfm_squelch_mode (power|noise|voice), nbfm_noise_squelch_db, nbfm_squelch_threshold, read from the channel dict. - op25-container: ConfigGenerator.nbfm_squelch_mode (enum, default noise) and nbfm_noise_squelch_db, written onto the analog channel. - edge-node: system config squelch_mode / squelch_db / squelch_threshold -> generate payload (validated, noise when absent); /api/listen and the dashboard's analog pane expose mode and level. #29's CI test of tk_drb_conv against the real tk_p25.py is not done here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6b884d5433
commit
a4a575ec9f
@@ -9,7 +9,11 @@ C2 system documents (the `config` blob is passed through untouched):
|
|||||||
P25 conventional type "P25", config.mode "conventional",
|
P25 conventional type "P25", config.mode "conventional",
|
||||||
config.channels = [{freq: MHz, name, nac?}, ...] (node-26#24)
|
config.channels = [{freq: MHz, name, nac?}, ...] (node-26#24)
|
||||||
Analog type "NBFM", config.channels = [{freq, name}] — or, from
|
Analog type "NBFM", config.channels = [{freq, name}] — or, from
|
||||||
before #24, config.control_channels[0]
|
before #24, config.control_channels[0]. Optional squelch
|
||||||
|
(node-26#46): config.squelch_mode "noise" (default) |
|
||||||
|
"voice" | "power", config.squelch_db (noise/voice: dB
|
||||||
|
of quieting to open) and config.squelch_threshold
|
||||||
|
(power: absolute dB, OP25 default -60)
|
||||||
|
|
||||||
A missing mode is trunked, so every system saved before #24 behaves exactly as
|
A missing mode is trunked, so every system saved before #24 behaves exactly as
|
||||||
it did. Pure functions; main._generate_op25_config does the I/O.
|
it did. Pure functions; main._generate_op25_config does the I/O.
|
||||||
@@ -80,6 +84,41 @@ def conventional_channels(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
SQUELCH_MODES = ("noise", "voice", "power")
|
||||||
|
DEFAULT_SQUELCH_MODE = "noise"
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_float(config: Dict[str, Any], key: str) -> Optional[float]:
|
||||||
|
value = (config or {}).get(key)
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(f"{key} {value!r} isn't a number.")
|
||||||
|
|
||||||
|
|
||||||
|
def analog_squelch(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""config -> the op25-container squelch fields for an analog channel.
|
||||||
|
|
||||||
|
Noise squelch by default (node-26#46): OP25's power squelch compares an
|
||||||
|
absolute level that depends on dongle, gain and antenna, and left analog
|
||||||
|
open on static; noise squelch measures FM quieting and needs no
|
||||||
|
calibration. A config that says nothing gets noise mode.
|
||||||
|
"""
|
||||||
|
mode = str((config or {}).get("squelch_mode") or DEFAULT_SQUELCH_MODE).strip().lower()
|
||||||
|
if mode not in SQUELCH_MODES:
|
||||||
|
raise ValueError(f"Squelch mode {mode!r} isn't one of {', '.join(SQUELCH_MODES)}.")
|
||||||
|
out: Dict[str, Any] = {"nbfm_squelch_mode": mode}
|
||||||
|
db = _optional_float(config, "squelch_db")
|
||||||
|
if db is not None:
|
||||||
|
out["nbfm_noise_squelch_db"] = db
|
||||||
|
threshold = _optional_float(config, "squelch_threshold")
|
||||||
|
if threshold is not None:
|
||||||
|
out["nbfm_squelch"] = threshold
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def analog_frequency(config: Dict[str, Any]) -> Optional[int]:
|
def analog_frequency(config: Dict[str, Any]) -> Optional[int]:
|
||||||
chans = (config or {}).get("channels") or []
|
chans = (config or {}).get("channels") or []
|
||||||
if chans and isinstance(chans[0], dict) and chans[0].get("freq") not in (None, ""):
|
if chans and isinstance(chans[0], dict) and chans[0].get("freq") not in (None, ""):
|
||||||
@@ -117,6 +156,7 @@ def build_generate_payload(
|
|||||||
if mode == ANALOG:
|
if mode == ANALOG:
|
||||||
freq = analog_frequency(config)
|
freq = analog_frequency(config)
|
||||||
payload["channels"] = [freq] if freq else []
|
payload["channels"] = [freq] if freq else []
|
||||||
|
payload.update(analog_squelch(config))
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
talkgroups = (config or {}).get("talkgroups", []) or []
|
talkgroups = (config or {}).get("talkgroups", []) or []
|
||||||
|
|||||||
@@ -208,6 +208,12 @@ class ListenBody(BaseModel):
|
|||||||
frequencies: List[float] = []
|
frequencies: List[float] = []
|
||||||
talkgroups: List[Talkgroup] = [] # p25 only; empty = every talkgroup
|
talkgroups: List[Talkgroup] = [] # p25 only; empty = every talkgroup
|
||||||
nac: Optional[str] = None # p25conv only, hex; empty = any NAC
|
nac: Optional[str] = None # p25conv only, hex; empty = any NAC
|
||||||
|
# analog only (node-26#46): OP25 squelch. noise (default) needs no
|
||||||
|
# calibration; squelch_db = dB of quieting to open (OP25 default 8);
|
||||||
|
# squelch_threshold = absolute dB for power mode (OP25 default -60).
|
||||||
|
squelch_mode: Optional[Literal["noise", "voice", "power"]] = None
|
||||||
|
squelch_db: Optional[float] = None
|
||||||
|
squelch_threshold: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
# RTL-SDR (R820T/R828D) tuning range.
|
# RTL-SDR (R820T/R828D) tuning range.
|
||||||
@@ -242,14 +248,21 @@ async def listen(body: ListenBody):
|
|||||||
else:
|
else:
|
||||||
is_p25 = body.mode == "p25"
|
is_p25 = body.mode == "p25"
|
||||||
label = (body.name or "").strip() or (f"P25 {first} MHz" if is_p25 else f"Analog {first} MHz")
|
label = (body.name or "").strip() or (f"P25 {first} MHz" if is_p25 else f"Analog {first} MHz")
|
||||||
|
sys_config = {
|
||||||
|
"control_channels": freqs,
|
||||||
|
"talkgroups": [tg.model_dump() for tg in body.talkgroups] if is_p25 else [],
|
||||||
|
}
|
||||||
|
if not is_p25:
|
||||||
|
sys_config["squelch_mode"] = body.squelch_mode or "noise"
|
||||||
|
if body.squelch_db is not None:
|
||||||
|
sys_config["squelch_db"] = body.squelch_db
|
||||||
|
if body.squelch_threshold is not None:
|
||||||
|
sys_config["squelch_threshold"] = body.squelch_threshold
|
||||||
config = SystemConfig(
|
config = SystemConfig(
|
||||||
system_id=f"local-{body.mode}",
|
system_id=f"local-{body.mode}",
|
||||||
name=label,
|
name=label,
|
||||||
type="P25" if is_p25 else "NBFM",
|
type="P25" if is_p25 else "NBFM",
|
||||||
config={
|
config=sys_config,
|
||||||
"control_channels": freqs,
|
|
||||||
"talkgroups": [tg.model_dump() for tg in body.talkgroups] if is_p25 else [],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await _apply_override(config)
|
await _apply_override(config)
|
||||||
return {"ok": True, "name": config.name}
|
return {"ok": True, "name": config.name}
|
||||||
|
|||||||
@@ -216,6 +216,20 @@
|
|||||||
<label for="an-name">Name <span class="hint">(optional)</span></label>
|
<label for="an-name">Name <span class="hint">(optional)</span></label>
|
||||||
<input type="text" id="an-name" placeholder="Fire dispatch">
|
<input type="text" id="an-name" placeholder="Fire dispatch">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="an-sq-mode">Squelch</label>
|
||||||
|
<select id="an-sq-mode">
|
||||||
|
<option value="noise" selected>Noise (recommended)</option>
|
||||||
|
<option value="voice">Voice (noise + speech detector)</option>
|
||||||
|
<option value="power">Power (old, needs calibration)</option>
|
||||||
|
</select>
|
||||||
|
<span class="hint">Noise squelch opens on a real carrier and needs no tuning per site.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="an-sq-level">Squelch level (dB) <span class="hint">(optional)</span></label>
|
||||||
|
<input type="text" id="an-sq-level" class="mono" placeholder="8" inputmode="decimal">
|
||||||
|
<span class="hint" id="an-sq-hint">Noise/voice: dB of quieting to open — 8 default, 12-15 for strong signals only, ~5 for weak ones.</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pane" data-pane="system" hidden>
|
<div class="pane" data-pane="system" hidden>
|
||||||
@@ -396,6 +410,13 @@
|
|||||||
// ── Listen locally ─────────────────────────────────────────────────────
|
// ── Listen locally ─────────────────────────────────────────────────────
|
||||||
let systems = [];
|
let systems = [];
|
||||||
function mode() { return document.querySelector('input[name=mode]:checked').value; }
|
function mode() { return document.querySelector('input[name=mode]:checked').value; }
|
||||||
|
$('an-sq-mode').addEventListener('change', () => {
|
||||||
|
const power = $('an-sq-mode').value === 'power';
|
||||||
|
$('an-sq-level').placeholder = power ? '-60' : '8';
|
||||||
|
$('an-sq-hint').textContent = power
|
||||||
|
? 'Power: absolute signal level in dB to open (-60 default); depends on this SDR, gain and antenna.'
|
||||||
|
: 'Noise/voice: dB of quieting to open — 8 default, 12-15 for strong signals only, ~5 for weak ones.';
|
||||||
|
});
|
||||||
document.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', () => {
|
document.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', () => {
|
||||||
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.dataset.pane !== mode(); });
|
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.dataset.pane !== mode(); });
|
||||||
$('listen-msg').textContent = '';
|
$('listen-msg').textContent = '';
|
||||||
@@ -443,7 +464,12 @@
|
|||||||
} else {
|
} else {
|
||||||
const freqs = parseFreqs($('an-freq').value);
|
const freqs = parseFreqs($('an-freq').value);
|
||||||
if (freqs.length !== 1 || isNaN(freqs[0])) { msg.className = 'msg err'; msg.textContent = 'Enter one frequency in MHz, e.g. 154.4300'; return; }
|
if (freqs.length !== 1 || isNaN(freqs[0])) { msg.className = 'msg err'; msg.textContent = 'Enter one frequency in MHz, e.g. 154.4300'; return; }
|
||||||
body = { mode: 'analog', frequencies: freqs, name: $('an-name').value };
|
const sqMode = $('an-sq-mode').value;
|
||||||
|
const sqRaw = $('an-sq-level').value.trim();
|
||||||
|
const sqLevel = sqRaw === '' ? null : Number(sqRaw);
|
||||||
|
if (sqLevel !== null && isNaN(sqLevel)) { msg.className = 'msg err'; msg.textContent = 'Squelch level must be a number of dB, e.g. 8 (or -60 for power mode).'; return; }
|
||||||
|
body = { mode: 'analog', frequencies: freqs, name: $('an-name').value, squelch_mode: sqMode };
|
||||||
|
if (sqLevel !== null) body[sqMode === 'power' ? 'squelch_threshold' : 'squelch_db'] = sqLevel;
|
||||||
}
|
}
|
||||||
$('listen-btn').disabled = true;
|
$('listen-btn').disabled = true;
|
||||||
msg.className = 'msg';
|
msg.className = 'msg';
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ def test_analog_payload_reads_channels_or_legacy(cfg):
|
|||||||
p = rc.build_generate_payload("NBFM", "Fire", cfg, ICE, "rtl-sdr-v3", None)
|
p = rc.build_generate_payload("NBFM", "Fire", cfg, ICE, "rtl-sdr-v3", None)
|
||||||
assert p["channels"] == [154_430_000]
|
assert p["channels"] == [154_430_000]
|
||||||
assert "p25_mode" not in p
|
assert "p25_mode" not in p
|
||||||
|
assert p["nbfm_squelch_mode"] == "noise" # node-26#46 default
|
||||||
|
|
||||||
|
|
||||||
|
def test_analog_squelch_is_plumbed_and_validated():
|
||||||
|
cfg = {"channels": [{"freq": 154.43}], "squelch_mode": "Power", "squelch_threshold": "-55", "squelch_db": 12}
|
||||||
|
p = rc.build_generate_payload("NBFM", "Fire", cfg, ICE, "rtl-sdr-v3", None)
|
||||||
|
assert p["nbfm_squelch_mode"] == "power" and p["nbfm_squelch"] == -55.0 and p["nbfm_noise_squelch_db"] == 12.0
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
rc.analog_squelch({"squelch_mode": "loud"})
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
rc.analog_squelch({"squelch_db": "lots"})
|
||||||
|
|
||||||
|
|
||||||
def test_system_mode():
|
def test_system_mode():
|
||||||
|
|||||||
@@ -229,6 +229,17 @@ def test_listen_analog_single_frequency(client, op25):
|
|||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
cfg = config_manager.load_node_config().override_config
|
cfg = config_manager.load_node_config().override_config
|
||||||
assert (cfg.type, cfg.name, cfg.config["control_channels"]) == ("NBFM", "Fire", [154.43])
|
assert (cfg.type, cfg.name, cfg.config["control_channels"]) == ("NBFM", "Fire", [154.43])
|
||||||
|
assert cfg.config["squelch_mode"] == "noise" # node-26#46 default
|
||||||
|
|
||||||
|
|
||||||
|
def test_listen_analog_squelch_is_saved(client, op25):
|
||||||
|
r = client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43],
|
||||||
|
"squelch_mode": "power", "squelch_threshold": -55})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
cfg = config_manager.load_node_config().override_config.config
|
||||||
|
assert (cfg["squelch_mode"], cfg["squelch_threshold"]) == ("power", -55)
|
||||||
|
assert client.post("/api/listen", json={"mode": "analog", "frequencies": [154.43],
|
||||||
|
"squelch_mode": "loud"}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_listen_p25_conventional(client, op25):
|
def test_listen_p25_conventional(client, op25):
|
||||||
|
|||||||
@@ -17,8 +17,16 @@ RUN apt-get update && \
|
|||||||
# Install custom PulseAudio system config (enables anonymous access for edge-node)
|
# Install custom PulseAudio system config (enables anonymous access for edge-node)
|
||||||
COPY system.pa /etc/pulse/system.pa
|
COPY system.pa /etc/pulse/system.pa
|
||||||
|
|
||||||
# Clone the boatbod op25 repository
|
# Clone the boatbod op25 repository, PINNED (node-26#29, #46). An unpinned
|
||||||
RUN git clone -b gr310 https://github.com/boatbod/op25 /op25
|
# clone plus CI's layer cache meant the image held whatever gr310 HEAD was at
|
||||||
|
# the last cache miss. 5b58335 (gr310, 2026-08-23) is the commit tk_drb_conv
|
||||||
|
# was verified against for node-26#29 and the first gr310 merge that carries
|
||||||
|
# the 2026-08-03 NBFM noise/voice squelch (PR #296, op25_nbfm.py
|
||||||
|
# nbfm_squelch_mode). Bump deliberately, re-checking tk_drb_conv against
|
||||||
|
# tk_p25.py at the new commit.
|
||||||
|
ARG OP25_COMMIT=5b583357240e0172bfae905f0c966474e0d0f094
|
||||||
|
RUN git clone -b gr310 https://github.com/boatbod/op25 /op25 && \
|
||||||
|
git -C /op25 checkout --detach "${OP25_COMMIT}"
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /op25
|
WORKDIR /op25
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ class P25Mode(str, Enum):
|
|||||||
CONVENTIONAL = "conventional"
|
CONVENTIONAL = "conventional"
|
||||||
|
|
||||||
|
|
||||||
|
class NbfmSquelchMode(str, Enum):
|
||||||
|
"""Values op25_nbfm.py accepts for nbfm_squelch_mode (gr310, 2026-08-03)."""
|
||||||
|
POWER = "power"
|
||||||
|
NOISE = "noise"
|
||||||
|
VOICE = "voice"
|
||||||
|
|
||||||
|
|
||||||
class ConventionalChannel(BaseModel):
|
class ConventionalChannel(BaseModel):
|
||||||
"""One fixed P25 conventional voice channel (node-26#24)."""
|
"""One fixed P25 conventional voice channel (node-26#24)."""
|
||||||
frequency: int # Hz
|
frequency: int # Hz
|
||||||
@@ -77,7 +84,16 @@ class ConfigGenerator(BaseModel):
|
|||||||
hardware_preset: str = "rtl-sdr-v3"
|
hardware_preset: str = "rtl-sdr-v3"
|
||||||
ppm_override: Optional[float] = None # when set, overrides the preset's default PPM
|
ppm_override: Optional[float] = None # when set, overrides the preset's default PPM
|
||||||
# NBFM only: power squelch threshold in dB (op25_nbfm default is -60).
|
# NBFM only: power squelch threshold in dB (op25_nbfm default is -60).
|
||||||
|
# Used only in "power" squelch mode.
|
||||||
nbfm_squelch: Optional[float] = None
|
nbfm_squelch: Optional[float] = None
|
||||||
|
# NBFM only (node-26#46): OP25 gr310's squelch algorithm — "power" (the
|
||||||
|
# absolute-level squelch, needs per-site calibration), "noise" (FM
|
||||||
|
# quieting, no calibration) or "voice" (noise + speech detector).
|
||||||
|
# Defaults to noise: power mode left analog open on static.
|
||||||
|
nbfm_squelch_mode: NbfmSquelchMode = NbfmSquelchMode.NOISE
|
||||||
|
# dB of quieting needed to open in noise/voice mode (OP25 default 8;
|
||||||
|
# 12-15 opens only on solid signals, ~5 chases weak ones).
|
||||||
|
nbfm_noise_squelch_db: Optional[float] = None
|
||||||
|
|
||||||
class DemodType(str, Enum):
|
class DemodType(str, Enum):
|
||||||
CQPSK = "cqpsk"
|
CQPSK = "cqpsk"
|
||||||
@@ -99,6 +115,9 @@ class ChannelConfig(BaseModel):
|
|||||||
frequency: Optional[int] = None
|
frequency: Optional[int] = None
|
||||||
# Key name op25_nbfm.py actually reads (from_dict(config, 'nbfm_squelch_threshold', -60)).
|
# Key name op25_nbfm.py actually reads (from_dict(config, 'nbfm_squelch_threshold', -60)).
|
||||||
nbfm_squelch_threshold: Optional[float] = None
|
nbfm_squelch_threshold: Optional[float] = None
|
||||||
|
# Also read by op25_nbfm.py from the channel dict (node-26#46).
|
||||||
|
nbfm_squelch_mode: Optional[str] = None
|
||||||
|
nbfm_noise_squelch_db: Optional[float] = None
|
||||||
destination: Optional[str] = "udp://127.0.0.1:23456"
|
destination: Optional[str] = "udp://127.0.0.1:23456"
|
||||||
# P25 conventional only, read by tk_drb_conv.py (not by multi_rx): the NAC
|
# 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
|
# to lock the frame assembler to. multi_rx passes the whole channel dict
|
||||||
|
|||||||
@@ -262,6 +262,8 @@ def create_op25_router():
|
|||||||
frequency=freq,
|
frequency=freq,
|
||||||
meta_stream_name="",
|
meta_stream_name="",
|
||||||
nbfm_squelch_threshold=generator.nbfm_squelch,
|
nbfm_squelch_threshold=generator.nbfm_squelch,
|
||||||
|
nbfm_squelch_mode=generator.nbfm_squelch_mode.value,
|
||||||
|
nbfm_noise_squelch_db=generator.nbfm_noise_squelch_db,
|
||||||
)]
|
)]
|
||||||
devices = [DeviceConfig(
|
devices = [DeviceConfig(
|
||||||
gains=preset["gains"], ppm=ppm, tunable=False,
|
gains=preset["gains"], ppm=ppm, tunable=False,
|
||||||
|
|||||||
@@ -149,6 +149,23 @@ def test_generate_config_route(tmp_path, monkeypatch):
|
|||||||
cfg = json.loads((tmp_path / "active.cfg.json").read_text())
|
cfg = json.loads((tmp_path / "active.cfg.json").read_text())
|
||||||
assert cfg["trunking"]["module"] == "tk_drb_conv.py"
|
assert cfg["trunking"]["module"] == "tk_drb_conv.py"
|
||||||
assert cfg["channels"][0]["frequency"] == 154430000
|
assert cfg["channels"][0]["frequency"] == 154430000
|
||||||
|
# node-26#46: analog defaults to OP25's noise squelch, not power.
|
||||||
|
assert cfg["channels"][0]["nbfm_squelch_mode"] == "noise"
|
||||||
|
assert "nbfm_noise_squelch_db" not in cfg["channels"][0]
|
||||||
|
|
||||||
|
r = client.post("/op25/generate-config", json={
|
||||||
|
"type": "NBFM", "systemName": "Fire", "channels": [154430000], "icecastConfig": ice,
|
||||||
|
"nbfm_squelch_mode": "power", "nbfm_squelch": -55, "nbfm_noise_squelch_db": 12,
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
ch = json.loads((tmp_path / "active.cfg.json").read_text())["channels"][0]
|
||||||
|
assert ch["nbfm_squelch_mode"] == "power" and ch["nbfm_squelch_threshold"] == -55
|
||||||
|
assert ch["nbfm_noise_squelch_db"] == 12
|
||||||
|
|
||||||
|
r = client.post("/op25/generate-config", json={
|
||||||
|
"type": "NBFM", "systemName": "Fire", "channels": [154430000], "nbfm_squelch_mode": "loud",
|
||||||
|
})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user