diff --git a/src/adn_server/application/routing/downlink.py b/src/adn_server/application/routing/downlink.py index 0f40bd1..e8e90fc 100644 --- a/src/adn_server/application/routing/downlink.py +++ b/src/adn_server/application/routing/downlink.py @@ -91,11 +91,12 @@ def normalize_ua_voice_slot(peer: dict[str, Any], wire_slot: int) -> int: def peer_listen_slots(peer: dict[str, Any], tgid: int) -> list[int]: - """Candidate RF slots where this peer is subscribed to ``tgid`` (OPTIONS). + """RF slots where this peer listens for ``tgid`` (static OPTIONS or wire fallback). - When the same TG is listed on both TS1 and TS2, both slots are returned as - candidates; :func:`iter_downlink_voice_slots` collapses that to **one** - delivery slot (never duplicate DMRD). + A genuinely duplex-capable peer (see ``peer_is_simplex``) with the same TG + listed on both TS1 and TS2 gets both slots back -- it has two independent + RF timeslots and is expected to key up on both, same as a real repeater + configured that way. Simplex peers/bridges always collapse to one slot. """ from adn_server.application.report.payloads import parse_peer_options_static @@ -656,18 +657,14 @@ def iter_downlink_voice_slots( wire_slot: int, tgid: int, ) -> list[int]: - """Exactly one voice slot for group downlink to this peer. + """Voice slot(s) to deliver a group downlink to this peer. - Legacy ``send_peers`` sends one DMRD per peer. If OPTIONS lists the same TG - on TS1 and TS2, keep a single copy on the bridge/OBP wire slot (remap only - when the peer listens on a different unambiguous slot). + Trusts ``peer_listen_slots`` -- it already collapses simplex peers/bridges + to one slot via ``peer_is_simplex``, and only returns more than one slot + for a peer confirmed duplex-capable with the TG on both TS1 and TS2, which + should genuinely receive on both (see peer_listen_slots docstring). """ listen = peer_listen_slots(peer, tgid) - if len(listen) > 1: - ws = int(wire_slot) - if ws in listen: - return [ws] - return [int(peer_downlink_voice_slot(peer, wire_slot, tgid))] if listen: return listen if peer_receives_group_tgid(peer, wire_slot, tgid): diff --git a/tests/application/test_downlink_stabilization.py b/tests/application/test_downlink_stabilization.py index 8b62c11..2ff74ad 100644 --- a/tests/application/test_downlink_stabilization.py +++ b/tests/application/test_downlink_stabilization.py @@ -170,15 +170,28 @@ def test_inject_only_hangtime_blocks_static_tg_after_dynamic_tx() -> None: assert not peer_slot_blocks_downlink(ctx, peer_id, peer, same7306, pkt_time=now + 8) -def test_same_static_tg_on_both_slots_delivers_once_on_wire_slot() -> None: - """TG on TS1+TS2 OPTIONS: one DMRD on the bridge wire slot (not dual fan-out).""" +def test_same_static_tg_on_both_slots_delivers_twice() -> None: + """Duplex peer: TG listed in TS1 and TS2 OPTIONS delivers on both voice slots.""" peer = {"OPTIONS": b"TS1=730444;TS2=730444;"} - assert iter_downlink_voice_slots(peer, 1, 730444) == [1] - assert iter_downlink_voice_slots(peer, 2, 730444) == [2] - # Subscription still visible on both; delivery collapses in iter_*. + assert iter_downlink_voice_slots(peer, 1, 730444) == [1, 2] assert peer_listen_slots(peer, 730444) == [1, 2] +def test_same_static_tg_on_both_slots_bridge_delivers_once() -> None: + """Simplex peer (bridge, e.g. ysf2dmr/adn-bridge): TG on both OPTIONS slots + still collapses to a single copy -- it has one audio pipeline, not two + independent RF timeslots, so dual-slot fan-out would double its downlink + rate (see PR #53).""" + peer = { + "OPTIONS": b"TS1=730444;TS2=730444;", + "RX_FREQ": "000000000", + "TX_FREQ": "000000000", + } + assert iter_downlink_voice_slots(peer, 1, 730444) == [2] + assert iter_downlink_voice_slots(peer, 2, 730444) == [2] + assert peer_listen_slots(peer, 730444) == [2] + + def test_static_tg_on_one_slot_unchanged() -> None: peer_ts2 = {"OPTIONS": b"TS2=9140;"} assert iter_downlink_voice_slots(peer_ts2, 1, 9140) == [2]