From 5945d6641b4056a77b678093f5fead8491263eb4 Mon Sep 17 00:00:00 2001 From: ce5rpy <169016246+ce5rpy@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:33:20 -0400 Subject: [PATCH 1/2] fix: cross-slot DMRD remap and monitor TE slot (#18) * chore(ci): sync develop via merge instead of force-push Merge master into develop after release so local checkouts can git pull without reset --hard. Try fast-forward first; merge commit after squash releases. * fix: restore cross-slot downlink and REPEAT monitor activity Cross-slot TG downlink, DMRA index parity, and REPEAT monitor START/TX reporting. * fix: remap REPEAT DMRD slot to peer OPTIONS for cross-slot downlink (#17) Flip the DMRD timeslot bit per receiver so hotspots hear group voice on the TS where the TG is configured (static OPTIONS or dynamic UA), and emit inject-proxy BRDG field 7 on that same slot for monitor TE parity. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../application/report/monitor_topology.py | 46 +++++++++-- src/adn_server/application/routing/helpers.py | 82 ++++++++++++++----- .../twisted_adapters/udp_hbp.py | 6 ++ tests/application/test_monitor_topology.py | 21 +++++ .../application/test_peer_single_downlink.py | 43 +++++++++- .../test_hbp_repeat_options_filter.py | 28 +++++++ 6 files changed, 198 insertions(+), 28 deletions(-) diff --git a/src/adn_server/application/report/monitor_topology.py b/src/adn_server/application/report/monitor_topology.py index 7f8e437..c8a8afb 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 is_special_tg, peer_should_receive_group_voice +from adn_server.application.routing.helpers import is_special_tg, 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 @@ -262,6 +262,26 @@ def _echo_tx_target_peer(parts: list[str], peers: dict[Any, Any]) -> bytes | Non return _peer_key_from_voice_csv(parts, peers) +def _voice_event_with_peer_display_slot( + parts: list[str], + *, + peer: dict[str, Any], + peer_key: bytes, + wire_slot: int, + tgid: int, + sys_cfg: dict[str, Any] | None, +) -> list[str]: + """Set BRDG CSV field 7 to the receiver's configured listen TS (monitor TE chip).""" + out = list(parts) + if len(out) > 7: + out[7] = str( + peer_downlink_voice_slot( + peer, wire_slot, tgid, sys_cfg, peer_id=peer_key, + ) + ) + return out + + def _remap_voice_event_to_slot( parts: list[str], *, @@ -361,13 +381,21 @@ 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 = _voice_event_with_peer_display_slot( + parts, + peer=peer, + peer_key=_peer_key_from_int(peer_key), + wire_slot=voice_slot, + tgid=tgid, + sys_cfg=sys_cfg, + ) 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] @@ -390,7 +418,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, peer in _peers_receiving_tgid( connected, slot=voice_slot, tgid=tgid, @@ -402,9 +430,17 @@ def remap_inject_proxy_voice_events( other_slot = slot_map.get(other_key) if other_slot is None: continue + peer_parts = _voice_event_with_peer_display_slot( + tx_parts, + peer=peer, + peer_key=_peer_key_from_int(other_key), + wire_slot=voice_slot, + tgid=tgid, + sys_cfg=sys_cfg, + ) results.append( _remap_voice_event_to_slot( - tx_parts, + peer_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 f9921ae..49b793e 100644 --- a/src/adn_server/application/routing/helpers.py +++ b/src/adn_server/application/routing/helpers.py @@ -638,6 +638,64 @@ def synthetic_group_dmrd_route_packet(slot: int, tgid: int) -> bytes: return b"DMRD" + b"\x00" * 4 + bytes_3(tgid) + b"\x00" * 4 + bytes([bits]) + b"\x00" * 38 +def peer_downlink_voice_slot( + peer: dict[str, Any], + wire_slot: int, + tgid: int, + sys_cfg: dict[str, Any] | None = None, + *, + peer_id: bytes | None = None, +) -> int: + """Monitor/BRDG field 7: TS where this peer listens for ``tgid`` (OPTIONS or UA).""" + static = peer_options_static_tg_slot(peer, tgid) + if static is not None: + return static + if sys_cfg is not None and peer_id is not None: + tgid_i = int(tgid) + pk = bytes_4(int_id(peer_id)) + for voice_slot in (1, 2): + locked = peer_single_exclusive_tgid( + peer, voice_slot, sys_cfg, peer_id=peer_id, + ) + if locked is not None and int(locked) == tgid_i: + return voice_slot + store = sys_cfg.get("_PEER_UA_MULTI_TGS") + if isinstance(store, dict): + per_peer = store.get(pk) + if isinstance(per_peer, dict): + for voice_slot in (1, 2): + slot_set = per_peer.get(voice_slot) + if isinstance(slot_set, set) and tgid_i in slot_set: + return voice_slot + return int(wire_slot) + + +def remap_dmrd_to_peer_static_slot( + packet: bytes, + peer: dict[str, Any], + sys_cfg: dict[str, Any] | None = None, + *, + peer_id: bytes | None = None, +) -> bytes: + """Flip DMRD slot bit so the hotspot RF TS matches OPTIONS/UA for this 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_downlink_voice_slot( + peer, voice_slot, tgid, sys_cfg, peer_id=peer_id, + ) + if cfg_slot == voice_slot: + return packet + bits = packet[15] + new_bits = bits ^ (1 << 7) + return packet[:15] + bytes([new_bits]) + packet[16:] + + def repeat_downlink_report_slot( wire_slot: int, tgid: int, @@ -656,27 +714,11 @@ def repeat_downlink_report_slot( peer = peers.get(peer_id) if not isinstance(peer, dict): continue - static_slot = peer_options_static_tg_slot(peer, tgid_i) - if static_slot is not None: - display_slots.add(static_slot) - continue - if not sys_cfg: - continue - pk = bytes_4(int_id(peer_id)) - for voice_slot in (1, 2): - locked = peer_single_exclusive_tgid( - peer, voice_slot, sys_cfg, peer_id=peer_id, + display_slots.add( + peer_downlink_voice_slot( + peer, wire_slot, tgid_i, sys_cfg, peer_id=peer_id, ) - if locked is not None and int(locked) == tgid_i: - display_slots.add(voice_slot) - store = sys_cfg.get("_PEER_UA_MULTI_TGS") - if isinstance(store, dict): - per_peer = store.get(pk) - if isinstance(per_peer, dict): - for voice_slot in (1, 2): - slot_set = per_peer.get(voice_slot) - if isinstance(slot_set, set) and tgid_i in slot_set: - display_slots.add(voice_slot) + ) if len(display_slots) == 1: return display_slots.pop() return int(wire_slot) diff --git a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py index 4218f16..3a5f181 100644 --- a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py +++ b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py @@ -50,6 +50,7 @@ from ...application.routing.helpers import ( peer_should_receive_group_voice, peer_single_exclusive_tgid, register_peer_ua_session, + remap_dmrd_to_peer_static_slot, resolve_voice_peer_id, repeat_downlink_report_slot, seed_peer_ua_session_from_status, @@ -576,6 +577,11 @@ 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, self._config, peer_id=_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 74061b1..06d2bd5 100644 --- a/tests/application/test_monitor_topology.py +++ b/tests/application/test_monitor_topology.py @@ -228,6 +228,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_local_hotspot_rx_companion_tx_uses_receiver_options_slot() -> None: + """HS2 TX slot 2 / TG 7144 → HS1 with TS1=7144 gets BRDG field 7 = 1 (monitor TE).""" + hs1 = bytes_4(730001) + hs2 = bytes_4(730002) + peers = { + hs1: _peer(options=b"TS1=7144;TS2=714,71442;"), + hs2: _peer(options=b"TS2=7144;"), + } + config = _proxy_config(peers) + peer_slots = {hs1: 0, hs2: 1} + raw = "GROUP VOICE,START,RX,SYSTEM,3262598598,730002,730002,2,7144" + events = remap_inject_proxy_voice_events( + raw, config, config["SYSTEMS"], peer_slots + ) + tx_events = [e for e in events if e.split(",")[2] == "TX"] + assert len(tx_events) == 1 + parts = tx_events[0].split(",") + assert parts[3] == "SYSTEM-0" + assert int(parts[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 f4c8d7d..822e549 100644 --- a/tests/application/test_peer_single_downlink.py +++ b/tests/application/test_peer_single_downlink.py @@ -30,14 +30,17 @@ from adn_server.application.routing.helpers import ( tg4000_reset_on_vhead, clear_peer_rx_status_slots, peer_options_static_tg_slot, + peer_downlink_voice_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, peer_single_exclusive_tgid, register_peer_ua_multi_tg, register_peer_ua_session, repeat_downlink_report_slot, + peer_downlink_voice_slot, seed_peer_ua_session_from_status, ) @@ -79,12 +82,46 @@ def test_dynamic_tg_on_opposite_slot_receives_group_voice() -> None: def test_repeat_downlink_report_slot_cross_slot_static() -> None: peers = { - _peer_id(): {"OPTIONS": b"TS2=730444;"}, + _peer_id(): {"OPTIONS": b"TS1=7144;"}, } slot = repeat_downlink_report_slot( - 1, 730444, peers, (_peer_id(),), _sys_cfg(), + 2, 7144, peers, (_peer_id(),), _sys_cfg(), ) - assert slot == 2 + assert slot == 1 + + +def test_peer_downlink_voice_slot_cross_slot_static() -> None: + peer = {"OPTIONS": b"TS1=7144;TS2=714,71442;"} + assert peer_downlink_voice_slot(peer, 2, 7144) == 1 + peer2 = {"OPTIONS": b"TS2=7144;"} + assert peer_downlink_voice_slot(peer2, 1, 7144) == 2 + + +def test_remap_dmrd_flips_slot_to_options_static_ts() -> None: + from tests.harness.deterministic import DeterministicScenario, PacketSpec + + peer = {"OPTIONS": b"TS1=7144;TS2=714,71442;"} + burst = DeterministicScenario.voice_burst_spec( + PacketSpec(dst_id=7144, slot=2, peer_id=730044402, 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 DeterministicScenario, PacketSpec + + peer = {"OPTIONS": b"TS2=7144;"} + burst = DeterministicScenario.voice_burst_spec( + PacketSpec(dst_id=7144, slot=2, peer_id=730044402, 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_repeat_downlink_report_slot_dynamic_on_ts2() -> None: diff --git a/tests/infrastructure/test_hbp_repeat_options_filter.py b/tests/infrastructure/test_hbp_repeat_options_filter.py index 6dda35f..a74097d 100644 --- a/tests/infrastructure/test_hbp_repeat_options_filter.py +++ b/tests/infrastructure/test_hbp_repeat_options_filter.py @@ -72,6 +72,34 @@ def test_repeat_only_reaches_peers_with_matching_options() -> None: assert other_pkts == [] +def test_repeat_remaps_slot_to_peer_options_ts() -> None: + """Simplex TS2 TX / TG 7144 → duplex TS1=7144 receives DMRD 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 + simplex = bytes_4(730002) + duplex = bytes_4(730001) + addr_simplex = ("10.0.0.31", 62031) + addr_duplex = ("10.0.0.30", 62030) + stack.register_peer(simplex, addr_simplex, options="TS2=7144;") + stack.register_peer(duplex, addr_duplex, options="TS1=7144;TS2=714,71442;") + + spec = PacketSpec( + peer_id=int.from_bytes(simplex, "big"), + rf_src=730002, + dst_id=7144, + 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_cross_slot_static_tg_downlink() -> None: """Voice on TS1 reaches hotspot that lists the TG only on TS2 (PR #2 parity).""" stack = build_hbp_repeat_stack(talker_alias=False, system_name="MASTER-A") From cf03fc5367f57630323cccf082959b162cdcf5c4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:34:09 +0000 Subject: [PATCH 2/2] chore(release): 2.0.5 --- CHANGELOG.md | 23 +++++++++++++++++++++++ README.md | 2 +- pyproject.toml | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0f7ab..23e262e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to **adn-server** are documented here. +## v2.0.5 (2026-06-21) + +### Bug Fixes + +- Cross-slot DMRD remap and monitor TE slot + ([#18](https://github.com/ce5rpy/ADN-DMR-Peer-Server/pull/18), + [`5945d66`](https://github.com/ce5rpy/ADN-DMR-Peer-Server/commit/5945d6641b4056a77b678093f5fead8491263eb4)) + +- Remap REPEAT DMRD slot to peer OPTIONS for cross-slot downlink + ([#18](https://github.com/ce5rpy/ADN-DMR-Peer-Server/pull/18), + [`5945d66`](https://github.com/ce5rpy/ADN-DMR-Peer-Server/commit/5945d6641b4056a77b678093f5fead8491263eb4)) + +- Restore cross-slot downlink and REPEAT monitor activity + ([#18](https://github.com/ce5rpy/ADN-DMR-Peer-Server/pull/18), + [`5945d66`](https://github.com/ce5rpy/ADN-DMR-Peer-Server/commit/5945d6641b4056a77b678093f5fead8491263eb4)) + +### Chores + +- **ci**: Sync develop via merge instead of force-push + ([#18](https://github.com/ce5rpy/ADN-DMR-Peer-Server/pull/18), + [`5945d66`](https://github.com/ce5rpy/ADN-DMR-Peer-Server/commit/5945d6641b4056a77b678093f5fead8491263eb4)) + + ## v2.0.4 (2026-06-21) ### Bug Fixes diff --git a/README.md b/README.md index 0514606..d641f27 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ADN DMR Peer Server -**Version 2.0.4** — pairs with **adn-monitor 2.0.0** (report v2 slim wire + JSON HELLO). +**Version 2.0.5** — pairs with **adn-monitor 2.0.0** (report v2 slim wire + JSON HELLO). ADN DMR conference bridge server. Configuration is YAML; the codebase follows clean architecture (domain, application, infrastructure). v2 adds integrated **PROXY**, **SubscriptionStore** routing, report v2 to the monitor, and a unified **`adn-server.py`** entrypoint (`--echo`, `--doctor`, `--no-proxy`). See [CHANGELOG.md](CHANGELOG.md). diff --git a/pyproject.toml b/pyproject.toml index 4ff4a46..6d51f1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "adn-server" -version = "2.0.4" +version = "2.0.5" description = "ADN DMR Peer Server" readme = "README.md" license = { text = "GPL-3.0-or-later" }