Merge pull request #2 from ce5rpy/fix/legacy-cross-slot-tg-downlink

Fix/legacy cross slot tg downlink
pull/4/head
ce5rpy 3 months ago committed by GitHub
commit a1114f9c23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -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" }

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

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

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

@ -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;"}

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

Loading…
Cancel
Save

Powered by TurnKey Linux.