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" diff --git a/src/adn_server/application/routing/helpers.py b/src/adn_server/application/routing/helpers.py index 92b6271..6ed76d3 100644 --- a/src/adn_server/application/routing/helpers.py +++ b/src/adn_server/application/routing/helpers.py @@ -526,14 +526,28 @@ 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 + 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")) - static = ts1 if slot == 1 else ts2 - if not static: - return False - return str(tgid) in static + tg = str(tgid) + 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 peer_single_blocks_uplink( @@ -589,7 +603,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..0a594ee 100644 --- a/tests/application/test_peer_single_downlink.py +++ b/tests/application/test_peer_single_downlink.py @@ -29,6 +29,8 @@ 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, peer_single_blocks_group_voice, peer_single_blocks_uplink, @@ -46,6 +48,14 @@ 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;"} 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)