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.
ADN-DMR-Peer-Server/tests/application/test_monitor_topology.py

450 lines
16 KiB

# ADN DMR Peer Server - tests application monitor topology
#
# 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
###############################################################################
"""Monitor topology parity for inject-only proxy (legacy SYSTEM-N report shape)."""
from __future__ import annotations
import pytest
from adn_server.application.report.monitor_topology import (
expand_inject_proxy_systems,
remap_inject_proxy_voice_event,
remap_inject_proxy_voice_event_for_peer,
remap_inject_proxy_voice_events,
)
from adn_server.application.report.payloads import build_topology
from adn_server.domain.value_objects import bytes_4
def _peer(*, connected: bool = True, options: bytes | None = None) -> dict:
row = {
"CONNECTION": "YES" if connected else "NO",
"CONNECTED": 1_700_000_000 if connected else 0,
"IP": "203.0.113.10",
"PORT": 62031,
"CALLSIGN": b"CE5RPY ",
"RX_FREQ": b"145625000",
"TX_FREQ": b"145625000",
}
if options is not None:
row["OPTIONS"] = options
return row
def _proxy_config(
peers: dict[bytes, dict],
*,
max_peers: int = 102,
base_port: int = 56400,
) -> dict:
return {
"PROXY": {"TARGET_SYSTEM": "SYSTEM"},
"SYSTEMS": {
"SYSTEM": {
"MODE": "MASTER",
"ENABLED": True,
"MAX_PEERS": max_peers,
"_REPORT_BASE_PORT": base_port,
"PEERS": peers,
}
},
}
def test_expand_inject_proxy_fans_peers_into_system_n() -> None:
peer_a = bytes_4(730039101)
peer_b = bytes_4(7301896)
config = _proxy_config({peer_a: _peer(), peer_b: _peer()})
config["SYSTEMS"]["ECHO"] = {"MODE": "MASTER", "ENABLED": True, "PEERS": {}}
expanded = expand_inject_proxy_systems(
config,
config["SYSTEMS"],
{peer_a: 2, peer_b: 66},
)
assert "SYSTEM" not in expanded
assert expanded["SYSTEM-2"]["PORT"] == 56402
assert expanded["SYSTEM-66"]["PORT"] == 56466
assert list(expanded["SYSTEM-2"]["PEERS"]) == [peer_a]
assert list(expanded["SYSTEM-66"]["PEERS"]) == [peer_b]
assert "ECHO" in expanded
def test_build_topology_after_expand_matches_monitor_shape() -> None:
peer = bytes_4(730039101)
config = _proxy_config({peer: _peer()})
expanded = expand_inject_proxy_systems(config, config["SYSTEMS"], {peer: 2})
doc = build_topology(expanded, seq=1)
names = {system["name"] for system in doc["systems"]}
assert "SYSTEM" not in names
assert "SYSTEM-2" in names
system = next(item for item in doc["systems"] if item["name"] == "SYSTEM-2")
assert system["port"] == 56402
assert len(system["peers"]) == 1
row = system["peers"][0]
assert row["id"] == 730039101
assert row["connected"] is True
assert row["ip"] == "203.0.113.10"
assert row["callsign"] == "CE5RPY"
def test_expand_inject_clears_merged_system_static_on_virtual() -> None:
peer = bytes_4(730039101)
config = _proxy_config({peer: _peer(options=b"TS2=7305;")})
config["SYSTEMS"]["SYSTEM"]["TS2_STATIC"] = "730,7305,214091"
expanded = expand_inject_proxy_systems(config, config["SYSTEMS"], {peer: 2})
assert expanded["SYSTEM-2"]["TS2_STATIC"] == ""
assert expanded["SYSTEM-2"]["TS1_STATIC"] == ""
def test_expand_inject_proxy_emits_all_virtual_masters() -> None:
peer = bytes_4(730039101)
config = _proxy_config({peer: _peer()}, max_peers=4)
expanded = expand_inject_proxy_systems(config, config["SYSTEMS"], {peer: 2})
for slot in range(4):
assert f"SYSTEM-{slot}" in expanded
assert expanded[f"SYSTEM-{slot}"]["PORT"] == 56400 + slot
assert list(expanded["SYSTEM-2"]["PEERS"]) == [peer]
assert expanded["SYSTEM-0"]["PEERS"] == {}
assert expanded["SYSTEM-1"]["PEERS"] == {}
@pytest.mark.parametrize(
"raw,peer_specs,slot_map,expect",
[
pytest.param(
"GROUP VOICE,START,RX,SYSTEM,3262598598,730039101,730039101,2,730444",
[(730039101,)],
{730039101: 4},
{"startswith": "GROUP VOICE,START,RX,SYSTEM-4,"},
id="rx_to_virtual_master",
),
pytest.param(
"GROUP VOICE,START,TX,SYSTEM,4100887026,9990,730039101,2,730444",
[(730039101,)],
{730039101: 4},
{"parts": {3: "SYSTEM-4", 5: "9990"}},
id="tx_echo_keeps_9990_for_hotspot_rx",
),
pytest.param(
"GROUP VOICE,START,TX,SYSTEM,4100887026,730039101,730039101,2,9990",
[(730039101,)],
{730039101: 4},
{"parts": {3: "SYSTEM-4", 5: "9990"}},
id="tx_echo_peer_id_in_field5_dst_9990",
),
pytest.param(
"GROUP VOICE,START,RX,SYSTEM,4100887026,73003,7300392,2,9990",
[(730039101,)],
{730039101: 4},
{"parts": {3: "SYSTEM-4", 5: "730039101"}},
id="rx_normalizes_field5_to_hotspot_radio_id",
),
pytest.param(
"GROUP VOICE,START,TX,SYSTEM,2693411696,9990,7300392,2,9990",
[(730039101,)],
{730039101: 4},
{"parts": {3: "SYSTEM-4", 5: "9990"}},
id="single_hotspot_user_prefix",
),
pytest.param(
"GROUP VOICE,START,TX,SYSTEM,2693411696,9990,7300391,2,9990",
[(730039101,), (730039102,)],
{730039101: 4, 730039102: 5},
{"unchanged": True},
id="ambiguous_user_multiple_hotspots",
),
pytest.param(
"GROUP VOICE,START,TX,SYSTEM,4100887026,9990,730039102,2,730444",
[(730039101,), (730039102,)],
{730039101: 4, 730039102: 5},
{"parts": {3: "SYSTEM-5", 5: "9990"}},
id="full_radio_id_with_sibling_hotspots",
),
],
)
def test_remap_voice_event_inject_proxy(
raw: str,
peer_specs: list[tuple[int, ...]],
slot_map: dict[int, int],
expect: dict,
) -> None:
peers = {bytes_4(rid): _peer() for spec in peer_specs for rid in spec}
peer_slots = {bytes_4(rid): slot_map[rid] for spec in peer_specs for rid in spec}
config = _proxy_config(peers)
remapped = remap_inject_proxy_voice_event(
raw, config, config["SYSTEMS"], peer_slots
)
if expect.get("unchanged"):
assert remapped == raw
return
if prefix := expect.get("startswith"):
assert remapped.startswith(prefix)
return
parts = remapped.split(",")
for idx, value in expect.get("parts", {}).items():
assert parts[idx] == value
def test_local_hotspot_rx_fans_out_tx_only_to_peers_with_matching_tg() -> None:
"""REPEAT companion TX only for hotspots that have the TG in OPTIONS."""
peers = {
bytes_4(7301795): _peer(options=b"TS2=730444;"),
bytes_4(7300444): _peer(options=b"TS2=730444;"),
bytes_4(730039101): _peer(options=b"TS2=91;"),
}
config = _proxy_config(peers)
peer_slots = {
bytes_4(7301795): 1,
bytes_4(7300444): 3,
bytes_4(730039101): 4,
}
raw = "GROUP VOICE,START,RX,SYSTEM,3262598598,7301795,7301795,2,730444"
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}
assert "SYSTEM-1" in by_system
assert by_system["SYSTEM-1"].startswith("GROUP VOICE,START,RX,SYSTEM-1,")
assert "SYSTEM-3" in by_system
assert by_system["SYSTEM-3"].startswith("GROUP VOICE,START,TX,SYSTEM-3,")
assert "SYSTEM-4" not in by_system
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)
peers = {peer: _peer(options=b"TS2=730,7305;")}
config = _proxy_config(peers)
peer_slots = {peer: 2}
raw = "GROUP VOICE,START,TX,SYSTEM,4100887026,73010,7000002,2,730444"
bridges = {
"730444": [
{
"SYSTEM": "SYSTEM",
"TS": 2,
"TGID": 730444,
"ACTIVE": True,
"TO_TYPE": "ON",
}
],
}
events = remap_inject_proxy_voice_events(
raw, config, config["SYSTEMS"], peer_slots, bridges
)
assert len(events) == 1
assert events[0].startswith("GROUP VOICE,START,TX,SYSTEM-2,")
def test_obp_bridge_tx_fans_out_only_to_peers_with_matching_static_tg() -> None:
"""OBP downlink TX must not light hotspots without the TG in OPTIONS."""
peers = {
bytes_4(7300444): _peer(options=b"TS2=730444;"),
bytes_4(7301795): _peer(options=b"TS2=730444;"),
bytes_4(730039101): _peer(options=b"TS2=91;"),
}
config = _proxy_config(peers)
peer_slots = {bytes_4(7300444): 3, bytes_4(7301795): 1, bytes_4(730039101): 4}
raw = "GROUP VOICE,START,TX,SYSTEM,4100887026,73010,7000002,2,730444"
bridges = {
"730444": [
{
"SYSTEM": "SYSTEM",
"TS": 2,
"ACTIVE": True,
"TO_TYPE": "ON",
}
],
}
events = remap_inject_proxy_voice_events(
raw, config, config["SYSTEMS"], peer_slots, bridges
)
systems = {ev.split(",")[3] for ev in events}
assert systems == {"SYSTEM-1", "SYSTEM-3"}
assert all(ev.split(",")[5] == "73010" for ev in events)
def test_remap_voice_event_passes_through_non_proxy_systems() -> None:
raw = "GROUP VOICE,START,RX,ECHO,1,9990,730039101,2,9990"
assert remap_inject_proxy_voice_event(raw, {}, {}) == raw
def test_hangtime_blocks_monitor_tx_fanout_to_blocked_peer() -> None:
"""Companion TX / OBP fan-out must not light peers blocked by GROUP_HANGTIME."""
import time
from adn_server.application.routing.downlink import (
DownlinkContext,
end_peer_voice_slot,
touch_peer_voice_slot,
)
from adn_server.domain import 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",
}
peer_hs_a = bytes_4(730039101)
peer_hs_b = bytes_4(730039210)
peers = {
peer_hs_a: _peer(options=b"TS2=730,7305;SINGLE=0;"),
peer_hs_b: _peer(options=b"TS2=7305;"),
}
config = _proxy_config(peers)
config["SYSTEMS"]["SYSTEM"]["GROUP_HANGTIME"] = 10
peer_slots = {peer_hs_a: 7, peer_hs_b: 8}
sys_cfg = config["SYSTEMS"]["SYSTEM"]
ctx = DownlinkContext(
config=config,
system_name="SYSTEM",
sys_cfg=sys_cfg,
peers=peers,
status={1: _empty_slot(), 2: _empty_slot()},
connected_count=2,
)
now = time.time()
touch_peer_voice_slot(
ctx, peer_hs_a, 2, bytes_4(0x1111), bytes_3(7306), pkt_time=now,
)
end_peer_voice_slot(
ctx, peer_hs_a, 2, bytes_4(0x1111), bytes_3(7306), pkt_time=now + 0.5,
)
rx_raw = "GROUP VOICE,START,RX,SYSTEM,3262598598,730039210,730039210,2,7305"
rx_events = remap_inject_proxy_voice_events(
rx_raw, config, config["SYSTEMS"], peer_slots, downlink_ctx=ctx,
)
rx_systems = {ev.split(",")[3] for ev in rx_events}
assert "SYSTEM-8" in rx_systems
assert "SYSTEM-7" not in rx_systems
tx_raw = "GROUP VOICE,START,TX,SYSTEM,4100887026,73010,7000002,2,7305"
tx_events = remap_inject_proxy_voice_events(
tx_raw, config, config["SYSTEMS"], peer_slots, downlink_ctx=ctx,
)
tx_systems = {ev.split(",")[3] for ev in tx_events}
assert tx_systems == {"SYSTEM-8"}
def test_remap_voice_event_for_single_peer() -> None:
peer = bytes_4(730039101)
peers = {peer: _peer(options=b"TS2=7305;")}
config = _proxy_config(peers)
peer_slots = {peer: 7}
raw = "GROUP VOICE,START,TX,SYSTEM,4100887026,73010,7000002,2,7305"
mapped = remap_inject_proxy_voice_event_for_peer(
raw, config, config["SYSTEMS"], peer, peer_slots,
)
assert mapped is not None
assert mapped.startswith("GROUP VOICE,START,TX,SYSTEM-7,")
def test_remap_announcement_tx_fans_out_to_ua_only_peer() -> None:
"""Inject START,TX on SYSTEM reaches peers with dynamic UA but no static OPTIONS TG."""
peer = bytes_4(730039210)
peers = {peer: _peer(options=b"TS2=730500;SINGLE=0;")}
config = _proxy_config(peers)
sys_cfg = config["SYSTEMS"]["SYSTEM"]
pk = bytes_4(730039210)
sys_cfg.setdefault("_PEER_UA_MULTI_TGS", {}).setdefault(pk, {})[2] = {730600}
peer_slots = {peer: 7}
raw = "GROUP VOICE,START,TX,SYSTEM,4100887026,1000001,1000001,2,730600"
events = remap_inject_proxy_voice_events(
raw, config, config["SYSTEMS"], peer_slots,
)
systems = {ev.split(",")[3] for ev in events}
assert systems == {"SYSTEM-7"}
def test_non_proxy_systems_pass_through_unchanged() -> None:
systems = {
"ECHO": {"MODE": "MASTER", "ENABLED": True, "PEERS": {}},
}
assert expand_inject_proxy_systems({"PROXY": {"TARGET_SYSTEM": "SYSTEM"}}, systems) is systems
def test_echo_tx_uses_rx_peer_when_rf_src_fuzzy_matches_sibling() -> None:
"""Echo TX must resolve to RX_PEER from STATUS, not fuzzy-match rf_src.
Reproduces the monitor display bug: user 7140023 has two hotspots,
714000103 (base 7140001) and 714002301 (base 7140023). The echo playback
arrives with peer_id=9990, rf_src=7140023. Fuzzy matching (// 100) resolves
714002301 instead of the true originator 714000103 held in STATUS.RX_PEER.
"""
from adn_server.application.routing.downlink import DownlinkContext
peer_origin = bytes_4(714000103) # hotspot that transmitted to 9990
peer_sibling = bytes_4(714002301) # same user, base == rf_src
peers = {peer_origin: _peer(), peer_sibling: _peer()}
config = _proxy_config(peers)
peer_slots = {peer_origin: 1, peer_sibling: 5}
status = {
2: {
"RX_PEER": peer_origin,
"RX_TGID": bytes_4(9990)[1:],
}
}
ctx = DownlinkContext(
config=config,
system_name="SYSTEM",
sys_cfg=config["SYSTEMS"]["SYSTEM"],
peers=peers,
status=status,
connected_count=2,
)
# Echo playback TX: peer_id=9990, rf_src=7140023 (user DMR ID), dst=9990 slot 2
raw = "GROUP VOICE,START,TX,SYSTEM,2693411696,9990,7140023,2,9990"
events = remap_inject_proxy_voice_events(
raw, config, config["SYSTEMS"], peer_slots, downlink_ctx=ctx,
)
assert len(events) == 1
parts = events[0].split(",")
# Must remap to SYSTEM-1 (peer_origin slot), NOT SYSTEM-5 (peer_sibling)
assert parts[3] == "SYSTEM-1"
assert parts[5] == "9990" # echo id preserved for TX chip display
def test_echo_tx_falls_back_without_downlink_ctx() -> None:
"""Without STATUS (no downlink_ctx), echo TX cannot resolve when rf_src
does not fuzzy-match any connected peer — event passes through unchanged."""
peer = bytes_4(714000103)
peers = {peer: _peer()}
config = _proxy_config(peers)
peer_slots = {peer: 1}
raw = "GROUP VOICE,START,TX,SYSTEM,2693411696,9990,7140023,2,9990"
events = remap_inject_proxy_voice_events(
raw, config, config["SYSTEMS"], peer_slots,
)
# rf_src 7140023 does not match peer 714000103 via // 100 or prefix;
# legacy leaves the event unchanged when the hotspot cannot be resolved.
assert len(events) == 1
assert events[0] == raw

Powered by TurnKey Linux.