From 64c0b9ab7f67c6eaefedb963e0a9fb09b41e5e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Tue, 16 Jun 2026 00:58:59 -0400 Subject: [PATCH 1/5] fix(routing): match legacy cross-slot TG downlink for hotspots Hotspots with a TG in TS1 or TS2 OPTIONS should receive group voice regardless of incoming timeslot, matching legacy REPEAT fan-out. --- src/adn_server/application/routing/helpers.py | 15 +++++++++------ .../application/routing/peer_downlink_index.py | 10 +++++++--- tests/application/test_peer_single_downlink.py | 8 ++++++++ tests/routing/test_peer_downlink_index.py | 9 +++++++++ 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/adn_server/application/routing/helpers.py b/src/adn_server/application/routing/helpers.py index 92b6271..d69c18e 100644 --- a/src/adn_server/application/routing/helpers.py +++ b/src/adn_server/application/routing/helpers.py @@ -526,14 +526,17 @@ def peer_single_blocks_group_voice( def peer_receives_group_tgid(peer: dict[str, Any], slot: int, tgid: int) -> bool: - """True when peer RPTO OPTIONS list the group TG on that voice timeslot.""" + """True when peer RPTO OPTIONS list the group TG on TS1 or TS2 (legacy REPEAT parity). + + Voice may arrive on either timeslot; hotspots in repeater mode often use one RF + slot while self-service lists the TG on the other. + """ + del slot from adn_server.application.report.payloads import parse_peer_options_static ts1, ts2 = parse_peer_options_static(peer.get("OPTIONS")) - static = ts1 if slot == 1 else ts2 - if not static: - return False - return str(tgid) in static + tg = str(tgid) + return tg in ts1 or tg in ts2 def peer_single_blocks_uplink( @@ -589,7 +592,7 @@ def peer_should_receive_group_voice( Inject-only multi-hotspot rules (per peer): 1. ``SINGLE=1`` with an active session on another TG → deny all other TGs. - 2. TG in this peer's OPTIONS static list → allow (when not blocked by SINGLE). + 2. TG in this peer's OPTIONS static list (TS1 or TS2) → allow (when not blocked by SINGLE). 3. ``SINGLE=1``: dynamic UA owned by this peer's exclusive session → allow. 4. ``SINGLE=0``: dynamic UA this peer keyed (multi set) → allow. 5. Sole connected hotspot with an ACTIVE bridge leg for ``(slot, tgid)`` → allow. diff --git a/src/adn_server/application/routing/peer_downlink_index.py b/src/adn_server/application/routing/peer_downlink_index.py index 0c2125f..69bccb1 100644 --- a/src/adn_server/application/routing/peer_downlink_index.py +++ b/src/adn_server/application/routing/peer_downlink_index.py @@ -71,10 +71,14 @@ class PeerDownlinkIndex: def candidates(self, slot: int, tgid: int, *, connected_count: int) -> frozenset[bytes]: if connected_count == 1: return self.connected + voice_slot = int(slot) + other_slot = 3 - voice_slot + tg = int(tgid) out: set[bytes] = set() - key = (int(slot), int(tgid)) - out.update(self.static_by_slot_tgid.get(key, ())) - out.update(self.ua_by_slot_tgid.get(key, ())) + for ts in (voice_slot, other_slot): + key = (ts, tg) + out.update(self.static_by_slot_tgid.get(key, ())) + out.update(self.ua_by_slot_tgid.get(key, ())) return frozenset(out) diff --git a/tests/application/test_peer_single_downlink.py b/tests/application/test_peer_single_downlink.py index 3078175..1bc8877 100644 --- a/tests/application/test_peer_single_downlink.py +++ b/tests/application/test_peer_single_downlink.py @@ -29,6 +29,7 @@ from adn_server.application.routing.helpers import ( clear_peer_ua_sessions, tg4000_reset_on_vhead, clear_peer_rx_status_slots, + peer_receives_group_tgid, peer_should_receive_group_voice, peer_single_blocks_group_voice, peer_single_blocks_uplink, @@ -46,6 +47,13 @@ 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_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;"} diff --git a/tests/routing/test_peer_downlink_index.py b/tests/routing/test_peer_downlink_index.py index 64fc8e4..e5b3553 100644 --- a/tests/routing/test_peer_downlink_index.py +++ b/tests/routing/test_peer_downlink_index.py @@ -55,6 +55,15 @@ def test_single_connected_peer_returns_all() -> None: assert idx.candidates(2, 52090, connected_count=1) == frozenset({p1}) +def test_static_tg_on_opposite_slot_is_candidate() -> None: + """Legacy REPEAT: TG in TS1 OPTIONS still receives voice on TS2.""" + p1 = bytes_4(730001) + peers = {p1: _peer("TS1=730170;")} + idx = build_peer_downlink_index(peers, {}) + assert p1 in idx.candidates(2, 730170, connected_count=5) + assert p1 not in idx.candidates(2, 52090, connected_count=5) + + def test_options_cache_invalidates_on_rpto() -> None: peer = _peer("TS2=52090;") cached_peer_static_tgs(peer) From 9ef574538cae1bc874387392e52a1b526350ba52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Tue, 16 Jun 2026 01:06:53 -0400 Subject: [PATCH 2/5] fix(routing): remap DMRD slot to peer OPTIONS static TS on downlink When voice arrives on TS2 but the hotspot lists the TG on TS1 (or vice versa), flip the slot bit on send_peer so duplex radios RX on the configured timeslot. --- src/adn_server/application/routing/helpers.py | 31 ++++++++++++++++++- .../twisted_adapters/udp_hbp.py | 4 +++ .../application/test_peer_single_downlink.py | 30 ++++++++++++++++++ .../test_hbp_repeat_options_filter.py | 28 +++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/adn_server/application/routing/helpers.py b/src/adn_server/application/routing/helpers.py index d69c18e..5bd9f59 100644 --- a/src/adn_server/application/routing/helpers.py +++ b/src/adn_server/application/routing/helpers.py @@ -532,11 +532,40 @@ def peer_receives_group_tgid(peer: dict[str, Any], slot: int, tgid: int) -> bool slot while self-service lists the TG on the other. """ del slot + return peer_options_static_tg_slot(peer, tgid) is not None + + +def peer_options_static_tg_slot(peer: dict[str, Any], tgid: int) -> int | None: + """Timeslot (1 or 2) where peer OPTIONS list ``tgid``, when unambiguous.""" from adn_server.application.report.payloads import parse_peer_options_static ts1, ts2 = parse_peer_options_static(peer.get("OPTIONS")) tg = str(tgid) - return tg in ts1 or tg in ts2 + in_ts1 = tg in ts1 + in_ts2 = tg in ts2 + if in_ts1 and not in_ts2: + return 1 + if in_ts2 and not in_ts1: + return 2 + return None + + +def remap_dmrd_to_peer_static_slot(packet: bytes, peer: dict[str, Any]) -> bytes: + """Flip DMRD slot bit when voice arrives on the opposite TS from OPTIONS static TG.""" + parsed = parse_dmrd_route_fields(packet) + if parsed is None: + return packet + voice_slot, tgid, call_type = parsed + if call_type not in ("group", "vcsbk"): + return packet + if is_special_tg(str(tgid)): + return packet + cfg_slot = peer_options_static_tg_slot(peer, tgid) + if cfg_slot is None or cfg_slot == voice_slot: + return packet + bits = packet[15] + new_bits = bits ^ (1 << 7) + return packet[:15] + bytes([new_bits]) + packet[16:] def peer_single_blocks_uplink( diff --git a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py index 013c696..7afdab4 100644 --- a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py +++ b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py @@ -48,6 +48,7 @@ from ...application.routing.helpers import ( parse_dmrd_route_fields, peer_matches_rf_source, peer_should_receive_group_voice, + remap_dmrd_to_peer_static_slot, peer_single_exclusive_tgid, register_peer_ua_session, seed_peer_ua_session_from_status, @@ -471,6 +472,9 @@ class HBPProtocol(DatagramProtocol): if _packet[:4] == DMRD: if not self._peer_should_receive_dmrd(_peer, _packet): return + peer = self._peers.get(_peer) + if peer is not None: + _packet = remap_dmrd_to_peer_static_slot(_packet, peer) _packet = b"".join([_packet[:11], _peer, _packet[15:]]) self.transport.write(_packet, self._peers[_peer]["SOCKADDR"]) diff --git a/tests/application/test_peer_single_downlink.py b/tests/application/test_peer_single_downlink.py index 1bc8877..3b68231 100644 --- a/tests/application/test_peer_single_downlink.py +++ b/tests/application/test_peer_single_downlink.py @@ -29,8 +29,10 @@ from adn_server.application.routing.helpers import ( clear_peer_ua_sessions, tg4000_reset_on_vhead, clear_peer_rx_status_slots, + peer_options_static_tg_slot, peer_receives_group_tgid, peer_should_receive_group_voice, + remap_dmrd_to_peer_static_slot, peer_single_blocks_group_voice, peer_single_blocks_uplink, register_peer_ua_multi_tg, @@ -51,9 +53,37 @@ 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_remap_dmrd_flips_slot_to_options_static_ts() -> None: + from tests.harness.deterministic import PacketSpec, DeterministicScenario + + peer = {"OPTIONS": b"TS1=73010;"} + burst = DeterministicScenario.voice_burst_spec( + PacketSpec(dst_id=73010, slot=2, peer_id=730044401, rf_src=7300444), + seq=1, + dtype_vseq=1, + ).data() + assert burst[15] & 0x80 + remapped = remap_dmrd_to_peer_static_slot(burst, peer) + assert not (remapped[15] & 0x80) + + +def test_remap_dmrd_keeps_slot_when_options_match_voice_ts() -> None: + from tests.harness.deterministic import PacketSpec, DeterministicScenario + + peer = {"OPTIONS": b"TS2=73010;"} + burst = DeterministicScenario.voice_burst_spec( + PacketSpec(dst_id=73010, slot=2, peer_id=730044401, rf_src=7300444), + seq=1, + dtype_vseq=1, + ).data() + remapped = remap_dmrd_to_peer_static_slot(burst, peer) + assert remapped[15] == burst[15] + + 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;"} diff --git a/tests/infrastructure/test_hbp_repeat_options_filter.py b/tests/infrastructure/test_hbp_repeat_options_filter.py index a5cd291..e84e45c 100644 --- a/tests/infrastructure/test_hbp_repeat_options_filter.py +++ b/tests/infrastructure/test_hbp_repeat_options_filter.py @@ -62,6 +62,34 @@ def _voice_burst() -> bytes: return DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1).data() +def test_repeat_remaps_slot_to_peer_options_ts() -> None: + """Duplex on TS1=73010 must receive TS2 ingress on TS1 (slot bit flip).""" + stack = build_hbp_repeat_stack(talker_alias=False, system_name="MASTER-A") + stack.config["PROXY"] = {"TARGET_SYSTEM": "MASTER-A"} + stack.hbp._CONFIG = stack.config + duplex = bytes_4(730001) + simplex = bytes_4(730002) + addr_duplex = ("10.0.0.30", 62030) + addr_simplex = ("10.0.0.31", 62031) + stack.register_peer(simplex, addr_simplex, options="TS2=73010;") + stack.register_peer(duplex, addr_duplex, options="TS1=73010;") + + spec = PacketSpec( + peer_id=int.from_bytes(simplex, "big"), + rf_src=730002, + dst_id=73010, + slot=2, + stream_id=0x22334455, + payload=b"\x00" * 33, + ) + burst = DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1).data() + stack.inject(burst, addr_simplex) + + duplex_pkts = [p for p in stack.transport.for_addr(addr_duplex) if p[:4] == DMRD] + assert len(duplex_pkts) == 1 + assert not (duplex_pkts[0][15] & 0x80) + + def test_repeat_only_reaches_peers_with_matching_options() -> None: stack = _inject_proxy_stack() stack.inject(_voice_burst(), _ADDR_TX) From 7701a699623f2a603131ae8e4a6c545863d69201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Tue, 16 Jun 2026 01:12:49 -0400 Subject: [PATCH 3/5] fix(report): remap BRDG_EVENT slot for cross-slot static TG downlink Monitor uses CSV field 7 for TS1/TS2 chips; REPEAT and bridge TX fan-out now report the peer OPTIONS static timeslot, matching DMRD slot remap. --- .../application/report/monitor_topology.py | 14 ++++++++----- src/adn_server/application/routing/helpers.py | 8 +++++++ tests/application/test_monitor_topology.py | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/adn_server/application/report/monitor_topology.py b/src/adn_server/application/report/monitor_topology.py index 35a9beb..d63e930 100644 --- a/src/adn_server/application/report/monitor_topology.py +++ b/src/adn_server/application/report/monitor_topology.py @@ -31,7 +31,7 @@ from __future__ import annotations import copy from typing import Any -from adn_server.application.routing.helpers import peer_should_receive_group_voice +from adn_server.application.routing.helpers import peer_downlink_voice_slot, peer_should_receive_group_voice from adn_server.application.proxy.deployment import is_proxy_inject_only, proxy_target_system from adn_server.domain.value_objects import bytes_4, int_id @@ -350,13 +350,15 @@ def remap_inject_proxy_voice_events( if not receivers: return [event] remapped: list[str] = [] - for peer_key, _peer in receivers: + for peer_key, peer in receivers: mapped_slot = slot_map.get(peer_key) if mapped_slot is None: continue + peer_parts = list(parts) + peer_parts[7] = str(peer_downlink_voice_slot(peer, voice_slot, tgid)) remapped.append( _remap_voice_event_to_slot( - parts, target=target, slot=mapped_slot, peer_key=peer_key + peer_parts, target=target, slot=mapped_slot, peer_key=peer_key ) ) return remapped if remapped else [event] @@ -379,7 +381,7 @@ def remap_inject_proxy_voice_events( tx_parts = list(parts) tx_parts[2] = "TX" tx_parts[5] = str(int_id(peer_key)) - for other_key, _peer in _peers_receiving_tgid( + for other_key, other_peer in _peers_receiving_tgid( connected, slot=voice_slot, tgid=tgid, @@ -391,9 +393,11 @@ def remap_inject_proxy_voice_events( other_slot = slot_map.get(other_key) if other_slot is None: continue + peer_tx_parts = list(tx_parts) + peer_tx_parts[7] = str(peer_downlink_voice_slot(other_peer, voice_slot, tgid)) results.append( _remap_voice_event_to_slot( - tx_parts, + peer_tx_parts, target=target, slot=other_slot, peer_key=other_key, diff --git a/src/adn_server/application/routing/helpers.py b/src/adn_server/application/routing/helpers.py index 5bd9f59..737b53b 100644 --- a/src/adn_server/application/routing/helpers.py +++ b/src/adn_server/application/routing/helpers.py @@ -550,6 +550,14 @@ def peer_options_static_tg_slot(peer: dict[str, Any], tgid: int) -> int | None: return None +def peer_downlink_voice_slot(peer: dict[str, Any], voice_slot: int, tgid: int) -> int: + """Timeslot for downlink DMRD and monitor BRDG_EVENT for this peer.""" + cfg_slot = peer_options_static_tg_slot(peer, tgid) + if cfg_slot is not None: + return cfg_slot + return int(voice_slot) + + def remap_dmrd_to_peer_static_slot(packet: bytes, peer: dict[str, Any]) -> bytes: """Flip DMRD slot bit when voice arrives on the opposite TS from OPTIONS static TG.""" parsed = parse_dmrd_route_fields(packet) diff --git a/tests/application/test_monitor_topology.py b/tests/application/test_monitor_topology.py index 3770174..fe3d986 100644 --- a/tests/application/test_monitor_topology.py +++ b/tests/application/test_monitor_topology.py @@ -221,6 +221,27 @@ def test_local_hotspot_rx_fans_out_tx_only_to_peers_with_matching_tg() -> None: assert "SYSTEM-4" not in by_system +def test_repeat_companion_tx_uses_peer_options_static_slot() -> None: + """Duplex TS1=73010 must show monitor TX on TS1 when simplex keys TS2.""" + duplex = bytes_4(730001) + simplex = bytes_4(730002) + peers = { + simplex: _peer(options=b"TS2=73010;"), + duplex: _peer(options=b"TS1=73010;"), + } + config = _proxy_config(peers) + peer_slots = {simplex: 0, duplex: 1} + raw = "GROUP VOICE,START,RX,SYSTEM,3262598598,730002,730002,2,73010" + events = remap_inject_proxy_voice_events( + raw, config, config["SYSTEMS"], peer_slots + ) + assert len(events) == 2 + by_system = {ev.split(",")[3]: ev for ev in events} + duplex_tx = by_system["SYSTEM-1"] + assert duplex_tx.startswith("GROUP VOICE,START,TX,SYSTEM-1,") + assert duplex_tx.split(",")[7] == "1" + + def test_obp_tx_single_hotspot_remaps_dynamic_tg_not_in_static() -> None: """One HS online: downlink/monitor must remap TX even when TG is UA-only (not in OPTIONS).""" peer = bytes_4(730039101) From cb56f5b1cfa4c7d8bfc706e9e20e2d97460e2b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Tue, 16 Jun 2026 01:18:14 -0400 Subject: [PATCH 4/5] revert: drop server-side slot remap; keep cross-slot TG eligibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire and BRDG_EVENT keep the real transmit timeslot. Display mapping belongs in adn-monitor (peer OPTIONS static TG → chip slot). --- .../application/report/monitor_topology.py | 14 ++++------ src/adn_server/application/routing/helpers.py | 26 ----------------- .../twisted_adapters/udp_hbp.py | 4 --- tests/application/test_monitor_topology.py | 21 -------------- .../application/test_peer_single_downlink.py | 28 ------------------- .../test_hbp_repeat_options_filter.py | 28 ------------------- 6 files changed, 5 insertions(+), 116 deletions(-) diff --git a/src/adn_server/application/report/monitor_topology.py b/src/adn_server/application/report/monitor_topology.py index d63e930..35a9beb 100644 --- a/src/adn_server/application/report/monitor_topology.py +++ b/src/adn_server/application/report/monitor_topology.py @@ -31,7 +31,7 @@ from __future__ import annotations import copy from typing import Any -from adn_server.application.routing.helpers import peer_downlink_voice_slot, peer_should_receive_group_voice +from adn_server.application.routing.helpers import peer_should_receive_group_voice from adn_server.application.proxy.deployment import is_proxy_inject_only, proxy_target_system from adn_server.domain.value_objects import bytes_4, int_id @@ -350,15 +350,13 @@ def remap_inject_proxy_voice_events( if not receivers: return [event] remapped: list[str] = [] - for peer_key, peer in receivers: + for peer_key, _peer in receivers: mapped_slot = slot_map.get(peer_key) if mapped_slot is None: continue - peer_parts = list(parts) - peer_parts[7] = str(peer_downlink_voice_slot(peer, voice_slot, tgid)) remapped.append( _remap_voice_event_to_slot( - peer_parts, target=target, slot=mapped_slot, peer_key=peer_key + parts, target=target, slot=mapped_slot, peer_key=peer_key ) ) return remapped if remapped else [event] @@ -381,7 +379,7 @@ def remap_inject_proxy_voice_events( tx_parts = list(parts) tx_parts[2] = "TX" tx_parts[5] = str(int_id(peer_key)) - for other_key, other_peer in _peers_receiving_tgid( + for other_key, _peer in _peers_receiving_tgid( connected, slot=voice_slot, tgid=tgid, @@ -393,11 +391,9 @@ def remap_inject_proxy_voice_events( other_slot = slot_map.get(other_key) if other_slot is None: continue - peer_tx_parts = list(tx_parts) - peer_tx_parts[7] = str(peer_downlink_voice_slot(other_peer, voice_slot, tgid)) results.append( _remap_voice_event_to_slot( - peer_tx_parts, + tx_parts, target=target, slot=other_slot, peer_key=other_key, diff --git a/src/adn_server/application/routing/helpers.py b/src/adn_server/application/routing/helpers.py index 737b53b..6ed76d3 100644 --- a/src/adn_server/application/routing/helpers.py +++ b/src/adn_server/application/routing/helpers.py @@ -550,32 +550,6 @@ def peer_options_static_tg_slot(peer: dict[str, Any], tgid: int) -> int | None: return None -def peer_downlink_voice_slot(peer: dict[str, Any], voice_slot: int, tgid: int) -> int: - """Timeslot for downlink DMRD and monitor BRDG_EVENT for this peer.""" - cfg_slot = peer_options_static_tg_slot(peer, tgid) - if cfg_slot is not None: - return cfg_slot - return int(voice_slot) - - -def remap_dmrd_to_peer_static_slot(packet: bytes, peer: dict[str, Any]) -> bytes: - """Flip DMRD slot bit when voice arrives on the opposite TS from OPTIONS static TG.""" - parsed = parse_dmrd_route_fields(packet) - if parsed is None: - return packet - voice_slot, tgid, call_type = parsed - if call_type not in ("group", "vcsbk"): - return packet - if is_special_tg(str(tgid)): - return packet - cfg_slot = peer_options_static_tg_slot(peer, tgid) - if cfg_slot is None or cfg_slot == voice_slot: - return packet - bits = packet[15] - new_bits = bits ^ (1 << 7) - return packet[:15] + bytes([new_bits]) + packet[16:] - - def peer_single_blocks_uplink( peer: dict[str, Any], peer_id: bytes, diff --git a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py index 7afdab4..013c696 100644 --- a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py +++ b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py @@ -48,7 +48,6 @@ from ...application.routing.helpers import ( parse_dmrd_route_fields, peer_matches_rf_source, peer_should_receive_group_voice, - remap_dmrd_to_peer_static_slot, peer_single_exclusive_tgid, register_peer_ua_session, seed_peer_ua_session_from_status, @@ -472,9 +471,6 @@ class HBPProtocol(DatagramProtocol): if _packet[:4] == DMRD: if not self._peer_should_receive_dmrd(_peer, _packet): return - peer = self._peers.get(_peer) - if peer is not None: - _packet = remap_dmrd_to_peer_static_slot(_packet, peer) _packet = b"".join([_packet[:11], _peer, _packet[15:]]) self.transport.write(_packet, self._peers[_peer]["SOCKADDR"]) diff --git a/tests/application/test_monitor_topology.py b/tests/application/test_monitor_topology.py index fe3d986..3770174 100644 --- a/tests/application/test_monitor_topology.py +++ b/tests/application/test_monitor_topology.py @@ -221,27 +221,6 @@ def test_local_hotspot_rx_fans_out_tx_only_to_peers_with_matching_tg() -> None: assert "SYSTEM-4" not in by_system -def test_repeat_companion_tx_uses_peer_options_static_slot() -> None: - """Duplex TS1=73010 must show monitor TX on TS1 when simplex keys TS2.""" - duplex = bytes_4(730001) - simplex = bytes_4(730002) - peers = { - simplex: _peer(options=b"TS2=73010;"), - duplex: _peer(options=b"TS1=73010;"), - } - config = _proxy_config(peers) - peer_slots = {simplex: 0, duplex: 1} - raw = "GROUP VOICE,START,RX,SYSTEM,3262598598,730002,730002,2,73010" - events = remap_inject_proxy_voice_events( - raw, config, config["SYSTEMS"], peer_slots - ) - assert len(events) == 2 - by_system = {ev.split(",")[3]: ev for ev in events} - duplex_tx = by_system["SYSTEM-1"] - assert duplex_tx.startswith("GROUP VOICE,START,TX,SYSTEM-1,") - assert duplex_tx.split(",")[7] == "1" - - def test_obp_tx_single_hotspot_remaps_dynamic_tg_not_in_static() -> None: """One HS online: downlink/monitor must remap TX even when TG is UA-only (not in OPTIONS).""" peer = bytes_4(730039101) diff --git a/tests/application/test_peer_single_downlink.py b/tests/application/test_peer_single_downlink.py index 3b68231..0a594ee 100644 --- a/tests/application/test_peer_single_downlink.py +++ b/tests/application/test_peer_single_downlink.py @@ -32,7 +32,6 @@ from adn_server.application.routing.helpers import ( peer_options_static_tg_slot, peer_receives_group_tgid, peer_should_receive_group_voice, - remap_dmrd_to_peer_static_slot, peer_single_blocks_group_voice, peer_single_blocks_uplink, register_peer_ua_multi_tg, @@ -57,33 +56,6 @@ def test_static_tg_on_opposite_slot_receives_group_voice() -> None: assert peer_should_receive_group_voice(peer, 2, 730170, connected_count=8) -def test_remap_dmrd_flips_slot_to_options_static_ts() -> None: - from tests.harness.deterministic import PacketSpec, DeterministicScenario - - peer = {"OPTIONS": b"TS1=73010;"} - burst = DeterministicScenario.voice_burst_spec( - PacketSpec(dst_id=73010, slot=2, peer_id=730044401, rf_src=7300444), - seq=1, - dtype_vseq=1, - ).data() - assert burst[15] & 0x80 - remapped = remap_dmrd_to_peer_static_slot(burst, peer) - assert not (remapped[15] & 0x80) - - -def test_remap_dmrd_keeps_slot_when_options_match_voice_ts() -> None: - from tests.harness.deterministic import PacketSpec, DeterministicScenario - - peer = {"OPTIONS": b"TS2=73010;"} - burst = DeterministicScenario.voice_burst_spec( - PacketSpec(dst_id=73010, slot=2, peer_id=730044401, rf_src=7300444), - seq=1, - dtype_vseq=1, - ).data() - remapped = remap_dmrd_to_peer_static_slot(burst, peer) - assert remapped[15] == burst[15] - - 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;"} diff --git a/tests/infrastructure/test_hbp_repeat_options_filter.py b/tests/infrastructure/test_hbp_repeat_options_filter.py index e84e45c..a5cd291 100644 --- a/tests/infrastructure/test_hbp_repeat_options_filter.py +++ b/tests/infrastructure/test_hbp_repeat_options_filter.py @@ -62,34 +62,6 @@ def _voice_burst() -> bytes: return DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1).data() -def test_repeat_remaps_slot_to_peer_options_ts() -> None: - """Duplex on TS1=73010 must receive TS2 ingress on TS1 (slot bit flip).""" - stack = build_hbp_repeat_stack(talker_alias=False, system_name="MASTER-A") - stack.config["PROXY"] = {"TARGET_SYSTEM": "MASTER-A"} - stack.hbp._CONFIG = stack.config - duplex = bytes_4(730001) - simplex = bytes_4(730002) - addr_duplex = ("10.0.0.30", 62030) - addr_simplex = ("10.0.0.31", 62031) - stack.register_peer(simplex, addr_simplex, options="TS2=73010;") - stack.register_peer(duplex, addr_duplex, options="TS1=73010;") - - spec = PacketSpec( - peer_id=int.from_bytes(simplex, "big"), - rf_src=730002, - dst_id=73010, - slot=2, - stream_id=0x22334455, - payload=b"\x00" * 33, - ) - burst = DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1).data() - stack.inject(burst, addr_simplex) - - duplex_pkts = [p for p in stack.transport.for_addr(addr_duplex) if p[:4] == DMRD] - assert len(duplex_pkts) == 1 - assert not (duplex_pkts[0][15] & 0x80) - - def test_repeat_only_reaches_peers_with_matching_options() -> None: stack = _inject_proxy_stack() stack.inject(_voice_burst(), _ADDR_TX) From a088d9fb63ac2cb736aca8b39aecef1a8507b9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Tue, 16 Jun 2026 01:28:05 -0400 Subject: [PATCH 5/5] chore: bump version to 2.0.0-rc.2 Cross-slot static TG downlink eligibility for inject-only hotspots. --- CHANGELOG.md | 10 +++++++++- pyproject.toml | 2 +- src/adn_server/__init__.py | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de9b695..e7ef748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,15 @@ All notable changes to **adn-server** are documented here. -## [Unreleased] +## [2.0.0-rc.2] - 2026-06-16 + +### Fixed + +- **Cross-slot static TG downlink** (legacy REPEAT parity): inject-only MASTER delivers group voice to hotspots when the TG is listed in TS1 or TS2 OPTIONS, regardless of incoming wire timeslot. Wire slot is unchanged; downlink index and `peer_should_receive_group_voice` match both OPTIONS lists. + +### Compatibility + +- **Monitor:** adn-monitor **2.0.0-rc.2** (TS chip maps OPTIONS static slot on receive). ## [2.0.0-rc.1] - 2026-06-12 diff --git a/pyproject.toml b/pyproject.toml index e161363..55ed987 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "adn-server" -version = "2.0.0-rc.1" +version = "2.0.0-rc.2" description = "ADN DMR Peer Server" readme = "README.md" license = { text = "GPL-3.0-or-later" } diff --git a/src/adn_server/__init__.py b/src/adn_server/__init__.py index 91b8a2a..a2f311e 100644 --- a/src/adn_server/__init__.py +++ b/src/adn_server/__init__.py @@ -37,4 +37,4 @@ """ADN DMR Peer Server — conference bridge (rewrite of bridge_master).""" -__version__ = "2.0.0-rc.1" +__version__ = "2.0.0-rc.2"