diff --git a/src/adn_server/application/routing_use_cases.py b/src/adn_server/application/routing_use_cases.py index f7257b9..8fee588 100644 --- a/src/adn_server/application/routing_use_cases.py +++ b/src/adn_server/application/routing_use_cases.py @@ -443,6 +443,27 @@ class RoutingUseCases( bridge_match_slot=bridge_match_slot, dst_int=dst_int, ) + # A BRIDGES scan (legacy parity, kept in SubscriptionRouter.resolve()) can list the + # same MASTER/PEER target on both TS1 and TS2 (e.g. an inject-only proxy whose + # connected hotspots collectively use both slots for one TG). Legacy's dumb + # send_peers() broadcast made that harmless — each hotspot's own radio dropped the + # slot it didn't want. This server's send_peers() instead resolves each peer's + # actual listen slot from its OPTIONS (iter_downlink_voice_slots) regardless of the + # wire slot, so a second identical leg to the same target delivers the same audio + # twice — doubling the downlink rate and making unpaced bridges (e.g. ysf2dmr) sound + # slow. OpenBridge targets keep distinct per-slot legs (real separate links); collapse + # only same-(target, translated TGID) duplicates for MASTER/PEER targets. + if forward_legs: + _seen_hbp_leg: set[tuple[str, int]] = set() + _deduped_legs = [] + for _leg in forward_legs: + if systems_cfg.get(_leg.target_system, {}).get("MODE") != "OPENBRIDGE": + _hbp_key = (_leg.target_system, int(_leg.target_tgid)) + if _hbp_key in _seen_hbp_leg: + continue + _seen_hbp_leg.add(_hbp_key) + _deduped_legs.append(_leg) + forward_legs = tuple(_deduped_legs) _tx_report_peer = int_id(peer_id) if not source_is_obp: _tx_report_peer = int_id( diff --git a/tests/routing/test_obp_hbp_target_slot_dedup.py b/tests/routing/test_obp_hbp_target_slot_dedup.py new file mode 100644 index 0000000..5c6830d --- /dev/null +++ b/tests/routing/test_obp_hbp_target_slot_dedup.py @@ -0,0 +1,110 @@ +# ADN DMR Peer Server - OBP to HBP target dual-slot dedup +# +# 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 +############################################################################### + +"""OBP-sourced group voice must not double-forward to a MASTER target that has +active bridge legs on both TS1 and TS2 for the same TG. + +Reproduces the reported bug: an inject-only MASTER aggregates static TGs from +every connected peer's OPTIONS (some listening on TS1, some on TS2), so its +BRIDGES table legitimately lists the MASTER as an active leg on both slots for +one TG. ``send_peers`` already resolves each peer's real listen slot from its +own OPTIONS regardless of the wire slot (``iter_downlink_voice_slots``), so a +second identical leg to that MASTER delivered the same audio twice — doubling +the downlink rate and making unpaced bridges (e.g. ysf2dmr) sound slow. This +never showed up for locally-originated (HBP) calls because a MASTER never +forwards to itself (SubscriptionRouter skips the source system). +""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_forwarded +from tests.harness.deterministic import ( + DeterministicScenario, + PacketSpec, + active_routing_table, + add_openbridge_system, + minimal_config, +) + +from adn_server.domain import bytes_3 + + +@pytest.mark.behavior +def test_obp_group_voice_forwards_once_to_hbp_target_on_both_slots() -> None: + """SYSTEM listed on TS1 + TS2 for one TG collapses to a single forward per frame.""" + config = minimal_config(("SYSTEM",)) + add_openbridge_system(config, "OBP-CL") + bridges = active_routing_table(7305, (("OBP-CL", 1), ("SYSTEM", 1), ("SYSTEM", 2))) + scenario = DeterministicScenario(config=config, routing_table=bridges) + scenario.routing._finalize_routing_state() + + base = PacketSpec(dst_id=7305, stream_id=0x28060549, slot=1) + scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base)) + scenario.inject_obp( + "OBP-CL", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + + # 2 frames in -> 2 packets out, not 4 (one per redundant TS1/TS2 leg). + assert_forwarded(scenario, "SYSTEM", count=2, dst_id=7305) + + +@pytest.mark.behavior +def test_obp_group_voice_keeps_distinct_tgid_translation_legs() -> None: + """Two legs to the same MASTER with different translated TGIDs are not collapsed.""" + config = minimal_config(("SYSTEM",)) + add_openbridge_system(config, "OBP-CL") + tg_b = bytes_3(7305) + translated_tg_b = bytes_3(9305) + bridges = { + "7305": [ + { + "SYSTEM": "OBP-CL", "TS": 1, "TGID": tg_b, "ACTIVE": True, + "TIMEOUT": 60.0, "TO_TYPE": "ON", "ON": [tg_b], "OFF": [], "RESET": [], "TIMER": 0, + }, + { + "SYSTEM": "SYSTEM", "TS": 1, "TGID": tg_b, "ACTIVE": True, + "TIMEOUT": 60.0, "TO_TYPE": "ON", "ON": [tg_b], "OFF": [], "RESET": [], "TIMER": 0, + }, + ], + "#7305": [ + { + "SYSTEM": "OBP-CL", "TS": 1, "TGID": tg_b, "ACTIVE": True, + "TIMEOUT": 60.0, "TO_TYPE": "ON", "ON": [tg_b], "OFF": [], "RESET": [], "TIMER": 0, + }, + { + "SYSTEM": "SYSTEM", "TS": 2, "TGID": translated_tg_b, "ACTIVE": True, + "TIMEOUT": 60.0, "TO_TYPE": "ON", "ON": [translated_tg_b], "OFF": [], "RESET": [], "TIMER": 0, + }, + ], + } + scenario = DeterministicScenario(config=config, routing_table=bridges) + scenario.routing._finalize_routing_state() + + base = PacketSpec(dst_id=7305, stream_id=0x28060549, slot=1) + scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base)) + scenario.inject_obp( + "OBP-CL", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + + # 2 frames, 2 distinct target TGIDs -> 4 packets (not collapsed: real distinct destinations). + assert_forwarded(scenario, "SYSTEM", count=4)