You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
195 lines
7.5 KiB
195 lines
7.5 KiB
# ADN DMR Peer Server - downlink stabilization (trial/downlink-clean)
|
|
#
|
|
# 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
|
|
###############################################################################
|
|
|
|
"""P3 hangtime, P4 dual-slot, HP3ICC-style slot contention via downlink.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from adn_server.application.routing.downlink import (
|
|
DownlinkContext,
|
|
iter_downlink_voice_slots,
|
|
peer_slot_blocks_downlink,
|
|
synthetic_group_dmrd_route_packet,
|
|
touch_peer_voice_slot,
|
|
)
|
|
from adn_server.domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, bytes_3, bytes_4
|
|
|
|
|
|
def _empty_slot() -> dict:
|
|
return {
|
|
"RX_TIME": 0.0,
|
|
"TX_TIME": 0.0,
|
|
"RX_TYPE": HBPF_SLT_VTERM,
|
|
"TX_TYPE": HBPF_SLT_VTERM,
|
|
"RX_TGID": b"\x00\x00\x00",
|
|
"TX_TGID": b"\x00\x00\x00",
|
|
"RX_STREAM_ID": b"",
|
|
"TX_STREAM_ID": b"",
|
|
"RX_PEER": b"\x00\x00\x00\x00",
|
|
"TX_PEER": b"\x00\x00\x00\x00",
|
|
}
|
|
|
|
|
|
def _ctx(*, hangtime: float = 3.0) -> DownlinkContext:
|
|
sys_cfg = {"GROUP_HANGTIME": hangtime, "MODE": "MASTER", "MAX_PEERS": 8}
|
|
config = {"GLOBAL": {}, "SYSTEMS": {"MASTER-A": sys_cfg}}
|
|
return DownlinkContext(
|
|
config=config,
|
|
system_name="MASTER-A",
|
|
sys_cfg=sys_cfg,
|
|
peers={},
|
|
status={1: _empty_slot(), 2: _empty_slot()},
|
|
connected_count=2,
|
|
)
|
|
|
|
|
|
def _vhead(slot: int, tgid: int, stream: int) -> bytes:
|
|
bits = 0x80 if slot == 2 else 0
|
|
bits |= HBPF_DATA_SYNC << 4
|
|
bits |= HBPF_SLT_VHEAD
|
|
return b"".join([
|
|
b"DMRD",
|
|
b"\x00",
|
|
bytes_3(100),
|
|
bytes_3(tgid),
|
|
b"\x00\x00\x00\x00",
|
|
bytes([bits]),
|
|
bytes_4(stream),
|
|
] + [b"\x00"] * 33)
|
|
|
|
|
|
def _vterm(slot: int, tgid: int, stream: int) -> bytes:
|
|
bits = 0x80 if slot == 2 else 0
|
|
bits |= HBPF_DATA_SYNC << 4
|
|
bits |= HBPF_SLT_VTERM
|
|
return b"".join([
|
|
b"DMRD",
|
|
b"\x00",
|
|
bytes_3(100),
|
|
bytes_3(tgid),
|
|
b"\x00\x00\x00\x00",
|
|
bytes([bits]),
|
|
bytes_4(stream),
|
|
] + [b"\x00"] * 33)
|
|
|
|
|
|
def test_single0_hangtime_blocks_other_tg() -> None:
|
|
"""P3: after VTERM, GROUP_HANGTIME blocks a different TG on the same slot."""
|
|
ctx = _ctx(hangtime=5.0)
|
|
peer_id = bytes_4(730002301)
|
|
peer = {"OPTIONS": b"TS2=7141;SINGLE=0;"}
|
|
ctx.peers[peer_id] = peer
|
|
now = 1_000_000.0
|
|
touch_peer_voice_slot(ctx, peer_id, 2, bytes_4(0x1111), bytes_3(7141), pkt_time=now)
|
|
from adn_server.application.routing.downlink import end_peer_voice_slot
|
|
|
|
end_peer_voice_slot(ctx, peer_id, 2, bytes_4(0x1111), bytes_3(7141), pkt_time=now + 1)
|
|
blocked_pkt = synthetic_group_dmrd_route_packet(2, 71442)
|
|
blocked_pkt = _vhead(2, 71442, 0x2222)
|
|
assert peer_slot_blocks_downlink(ctx, peer_id, peer, blocked_pkt, pkt_time=now + 3)
|
|
same_pkt = _vhead(2, 7141, 0x3333)
|
|
assert not peer_slot_blocks_downlink(ctx, peer_id, peer, same_pkt, pkt_time=now + 3)
|
|
|
|
|
|
def test_hangtime_blocks_when_ua_slot_differs_from_rf_slot() -> None:
|
|
"""Hangtime on RF slot 2 must block downlink even if UA store lists TG on slot 1."""
|
|
sys_cfg = {"GROUP_HANGTIME": 10.0, "MODE": "MASTER", "MAX_PEERS": 8}
|
|
config = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": sys_cfg}}
|
|
peer_id = bytes_4(730039101)
|
|
peer = {"OPTIONS": b"TS2=730,7305;SINGLE=0;"}
|
|
sys_cfg["_PEER_UA_MULTI_TGS"] = {peer_id: {1: {7306}}}
|
|
ctx = DownlinkContext(
|
|
config=config,
|
|
system_name="MASTER-A",
|
|
sys_cfg=sys_cfg,
|
|
peers={peer_id: peer},
|
|
status={1: _empty_slot(), 2: _empty_slot()},
|
|
connected_count=2,
|
|
)
|
|
now = 4_000_000.0
|
|
touch_peer_voice_slot(ctx, peer_id, 2, bytes_4(0x1111), bytes_3(7306), pkt_time=now)
|
|
from adn_server.application.routing.downlink import end_peer_voice_slot
|
|
|
|
end_peer_voice_slot(ctx, peer_id, 2, bytes_4(0x1111), bytes_3(7306), pkt_time=now + 1)
|
|
assert ctx.peer_voice_hangtime[peer_id][2] == (7306, now + 1)
|
|
foreign = _vhead(2, 7305, 0x2222)
|
|
assert peer_slot_blocks_downlink(ctx, peer_id, peer, foreign, pkt_time=now + 3)
|
|
|
|
|
|
def test_inject_only_hangtime_blocks_static_tg_after_dynamic_tx() -> None:
|
|
"""7306 TX then OBP/static 7305 downlink blocked for GROUP_HANGTIME (inject-only lab)."""
|
|
sys_cfg = {"GROUP_HANGTIME": 10.0, "MODE": "MASTER", "MAX_PEERS": 8}
|
|
config = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": sys_cfg}}
|
|
ctx = DownlinkContext(
|
|
config=config,
|
|
system_name="MASTER-A",
|
|
sys_cfg=sys_cfg,
|
|
peers={},
|
|
status={
|
|
1: _empty_slot(),
|
|
2: {
|
|
**_empty_slot(),
|
|
"TX_PEER": bytes_4(73010),
|
|
"TX_STREAM_ID": bytes_4(0xCCCCDDDD),
|
|
"TX_TYPE": HBPF_SLT_VHEAD,
|
|
"TX_TIME": 2_000_001.0,
|
|
"TX_TGID": bytes_3(7305),
|
|
},
|
|
},
|
|
connected_count=1,
|
|
)
|
|
peer_id = bytes_4(730039101)
|
|
peer = {"OPTIONS": b"TS2=730,7305;SINGLE=0;"}
|
|
ctx.peers[peer_id] = peer
|
|
now = 2_000_000.0
|
|
touch_peer_voice_slot(ctx, peer_id, 2, bytes_4(0xAAAABBBB), bytes_3(7306), pkt_time=now)
|
|
from adn_server.application.routing.downlink import end_peer_voice_slot
|
|
|
|
end_peer_voice_slot(ctx, peer_id, 2, bytes_4(0xAAAABBBB), bytes_3(7306), pkt_time=now + 5)
|
|
obp7305 = _vhead(2, 7305, 0xCCCCDDDD)
|
|
assert peer_slot_blocks_downlink(ctx, peer_id, peer, obp7305, pkt_time=now + 8)
|
|
same7306 = _vhead(2, 7306, 0xDDDDEEEE)
|
|
assert not peer_slot_blocks_downlink(ctx, peer_id, peer, same7306, pkt_time=now + 8)
|
|
|
|
|
|
def test_same_static_tg_on_both_slots_delivers_twice() -> None:
|
|
"""P4: TG listed in TS1 and TS2 OPTIONS maps to both voice slots."""
|
|
peer = {"OPTIONS": b"TS1=730444;TS2=730444;"}
|
|
assert iter_downlink_voice_slots(peer, 1, 730444) == [1, 2]
|
|
|
|
|
|
def test_hp3icc_style_slot_busy_until_vterm() -> None:
|
|
"""P2: active QSO on slot 2 blocks foreign TG until VTERM (pcap HP3ICC pattern)."""
|
|
ctx = _ctx()
|
|
peer_id = bytes_4(714002301)
|
|
peer = {"OPTIONS": b"TS2=7141;SINGLE=1;TIMER=5;"}
|
|
ctx.peers[peer_id] = peer
|
|
now = 2_000_000.0
|
|
touch_peer_voice_slot(ctx, peer_id, 2, bytes_4(0xAAAA), bytes_3(7141), pkt_time=now)
|
|
foreign = _vhead(2, 71442, 0xBBBB)
|
|
assert peer_slot_blocks_downlink(ctx, peer_id, peer, foreign, pkt_time=now + 0.5)
|
|
same_stream = _vhead(2, 71442, 0xAAAA)
|
|
assert not peer_slot_blocks_downlink(ctx, peer_id, peer, same_stream, pkt_time=now + 0.5)
|
|
from adn_server.application.routing.downlink import end_peer_voice_slot
|
|
|
|
end_peer_voice_slot(ctx, peer_id, 2, bytes_4(0xAAAA), bytes_3(7141), pkt_time=now + 2)
|
|
assert peer_slot_blocks_downlink(ctx, peer_id, peer, foreign, pkt_time=now + 2.5)
|
|
assert not peer_slot_blocks_downlink(ctx, peer_id, peer, foreign, pkt_time=now + 5.5)
|