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
@@ -126,10 +126,48 @@ def test_analog_needs_no_control_channel():
|
||||
console={"last_poll_epoch": now, "channels": [{"freq": 154430000}], "trunk": [], "last_heard": None},
|
||||
)))
|
||||
assert c["decode"]["state"] == "ok" and "154.4300 MHz" in c["decode"]["summary"]
|
||||
assert "not recorded" in c["decode"]["action"]
|
||||
# node-26#20: analog calls are recorded now (single-channel attribution).
|
||||
assert c["decode"]["action"] == ""
|
||||
assert "this dashboard" in c["source"]["summary"]
|
||||
|
||||
|
||||
_CONV_SRC = {"name": "White Plains", "type": "P25", "mode": "conventional", "system_id": "wp", "local": False}
|
||||
_PD1 = {"name": "PD1 Dispatch", "freq": 460400000, "nac": None, "encrypted": False, "algid": None, "keyid": None}
|
||||
_PD3 = {"name": "PD3 Tac", "freq": 460425000, "nac": None, "encrypted": False, "algid": None, "keyid": None}
|
||||
|
||||
|
||||
def _conv_facts(chans):
|
||||
now = 1_000_000.0
|
||||
return _facts(source=_CONV_SRC, conventional=chans,
|
||||
console={"last_poll_epoch": now, "channels": [], "trunk": [], "last_heard": None})
|
||||
|
||||
|
||||
def test_conventional_quiet_channels_are_ok_not_a_control_channel_error():
|
||||
c = _by_id(health.build_checks(_conv_facts([_PD1, _PD3])))
|
||||
assert c["decode"]["state"] == "ok"
|
||||
assert "PD1 Dispatch (460.4000 MHz)" in c["decode"]["summary"]
|
||||
assert "control channel" not in c["decode"]["summary"]
|
||||
assert "P25 conventional" in c["source"]["summary"]
|
||||
|
||||
|
||||
def test_conventional_decoding_shows_nac():
|
||||
c = _by_id(health.build_checks(_conv_facts([dict(_PD1, nac=0x293), _PD3])))
|
||||
assert c["decode"]["state"] == "ok" and "NAC 0x293" in c["decode"]["summary"]
|
||||
|
||||
|
||||
def test_conventional_all_encrypted_is_an_error_naming_the_algorithm():
|
||||
enc = [dict(_PD1, encrypted=True, algid=0x84, keyid=0x1234), dict(_PD3, encrypted=True, algid=0x84)]
|
||||
c = _by_id(health.build_checks(_conv_facts(enc)))
|
||||
assert c["decode"]["state"] == "error"
|
||||
assert "AES-256" in c["decode"]["summary"] and "0x1234" in c["decode"]["summary"]
|
||||
assert "different system" in c["decode"]["action"]
|
||||
|
||||
|
||||
def test_conventional_partly_encrypted_warns():
|
||||
c = _by_id(health.build_checks(_conv_facts([dict(_PD1, encrypted=True, algid=0x81), _PD3])))
|
||||
assert c["decode"]["state"] == "warn" and "PD1 Dispatch" in c["decode"]["summary"]
|
||||
|
||||
|
||||
def test_op25_unreachable_and_icecast_down():
|
||||
c = _by_id(health.build_checks(_facts(op25_status=None, icecast_live=None)))
|
||||
assert c["op25"]["state"] == "error"
|
||||
@@ -182,7 +220,26 @@ def test_listen_analog_single_frequency(client, op25):
|
||||
assert (cfg.type, cfg.name, cfg.config["control_channels"]) == ("NBFM", "Fire", [154.43])
|
||||
|
||||
|
||||
def test_listen_p25_conventional(client, op25):
|
||||
r = client.post("/api/listen", json={
|
||||
"mode": "p25conv", "frequencies": [460.4, 460.425], "name": "White Plains", "nac": "0x293",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
cfg = config_manager.load_node_config().override_config
|
||||
assert cfg.type == "P25" and cfg.config["mode"] == "conventional"
|
||||
assert "control_channels" not in cfg.config
|
||||
assert [c["freq"] for c in cfg.config["channels"]] == [460.4, 460.425]
|
||||
assert {c["nac"] for c in cfg.config["channels"]} == {"293"}
|
||||
op25["gen"].assert_awaited_once()
|
||||
status = client.get("/api/status").json()
|
||||
assert status["system_kind"] == "P25 conventional"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body,needle", [
|
||||
({"mode": "p25conv", "frequencies": [460.4, 460.425, 460.45]}, "at most 2"),
|
||||
({"mode": "p25conv", "frequencies": [460.4, 462.6]}, "apart"),
|
||||
({"mode": "p25conv", "frequencies": [460.4, 460.4]}, "twice"),
|
||||
({"mode": "p25conv", "frequencies": [460.4], "nac": "zz"}, "hex"),
|
||||
({"mode": "p25", "frequencies": []}, "at least one"),
|
||||
({"mode": "p25", "frequencies": [851012500]}, "MHz"),
|
||||
({"mode": "analog", "frequencies": [154.43, 155.1]}, "exactly one"),
|
||||
|
||||
Reference in New Issue
Block a user