Use SUB_MAP's stored peer id for delivery (repeat, unit-data, and pvt_call_received), add Talker Alias support for private calls, and report the receiving hotspot to the monitor.pull/62/head
parent
a448f12f08
commit
11970e0552
@ -0,0 +1,134 @@
|
||||
# ADN DMR Peer Server - tests infrastructure hbp private call targeting
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""SUB_MAP-based precise private-call targeting on a MASTER with 3+ peers.
|
||||
|
||||
Legacy (hblink.py master_datagramReceived) broadcasts every private call to
|
||||
every connected peer unconditionally -- the receiving hotspot's own ACL/sub
|
||||
list is the only filter, so a private call between two unrelated users still
|
||||
reaches every other hotspot's air interface. _pvt_repeat_targets narrows this:
|
||||
once SUB_MAP has recorded which peer_id last carried a given subscriber's
|
||||
traffic, a private call to that subscriber is delivered only to that peer,
|
||||
never broadcast. Unknown destinations still fall back to broadcast (legacy
|
||||
parity) -- see test_hbp_repeat_private_call.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec
|
||||
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
|
||||
|
||||
from adn_server.domain import bytes_3, bytes_4
|
||||
from adn_server.infrastructure.hbp_constants import RPTC, RPTK, RPTL
|
||||
from adn_server.infrastructure.twisted_adapters.udp_hbp import _calc_hash, _get_passphrase_bytes
|
||||
|
||||
_PEER_TX = bytes_4(730039210)
|
||||
_PEER_RX = bytes_4(730039101)
|
||||
_PEER_OTHER = bytes_4(730039199)
|
||||
_ADDR_TX = ("10.0.0.1", 62001)
|
||||
_ADDR_RX = ("10.0.0.2", 62002)
|
||||
_ADDR_OTHER = ("10.0.0.3", 62003)
|
||||
|
||||
_DST_SUB = 7304011
|
||||
|
||||
|
||||
def _private_spec() -> PacketSpec:
|
||||
return PacketSpec(
|
||||
peer_id=730039210,
|
||||
rf_src=7300392,
|
||||
dst_id=_DST_SUB,
|
||||
slot=2,
|
||||
call_type="unit",
|
||||
stream_id=0xA1B2C3D4,
|
||||
payload=b"\x00" * 33,
|
||||
)
|
||||
|
||||
|
||||
def _fire_private_call(stack, base: PacketSpec) -> None:
|
||||
stack.inject_spec(DeterministicScenario.voice_head_spec(base), _ADDR_TX)
|
||||
stack.transport.clear()
|
||||
stack.inject_spec(
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), _ADDR_TX,
|
||||
)
|
||||
|
||||
|
||||
def test_private_call_delivered_only_to_last_known_peer_not_broadcast() -> None:
|
||||
stack = build_hbp_repeat_stack(talker_alias=True)
|
||||
stack.register_peer(_PEER_TX, _ADDR_TX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_RX, _ADDR_RX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_OTHER, _ADDR_OTHER, options="TS2=7304;")
|
||||
stack.config["_SUB_MAP"] = {
|
||||
bytes_3(_DST_SUB): (stack.system_name, 2, 1_700_000_000.0, _PEER_RX),
|
||||
}
|
||||
|
||||
_fire_private_call(stack, _private_spec())
|
||||
|
||||
assert stack.transport.for_addr(_ADDR_RX), "known peer must still receive the call"
|
||||
assert not stack.transport.for_addr(_ADDR_OTHER), "uninvolved peer must not see this private call"
|
||||
|
||||
|
||||
def test_private_call_dropped_when_known_peer_no_longer_connected() -> None:
|
||||
stack = build_hbp_repeat_stack(talker_alias=True)
|
||||
stack.register_peer(_PEER_TX, _ADDR_TX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_OTHER, _ADDR_OTHER, options="TS2=7304;")
|
||||
# _PEER_RX was last heard here, but is no longer registered/connected.
|
||||
stack.config["_SUB_MAP"] = {
|
||||
bytes_3(_DST_SUB): (stack.system_name, 2, 1_700_000_000.0, _PEER_RX),
|
||||
}
|
||||
|
||||
_fire_private_call(stack, _private_spec())
|
||||
|
||||
assert not stack.transport.for_addr(_ADDR_OTHER), "must not blast to peers that can't be the destination"
|
||||
|
||||
|
||||
def test_private_call_not_delivered_locally_when_known_on_different_system() -> None:
|
||||
stack = build_hbp_repeat_stack(talker_alias=True)
|
||||
stack.register_peer(_PEER_TX, _ADDR_TX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_OTHER, _ADDR_OTHER, options="TS2=7304;")
|
||||
stack.config["_SUB_MAP"] = {
|
||||
bytes_3(_DST_SUB): ("MASTER-B", 2, 1_700_000_000.0, _PEER_RX),
|
||||
}
|
||||
|
||||
_fire_private_call(stack, _private_spec())
|
||||
|
||||
assert not stack.transport.for_addr(_ADDR_OTHER), "destination known elsewhere must not repeat locally"
|
||||
|
||||
|
||||
def test_sub_map_entries_for_peer_purged_on_reconnect() -> None:
|
||||
"""Once a hotspot finishes (re)logging in, any SUB_MAP entry pointing at it is
|
||||
stale -- the radio may have moved to a different hotspot while this one was
|
||||
offline and there's no way to tell without hearing it transmit again."""
|
||||
stack = build_hbp_repeat_stack(talker_alias=True)
|
||||
stack.hbp._config["PASSPHRASE"] = b"test-passphrase"
|
||||
stack.config["_SUB_MAP"] = {
|
||||
bytes_3(_DST_SUB): (stack.system_name, 2, 1_700_000_000.0, _PEER_RX),
|
||||
bytes_3(1234567): (stack.system_name, 1, 1_700_000_000.0, _PEER_OTHER),
|
||||
}
|
||||
passphrase = _get_passphrase_bytes(stack.hbp._config)
|
||||
|
||||
stack.hbp.datagramReceived(RPTL + _PEER_RX, _ADDR_RX)
|
||||
salt = bytes_4(stack.hbp._peers[_PEER_RX]["SALT"])
|
||||
stack.hbp.datagramReceived(RPTK + _PEER_RX + _calc_hash(salt, passphrase), _ADDR_RX)
|
||||
stack.hbp.datagramReceived(
|
||||
RPTC + _PEER_RX + b"CE5RPY " + b"\x00" * 85 + b"4", _ADDR_RX,
|
||||
)
|
||||
|
||||
assert stack.hbp._peers[_PEER_RX]["CONNECTION"] == "YES"
|
||||
assert bytes_3(_DST_SUB) not in stack.config["_SUB_MAP"], "entry pointing at the reconnected peer must be gone"
|
||||
assert bytes_3(1234567) in stack.config["_SUB_MAP"], "entries for other peers must be untouched"
|
||||
@ -0,0 +1,135 @@
|
||||
# ADN DMR Peer Server - tests infrastructure hbp repeat private call
|
||||
#
|
||||
# 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
|
||||
###############################################################################
|
||||
|
||||
"""REPEAT path through real HBPProtocol for private (unit-to-unit) calls.
|
||||
|
||||
Regression: the raw intra-MASTER REPEAT loop was gated on
|
||||
`call_type in ("group", "vcsbk")`, so two peers registered under the same
|
||||
MASTER system never saw each other's private calls at all — legacy
|
||||
(hblink.py master_datagramReceived) repeats every call_type unconditionally.
|
||||
This test exercises the real infrastructure layer (not the routing-only
|
||||
harness in tests/harness/deterministic.py, which calls dmrd_received()
|
||||
directly and never touches this REPEAT loop)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.deterministic import DeterministicScenario, PacketSpec
|
||||
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
|
||||
|
||||
from adn_server.domain import bytes_4
|
||||
|
||||
_PEER_TX = bytes_4(730039210)
|
||||
_PEER_RX = bytes_4(730039101)
|
||||
_ADDR_TX = ("10.0.0.1", 62001)
|
||||
_ADDR_RX = ("10.0.0.2", 62002)
|
||||
|
||||
|
||||
def _private_spec() -> PacketSpec:
|
||||
return PacketSpec(
|
||||
peer_id=730039210,
|
||||
rf_src=7300392,
|
||||
dst_id=7304011, # 7-digit subscriber ID, not a talkgroup
|
||||
slot=2,
|
||||
call_type="unit",
|
||||
stream_id=0xA1B2C3D4,
|
||||
payload=b"\x00" * 33,
|
||||
)
|
||||
|
||||
|
||||
def test_private_call_is_repeated_to_other_peer_on_same_master() -> None:
|
||||
stack = build_hbp_repeat_stack(talker_alias=True)
|
||||
stack.register_peer(_PEER_TX, _ADDR_TX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_RX, _ADDR_RX, options="TS2=7304;")
|
||||
base = _private_spec()
|
||||
|
||||
stack.inject_spec(DeterministicScenario.voice_head_spec(base), _ADDR_TX)
|
||||
stack.transport.clear()
|
||||
stack.inject_spec(
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), _ADDR_TX,
|
||||
)
|
||||
|
||||
downlink = stack.transport.for_addr(_ADDR_RX)
|
||||
assert downlink, "private call must be repeated to the other peer on the same MASTER"
|
||||
assert downlink[0][11:15] == _PEER_RX
|
||||
|
||||
|
||||
def test_private_call_reaches_peer_under_inject_only_proxy_with_multiple_peers() -> None:
|
||||
"""Regression: parse_dmrd_burst_fields() returns None for private (unit) calls by
|
||||
design (it's a group/vcsbk-only helper) — _peer_should_receive_dmrd's fallback for
|
||||
"parsed is None" under an inject-only multi-peer proxy is `connected_count <= 1`,
|
||||
which blocks delivery entirely once a 2nd hotspot connects. Real deployments run
|
||||
with PROXY.TARGET_SYSTEM set and many peers, so this was silently dropping every
|
||||
private call — reproduced here since build_hbp_repeat_stack's default config has
|
||||
no PROXY section (the other tests in this file don't exercise this branch at all)."""
|
||||
stack = build_hbp_repeat_stack(talker_alias=True)
|
||||
stack.config["PROXY"] = {"TARGET_SYSTEM": stack.system_name}
|
||||
stack.register_peer(_PEER_TX, _ADDR_TX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_RX, _ADDR_RX, options="TS2=7304;")
|
||||
base = _private_spec()
|
||||
|
||||
stack.inject_spec(DeterministicScenario.voice_head_spec(base), _ADDR_TX)
|
||||
stack.transport.clear()
|
||||
stack.inject_spec(
|
||||
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), _ADDR_TX,
|
||||
)
|
||||
|
||||
downlink = stack.transport.for_addr(_ADDR_RX)
|
||||
assert downlink, "private call must reach the other peer even under inject-only multi-peer proxy"
|
||||
assert downlink[0][11:15] == _PEER_RX
|
||||
|
||||
|
||||
def test_private_call_repeat_leaves_burst_payload_unchanged_when_talker_alias_disabled() -> None:
|
||||
stack = build_hbp_repeat_stack(talker_alias=False)
|
||||
stack.register_peer(_PEER_TX, _ADDR_TX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_RX, _ADDR_RX, options="TS2=7304;")
|
||||
base = _private_spec()
|
||||
|
||||
stack.inject_spec(DeterministicScenario.voice_head_spec(base), _ADDR_TX)
|
||||
stack.transport.clear()
|
||||
uplink = DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1)
|
||||
stack.inject_spec(uplink, _ADDR_TX)
|
||||
|
||||
downlink = stack.transport.for_addr(_ADDR_RX)
|
||||
assert len(downlink) == 1
|
||||
assert downlink[0][20:53] == uplink.payload
|
||||
assert stack.hbp.STATUS.get(2, {}).get("TX_TA_EMB") is None
|
||||
|
||||
|
||||
def test_private_call_repeat_embeds_unit_lc_when_talker_alias_enabled() -> None:
|
||||
"""Same rules as group calls: inject the configured TA when the source doesn't
|
||||
supply its own -- only the LC opt byte differs (LC_OPT_U, subscriber destination
|
||||
instead of a talkgroup)."""
|
||||
stack = build_hbp_repeat_stack(talker_alias=True)
|
||||
stack.register_peer(_PEER_TX, _ADDR_TX, options="TS2=7304;")
|
||||
stack.register_peer(_PEER_RX, _ADDR_RX, options="TS2=7304;")
|
||||
base = _private_spec()
|
||||
uplink = DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1)
|
||||
|
||||
stack.inject_spec(DeterministicScenario.voice_head_spec(base), _ADDR_TX)
|
||||
stack.transport.clear()
|
||||
stack.inject_spec(uplink, _ADDR_TX)
|
||||
|
||||
downlink = stack.transport.for_addr(_ADDR_RX)
|
||||
assert len(downlink) == 1
|
||||
slot_st = stack.hbp.STATUS[2]
|
||||
assert slot_st.get("TX_TA_EMB") is not None
|
||||
assert slot_st.get("REP_EMB_LC") is not None
|
||||
# AMBE voice bits (outside the embedded LC slice) still pass through untouched.
|
||||
assert downlink[0][20:53][:14] == uplink.payload[:14]
|
||||
Loading…
Reference in new issue