fix: forward OBP group voice to an HBP target only once per TG

A BRIDGES scan can legitimately list the same MASTER on both TS1 and
TS2 for one TG (e.g. an inject-only proxy whose connected hotspots
collectively use both slots). SubscriptionRouter.resolve() kept both
as distinct legs, so dmrd_received called send_to_system twice for
that MASTER per source frame.

Legacy's dumb send_peers() broadcast made that harmless: each
hotspot's own radio silently dropped the slot it didn't want. This
server's send_peers() instead resolves every peer's real listen slot
from its own OPTIONS regardless of the wire slot
(iter_downlink_voice_slots), so the second leg delivered the same
audio a second time to every subscribed peer - doubling the downlink
rate and making unpaced bridges (e.g. ysf2dmr) sound slow. Only
OBP-sourced calls hit this: a MASTER never forwards to itself, so
locally-originated (HBP) calls never produced a redundant leg.

Collapse forward legs to a MASTER/PEER target down to one per
(target_system, target_tgid), keeping distinct legs for OpenBridge
targets and for legitimate TG-translation legs to the same target on
a different TGID.
pull/57/head
Rodrigo Pérez 2 months ago
parent 747d0c7a84
commit 6a6601055e

@ -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(

@ -0,0 +1,110 @@
# ADN DMR Peer Server - OBP to HBP target dual-slot dedup
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# 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)
Loading…
Cancel
Save

Powered by TurnKey Linux.