# ADN DMR Peer Server - tests application peer single downlink # # Copyright (C) 2026 Rodrigo Pérez, CE5RPY # ############################################################################### # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA ############################################################################### """Per-peer SINGLE=1 downlink exclusivity (inject-only multi-hotspot).""" from __future__ import annotations from adn_server.application.routing.helpers import ( clear_peer_rx_status_slots, clear_peer_ua_sessions, peer_options_static_tg_slot, peer_receives_group_tgid, peer_should_receive_group_voice, peer_single_blocks_group_voice, peer_single_exclusive_tgid, register_peer_ua_multi_tg, register_peer_ua_session, seed_peer_ua_session_from_status, tg4000_reset_on_vhead, ) from adn_server.domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, bytes_3, bytes_4, int_id def _sys_cfg() -> dict: return {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 10} def _peer_id() -> bytes: return bytes_4(730039101) def test_static_tg_on_opposite_slot_receives_group_voice() -> None: """Hotspot lists TG on TS1 but network voice arrives on TS2 (legacy REPEAT parity).""" peer = {"OPTIONS": b"TS1=730170;"} assert peer_receives_group_tgid(peer, 2, 730170) assert peer_options_static_tg_slot(peer, 730170) == 1 assert peer_should_receive_group_voice(peer, 2, 730170, connected_count=8) def test_single_blocks_other_static_tg_while_session_on_static() -> None: """Indigo on 7305 blocks RX on 730 even when both are in OPTIONS.""" peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) assert peer_should_receive_group_voice( peer, 2, 7305, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 60 ) assert not peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 60 ) def test_single_session_survives_on_sys_cfg_store() -> None: """Session lives on ``sys_cfg['_PEER_UA_SESSIONS']``, not only the peer dict.""" peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) peer.pop("_UA_SESSION", None) assert not peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 60 ) def test_single_uses_peer_options_timer_not_yaml_default() -> None: peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 60} peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) still_locked = now + 5 * 60 - 1 expired = now + 5 * 60 + 1 assert not peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=still_locked ) assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=expired ) def test_single_allows_other_tg_after_timer_expires() -> None: peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) expired = now + 5 * 60 + 1 assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=expired ) def test_single_zero_allows_multiple_static_tgs() -> None: peer = {"OPTIONS": b"TS2=730,7305;SINGLE=0;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 60 ) def test_tg4000_reset_only_on_voice_header() -> None: assert tg4000_reset_on_vhead(4000, HBPF_DATA_SYNC, HBPF_SLT_VHEAD) assert not tg4000_reset_on_vhead(4000, HBPF_DATA_SYNC, 2) assert not tg4000_reset_on_vhead(730, HBPF_DATA_SYNC, HBPF_SLT_VHEAD) def test_tg4000_clears_single_session() -> None: peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) clear_peer_ua_sessions(peer, sys_cfg, peer_id, slot=2) assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 10 ) def test_tg4000_clear_rx_status_blocks_rpto_reseed() -> None: """After 4000, stale STATUS must not re-seed UA session on RPTO.""" peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;", "CONNECTED": 999_000.0} peer_id = _peer_id() sys_cfg = _sys_cfg() status = {2: {"RX_PEER": peer_id, "RX_TGID": bytes_3(7305), "RX_TIME": 999_500.0}} now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) clear_peer_ua_sessions(peer, sys_cfg, peer_id, slot=2) clear_peer_rx_status_slots(status, peer_id, slot=2) seed_peer_ua_session_from_status(peer, peer_id, 2, status[2], sys_cfg, now=now + 10) assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 20 ) def test_seed_session_from_status_after_options() -> None: """TX on 7305 before RPTO SINGLE=1 — OPTIONS must seed the lock.""" peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;", "CONNECTED": 999_000.0} peer_id = _peer_id() sys_cfg = _sys_cfg() status = {"RX_PEER": peer_id, "RX_TGID": bytes_3(7305), "RX_TIME": 999_500.0} now = 1_000_000.0 seed_peer_ua_session_from_status( peer, peer_id, 2, status, sys_cfg, now=now, ) assert not peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 10 ) def test_seed_ignores_stale_status_from_before_reconnect() -> None: """OPTIONS after reconnect must not restore indigo from pre-login STATUS.""" peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;", "CONNECTED": 2_000_000.0} peer_id = _peer_id() sys_cfg = _sys_cfg() status = {"RX_PEER": peer_id, "RX_TGID": bytes_3(7305), "RX_TIME": 1_000_000.0} now = 2_000_100.0 seed_peer_ua_session_from_status( peer, peer_id, 2, status, sys_cfg, now=now, ) assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 10 ) def test_disconnect_clears_session_on_peer_and_sys_cfg() -> None: peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) clear_peer_ua_sessions(peer, sys_cfg, peer_id) assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 10 ) def test_disconnect_clears_stale_rx_status() -> None: peer_id = _peer_id() status = { 2: {"RX_PEER": peer_id, "RX_TGID": bytes_3(7305), "RX_TIME": 1_000_000.0}, } clear_peer_rx_status_slots(status, peer_id) assert status[2]["RX_PEER"] == b"\x00" assert int_id(status[2]["RX_TGID"]) == 0 def test_single_never_blocks_uplink_tx_switches_indigo() -> None: """SINGLE=1 blocks RX on other TGs, not local TX; new TX replaces the session.""" peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) assert peer_single_blocks_group_voice(peer, 2, 730, sys_cfg, peer_id=peer_id, now=now + 10) register_peer_ua_session(peer, peer_id, 2, 730, sys_cfg, now=now + 120) assert not peer_single_blocks_group_voice(peer, 2, 730, sys_cfg, peer_id=peer_id, now=now + 121) assert peer_single_blocks_group_voice(peer, 2, 7305, sys_cfg, peer_id=peer_id, now=now + 121) def test_single_still_blocks_non_static_dynamic() -> None: """Dynamic UA not in OPTIONS stays exclusive to the session owner.""" peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) assert not peer_should_receive_group_voice( peer, 2, 730444, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 10 ) def test_cross_peer_static_tx_does_not_reach_locked_peer() -> None: """730039101 indigo 7305 must not hear 730 when 730039210 keys up on 730.""" peer_a = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} peer_b = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() id_a = bytes_4(730039101) id_b = bytes_4(730039210) now = 1_000_000.0 register_peer_ua_session(peer_a, id_a, 2, 7305, sys_cfg, now=now) register_peer_ua_session(peer_b, id_b, 2, 730, sys_cfg, now=now + 5) assert not peer_should_receive_group_voice( peer_a, 2, 730, peer_id=id_a, connected_count=3, sys_cfg=sys_cfg, now=now + 10 ) def test_active_routing_table_does_not_fan_out_without_static_tg() -> None: """Regression: ACTIVE BRIDGES leg must not deliver to peers missing TG in OPTIONS.""" peer_without = {"OPTIONS": b"TS2=91;"} peer_with = {"OPTIONS": b"TS2=7305;"} bridges = { "7305": [ { "SYSTEM": "SYSTEM", "TS": 2, "ACTIVE": True, "TO_TYPE": "ON", } ] } assert not peer_should_receive_group_voice( peer_without, 2, 7305, system="SYSTEM", bridges=bridges, connected_count=8, sys_cfg=_sys_cfg(), ) assert peer_should_receive_group_voice( peer_with, 2, 7305, system="SYSTEM", bridges=bridges, connected_count=8, sys_cfg=_sys_cfg(), ) def test_dynamic_ua_delivers_only_to_session_owner() -> None: """UA TG not in static OPTIONS — only the activating SINGLE peer receives it.""" peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} other = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() other_id = bytes_4(730039210) now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 730444, sys_cfg, now=now) assert peer_should_receive_group_voice( peer, 2, 730444, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 10 ) assert not peer_should_receive_group_voice( other, 2, 730444, peer_id=other_id, connected_count=8, sys_cfg=sys_cfg, now=now + 10 ) def test_peer_without_static_tgs_receives_nothing_until_dynamic() -> None: """No OPTIONS static list → no downlink; dynamic UA session is the only path.""" peer = {"OPTIONS": b"Type=HBlink;SINGLE=1;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 assert not peer_should_receive_group_voice( peer, 2, 7305, peer_id=peer_id, connected_count=1, sys_cfg=sys_cfg, now=now + 10 ) register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) assert peer_should_receive_group_voice( peer, 2, 7305, peer_id=peer_id, connected_count=1, sys_cfg=sys_cfg, now=now + 10 ) def test_multi_zero_static_on_one_slot_still_tracks_dynamic_on_other() -> None: """Real bug: TG static on TS2, peer separately keys the SAME TG on TS1 -- the static match on TS2 must not block persisting TS1 as dynamic too. register_peer_ua_multi_tg's guard used to be slot-blind (peer_receives_group_tgid ignores its slot argument and checks either static list), so this dynamic activation was silently never recorded.""" peer = {"OPTIONS": b"TS2=71442;SINGLE=0;"} sys_cfg = _sys_cfg() peer_id = _peer_id() register_peer_ua_multi_tg(peer, peer_id, 1, 71442, sys_cfg) pk = bytes_4(int_id(peer_id)) assert 71442 in sys_cfg["_PEER_UA_MULTI_TGS"][pk][1] def test_multi_zero_simplex_static_on_either_slot_still_blocks_dynamic() -> None: """Simplex peer: one real RF path regardless of nominal TS1/TS2, so a static match on either slot still blocks dynamic tracking (unlike duplex, which is tracked independently per slot).""" peer = {"OPTIONS": b"TS2=71442;SINGLE=0;", "RF_MODE": "simplex"} sys_cfg = _sys_cfg() peer_id = _peer_id() register_peer_ua_multi_tg(peer, peer_id, 1, 71442, sys_cfg) pk = bytes_4(int_id(peer_id)) assert pk not in sys_cfg.get("_PEER_UA_MULTI_TGS", {}) def test_single_zero_dynamic_heard_when_both_peers_keyed() -> None: """SINGLE=0: HS1 and HS2 both keyed 7304 → each hears the other's TX on 7304.""" peer_a = {"OPTIONS": b"TS2=730,7305;SINGLE=0;"} peer_b = {"OPTIONS": b"TS2=730,7305;SINGLE=0;"} sys_cfg = _sys_cfg() id_a = bytes_4(730039101) id_b = bytes_4(730039210) now = 1_000_000.0 register_peer_ua_session(peer_a, id_a, 2, 7304, sys_cfg, now=now) register_peer_ua_session(peer_b, id_b, 2, 7304, sys_cfg, now=now + 5) assert peer_should_receive_group_voice( peer_a, 2, 7304, peer_id=id_a, connected_count=2, sys_cfg=sys_cfg, now=now + 10 ) assert peer_should_receive_group_voice( peer_b, 2, 7304, peer_id=id_b, connected_count=2, sys_cfg=sys_cfg, now=now + 10 ) def test_single_zero_dynamic_not_heard_without_local_key() -> None: """SINGLE=0: dynamic 7304 only reaches peers that keyed it (not the other HS).""" peer_a = {"OPTIONS": b"TS2=730,7305;SINGLE=0;"} peer_b = {"OPTIONS": b"TS2=730,7305;SINGLE=0;"} sys_cfg = _sys_cfg() id_a = bytes_4(730039101) id_b = bytes_4(730039210) now = 1_000_000.0 register_peer_ua_session(peer_a, id_a, 2, 7304, sys_cfg, now=now) assert peer_should_receive_group_voice( peer_a, 2, 7304, peer_id=id_a, connected_count=2, sys_cfg=sys_cfg, now=now + 10 ) assert not peer_should_receive_group_voice( peer_b, 2, 7304, peer_id=id_b, connected_count=2, sys_cfg=sys_cfg, now=now + 10 ) def test_single_zero_tg4000_clears_multi_dynamic() -> None: peer = {"OPTIONS": b"TS2=730,7305;SINGLE=0;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_multi_tg(peer, peer_id, 2, 7304, sys_cfg) clear_peer_ua_sessions(peer, sys_cfg, peer_id, slot=2) assert not peer_should_receive_group_voice( peer, 2, 7304, peer_id=peer_id, connected_count=2, sys_cfg=sys_cfg, now=now + 10 ) def test_register_peer_ua_session_ignores_4000() -> None: """TG 4000 resets dynamics; it must never be stored as a UA session.""" peer_id = _peer_id() sys_cfg = _sys_cfg() now = 1_000_000.0 peer_multi = {"OPTIONS": b"TS2=730,7305;SINGLE=0;"} register_peer_ua_session(peer_multi, peer_id, 2, 4000, sys_cfg, now=now) pk = peer_id assert sys_cfg.get("_PEER_UA_MULTI_TGS", {}).get(pk, {}).get(2, set()) == set() peer_single = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} register_peer_ua_session(peer_single, peer_id, 2, 4000, sys_cfg, now=now) assert peer_single_exclusive_tgid(peer_single, 2, sys_cfg, peer_id=peer_id, now=now) is None def test_register_peer_ua_session_ignores_echo_9990() -> None: """Echo TG 9990 must not create SINGLE=1 exclusive listen lock.""" peer_id = _peer_id() sys_cfg = _sys_cfg() now = 1_000_000.0 peer = {"OPTIONS": b"TS2=730444;SINGLE=1;TIMER=5;"} register_peer_ua_session(peer, peer_id, 2, 730444, sys_cfg, now=now) register_peer_ua_session(peer, peer_id, 2, 9990, sys_cfg, now=now) assert peer_single_exclusive_tgid(peer, 2, sys_cfg, peer_id=peer_id, now=now) == 730444 def test_register_peer_ua_session_ignores_service_999x() -> None: """On-demand / service 9991–9999 are not UA sessions (same class as echo 9990).""" peer_id = _peer_id() sys_cfg = _sys_cfg() now = 1_000_000.0 peer = {"OPTIONS": b"TS2=730444;SINGLE=1;TIMER=5;"} register_peer_ua_session(peer, peer_id, 2, 9999, sys_cfg, now=now) assert peer_single_exclusive_tgid(peer, 2, sys_cfg, peer_id=peer_id, now=now) is None def test_new_tx_replaces_single_session_tg() -> None: peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) register_peer_ua_session(peer, peer_id, 2, 730, sys_cfg, now=now + 120) assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 121 ) assert not peer_should_receive_group_voice( peer, 2, 7305, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 121 ) def test_downlink_listen_registers_single_session() -> None: """SINGLE=1: first downlink VHEAD on a static TG locks out other TGs.""" from adn_server.application.routing.downlink import DownlinkContext, track_peer_group_dmrd sys_cfg = {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 10, "MODE": "MASTER", "MAX_PEERS": 8} config = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": sys_cfg}} peer_id = _peer_id() peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} ctx = DownlinkContext( config=config, system_name="MASTER-A", sys_cfg=sys_cfg, peers={peer_id: peer}, status={1: {}, 2: {}}, connected_count=3, ) now = 1_000_000.0 vhead_7305 = b"".join([ b"DMRD", b"\x00", bytes_3(100), bytes_3(7305), b"\x00\x00\x00\x00", bytes([0x80 | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VHEAD]), bytes_4(0x11111111), ] + [b"\x00"] * 33) track_peer_group_dmrd(ctx, peer_id, vhead_7305, peer, pkt_time=now) assert peer_should_receive_group_voice( peer, 2, 7305, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 1, ) assert not peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 1, ) def test_downlink_listen_allows_voice_frames_after_vhead() -> None: """Listen lock on VHEAD must not block voice frames of the same downlink stream.""" from adn_server.application.routing.downlink import ( DownlinkContext, peer_accepts_group_dmrd_packet, track_peer_group_dmrd, ) from adn_server.domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, bytes_3, bytes_4 from adn_server.domain.hbp_protocol import HBPF_VOICE sys_cfg = {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 10, "MODE": "MASTER", "MAX_PEERS": 8} config = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": sys_cfg}} peer_id = _peer_id() peer = {"OPTIONS": b"TS2=730502;SINGLE=1;TIMER=60;"} ctx = DownlinkContext( config=config, system_name="MASTER-A", sys_cfg=sys_cfg, peers={peer_id: peer}, status={1: {}, 2: {}}, connected_count=3, ) now = 1_000_000.0 stream = bytes_4(0x11111111) vhead = b"".join([ b"DMRD", b"\x00", bytes_3(100), bytes_3(730502), b"\x00\x00\x00\x00", bytes([0x80 | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VHEAD]), stream, ] + [b"\x00"] * 33) voice = b"".join([ b"DMRD", b"\x00", bytes_3(100), bytes_3(730502), b"\x00\x00\x00\x00", bytes([0x80 | (HBPF_VOICE << 4)]), stream, ] + [b"\x00"] * 33) track_peer_group_dmrd(ctx, peer_id, vhead, peer, pkt_time=now) assert peer_accepts_group_dmrd_packet(ctx, peer_id, peer, voice) def test_downlink_vterm_clears_listen_session_not_local_tx() -> None: """Listen lock from downlink ends on VTERM; local TX session survives.""" from adn_server.application.routing.downlink import ( DownlinkContext, touch_peer_voice_slot, track_peer_group_dmrd, ) sys_cfg = {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 10, "MODE": "MASTER", "MAX_PEERS": 8} config = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": sys_cfg}} peer_id = _peer_id() peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"} ctx = DownlinkContext( config=config, system_name="MASTER-A", sys_cfg=sys_cfg, peers={peer_id: peer}, status={1: {}, 2: {}}, connected_count=3, ) now = 1_000_000.0 stream = bytes_4(0x11111111) vhead = b"".join([ b"DMRD", b"\x00", bytes_3(100), bytes_3(7305), b"\x00\x00\x00\x00", bytes([0x80 | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VHEAD]), stream, ] + [b"\x00"] * 33) vterm = b"".join([ b"DMRD", b"\x00", bytes_3(100), bytes_3(7305), b"\x00\x00\x00\x00", bytes([0x80 | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VTERM]), stream, ] + [b"\x00"] * 33) track_peer_group_dmrd(ctx, peer_id, vhead, peer, pkt_time=now) track_peer_group_dmrd(ctx, peer_id, vterm, peer, pkt_time=now + 8) assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 9, ) register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now + 20) touch_peer_voice_slot( ctx, peer_id, 2, bytes_4(0x22222222), bytes_3(7305), pkt_time=now + 21, ingress=True, ) track_peer_group_dmrd(ctx, peer_id, vterm, peer, pkt_time=now + 30, from_ingress=False) assert not peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 31, ) def test_single_lock_persists_after_vterm_until_timer_expires() -> None: """SINGLE=1: ingress VTERM does not clear TIMER session; other TGs blocked until expiry.""" peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=1;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now) assert not peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 30, ) assert peer_should_receive_group_voice( peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 61, ) def test_single_allows_dynamic_same_tg_while_local_ua() -> None: """SINGLE=1 UA on a dynamic TG: downlink on the same TG must pass. A local PTT that activates a dynamic TG (not in OPTIONS) proves the peer wants to listen to that TG. Blocking it would make the peer deaf to the TG it just activated — the OBP/HBP round-trip regression. """ from adn_server.application.routing.helpers import peer_single_blocks_foreign_same_tg_downlink peer = {"OPTIONS": b"TS2=730500,730508;SINGLE=1;TIMER=60;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 730502, sys_cfg, now=now) assert not peer_single_blocks_foreign_same_tg_downlink( peer, peer_id, 2, bytes_3(730502), None, sys_cfg, now=now + 10, ) def test_single_allows_static_same_tg_cross_rx_after_local_ptt() -> None: """Static TG in OPTIONS: network downlink on same TG after local PTT (cross-rx).""" from adn_server.application.routing.helpers import peer_single_blocks_foreign_same_tg_downlink peer = {"OPTIONS": b"TS1=730500;TS2=730501,730502,730503;SINGLE=1;TIMER=60;"} sys_cfg = _sys_cfg() peer_id = _peer_id() now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 1, 730500, sys_cfg, now=now) assert not peer_single_blocks_foreign_same_tg_downlink( peer, peer_id, 1, bytes_3(730500), None, sys_cfg, now=now + 10, ) def test_downlink_vterm_does_not_clear_local_ua_session() -> None: """Network VTERM on session TG must not wipe a local PTT lock (TIMER session).""" from adn_server.application.routing.downlink import DownlinkContext, track_peer_group_dmrd sys_cfg = {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 10, "MODE": "MASTER", "MAX_PEERS": 8} config = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": sys_cfg}} peer_id = _peer_id() peer = {"OPTIONS": b"TS2=730500,730508;SINGLE=1;TIMER=60;"} ctx = DownlinkContext( config=config, system_name="MASTER-A", sys_cfg=sys_cfg, peers={peer_id: peer}, status={1: {}, 2: {}}, connected_count=3, ) now = 1_000_000.0 register_peer_ua_session(peer, peer_id, 2, 730502, sys_cfg, now=now, source="local") stream = bytes_4(0x11111111) vterm = b"".join([ b"DMRD", b"\x00", bytes_3(100), bytes_3(730502), b"\x00\x00\x00\x00", bytes([0x80 | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VTERM]), stream, ] + [b"\x00"] * 33) track_peer_group_dmrd(ctx, peer_id, vterm, peer, pkt_time=now + 8) assert not peer_should_receive_group_voice( peer, 2, 730500, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 9, )