fix: align test suite and release infra with develop for 2.2.0

Integrate develop pytest layout, CI workflows, and routing test coverage
on top of the downlink-clean application tree.
pull/29/head
Rodrigo Pérez 3 months ago
parent 905ec792f4
commit f404dee708

@ -0,0 +1,80 @@
# Remind release PR authors to use merge commit (not squash) so post-release
# develop sync can fast-forward and release PRs do not accumulate phantom commits.
name: release-pr-check
on:
pull_request:
branches: [master]
types: [opened, synchronize, reopened, ready_for_review]
permissions:
pull-requests: write
contents: read
jobs:
check:
if: github.event.pull_request.head.ref == 'develop'
runs-on: ubuntu-latest
steps:
- name: Checkout develop tip
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Measure delta vs master
id: delta
run: |
set -euo pipefail
git fetch origin master
commits="$(git rev-list --count origin/master..HEAD)"
echo "commits=${commits}" >> "$GITHUB_OUTPUT"
echo "Commits on develop not in master: ${commits}"
git diff origin/master...HEAD --stat | tail -1 || true
- name: Post merge-method reminder
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- release-pr-check -->';
const commits = '${{ steps.delta.outputs.commits }}';
const body = [
marker,
'## Release PR — merge method',
'',
`**Commits listed on this PR:** ${commits} (file diff may be smaller — that is normal when squash releases stacked up).`,
'',
'### Required',
'Merge with **Create a merge commit**. Do **not** use **Squash and merge**.',
'',
'### Why',
'- Squash on `master` creates new commit SHAs; `develop` keeps the original squash commits from feature PRs.',
'- The next release PR then lists old fixes again even though `master` already has them.',
'- **Merge commit** keeps the same SHAs on both branches; CI **fast-forwards** `develop` to `master` after `chore(release)` — no force-push, local `git pull` on `develop` still works.',
'',
'### One-time cleanup',
'If this PR lists commits from earlier releases (2.0.4, 2.0.5, …), merge once with **merge commit**; post-release sync realigns `develop` with `master` automatically.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}

@ -0,0 +1,104 @@
# Release workflow
#
# master: python-semantic-release on push → semver tag + GitHub Release → sync develop
name: release
on:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-${{ github.repository }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Reset to workflow SHA
run: git reset --hard ${{ github.sha }}
- name: Semantic Release
id: semantic
uses: python-semantic-release/python-semantic-release@v10.5.3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
git_committer_name: github-actions[bot]
git_committer_email: 41898282+github-actions[bot]@users.noreply.github.com
vcs_release: "false"
- name: Create GitHub Release
if: steps.semantic.outputs.released == 'true' && steps.semantic.outputs.tag != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.semantic.outputs.tag }}
VERSION: ${{ steps.semantic.outputs.version }}
RELEASE_NOTES: ${{ steps.semantic.outputs.release_notes }}
run: |
set -euo pipefail
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release already exists for $TAG"
gh release view "$TAG" --json url -q .url
exit 0
fi
NOTES_FILE="$(mktemp)"
if [[ -n "$RELEASE_NOTES" ]]; then
printf '%s\n' "$RELEASE_NOTES" > "$NOTES_FILE"
else
printf 'Release %s\n' "$VERSION" > "$NOTES_FILE"
fi
gh release create "$TAG" --title "$VERSION" --notes-file "$NOTES_FILE"
rm -f "$NOTES_FILE"
- name: Verify GitHub Release
if: steps.semantic.outputs.released == 'true' && steps.semantic.outputs.tag != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.semantic.outputs.tag }}
run: gh release view "$TAG"
- name: Sync develop from master
if: steps.semantic.outputs.released == 'true'
env:
VERSION: ${{ steps.semantic.outputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin master develop
git checkout -B develop origin/develop
phantom_before="$(git rev-list --count origin/master..HEAD)"
echo "Develop commits not in master (before sync): ${phantom_before}"
# Fast-forward works when the release PR used "Create a merge commit" (not squash):
# develop's commits are ancestors of master, so develop can catch up without force-push.
if git merge origin/master --ff-only; then
echo "Fast-forwarded develop to master (branches aligned)"
else
echo "::warning::Could not fast-forward develop to master."
echo "::warning::The release PR was likely squash-merged. Use 'Create a merge commit' on develop→master PRs."
echo "::warning::Applying fallback merge sync (phantom commits on develop will remain until a merge-commit release)."
git merge origin/master --no-edit \
-m "chore: sync develop from master after release ${VERSION}"
fi
git push origin develop
git fetch origin master develop
phantom_after="$(git rev-list --count origin/master..HEAD)"
echo "Develop commits not in master (after sync): ${phantom_after}"
if [[ "${phantom_after}" -eq 0 ]]; then
echo "Develop and master are aligned (next release PR will list only new commits)"
else
echo "::notice::Develop still has ${phantom_after} commit(s) not in master."
echo "::notice::Merge the next release PR with 'Create a merge commit' to realign automatically."
fi

@ -84,3 +84,5 @@ Do not chase coverage percentage; chase regressions operators would notice on RF
## v2 policy
Each v2 feature branch should include or extend regression tests in the same PR. Baseline task **V2-TST-001** tracks bringing `tests/` into the repo on `develop`.
On-air manual QA (three hotspots, OBP, cross-slot, monitor): maintainer playbook in **`docs-priv/es/manual-qa-playbook.md`** (not in the public docs tree).

@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "adn-server"
version = "2.0.0-rc.3"
version = "2.1.2-dev.2"
description = "ADN DMR Peer Server"
readme = "README.md"
license = { text = "GPL-3.0-or-later" }

@ -0,0 +1,26 @@
[pytest]
testpaths = tests
pythonpath = src
addopts = -ra
markers =
smoke: minimal wiring checks; not sufficient alone for regression
behavior: observable regression test (preferred for bridge/voice integration)
unit: pure domain or isolated helper; private methods acceptable
udp_blackbox: opt-in subprocess UDP integration tests (not yet implemented)
[coverage:run]
source = adn_server
omit =
*/main.py
*/parrot_main.py
*/infrastructure/twisted_adapters/*
*/infrastructure/persistence/*
*/infrastructure/security/*
*/infrastructure/voice/*
*/infrastructure/logging_config.py
*/infrastructure/config_loader.py
[coverage:report]
show_missing = false
precision = 0
skip_empty = true

@ -21,11 +21,10 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
from .routing_use_cases import RoutingUseCases
from .ident_use_cases import IdentUseCases
from .ports import (
AliasLoader,
AclRouter,
AliasLoader,
ConfigLoader,
KeysStore,
ReportSender,
@ -34,6 +33,7 @@ from .ports import (
VoiceProvider,
)
from .reporting_use_cases import ReportingUseCases
from .routing_use_cases import RoutingUseCases
from .voice_use_cases import VoiceUseCases
__all__ = [

@ -20,12 +20,6 @@
"""Report application layer: payload mapping and protocol mode (no Twisted / wire bytes)."""
from .queue import (
DEFAULT_MAX_DRAIN_PER_TICK,
DEFAULT_MAX_EVENTS,
BoundedReportQueue,
QueuedReportSender,
)
from .dashboard_state import build_dashboard_state
from .payloads import (
REPORT_FEATURES,
@ -37,6 +31,12 @@ from .payloads import (
routing_table_delta,
topology_delta,
)
from .queue import (
DEFAULT_MAX_DRAIN_PER_TICK,
DEFAULT_MAX_EVENTS,
BoundedReportQueue,
QueuedReportSender,
)
__all__ = [
"DEFAULT_MAX_DRAIN_PER_TICK",

@ -26,6 +26,7 @@ import time
from typing import Any
from adn_server.domain import int_id
from .payloads import _peer_field_json, build_topology

@ -31,12 +31,13 @@ from __future__ import annotations
import copy
from typing import Any
from adn_server.application.proxy.deployment import is_proxy_inject_only, proxy_target_system
from adn_server.application.routing.downlink import (
DownlinkContext,
peer_monitor_end_tx_conflicts_with_session,
peer_would_show_group_voice_on_monitor,
)
from adn_server.application.routing.helpers import is_special_tg, 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
DEFAULT_REPORT_BASE_PORT = 56400
@ -243,6 +244,7 @@ def _peers_receiving_tgid(
sys_cfg: dict[str, Any] | None = None,
downlink_ctx: DownlinkContext | None = None,
stream_id: bytes | None = None,
for_end_tx: bool = False,
) -> list[tuple[Any, dict[str, Any]]]:
out: list[tuple[Any, dict[str, Any]]] = []
n_connected = len(connected)
@ -269,6 +271,10 @@ def _peers_receiving_tgid(
options_eligible=options_ok,
stream_id=stream_id,
):
if for_end_tx and peer_monitor_end_tx_conflicts_with_session(
downlink_ctx, pk, peer, slot, tgid, stream_id,
):
continue
out.append((peer_key, peer))
return out
@ -380,6 +386,7 @@ def remap_inject_proxy_voice_events(
if tgid_slot is None:
return [event]
tgid, voice_slot = tgid_slot
action = parts[1].strip() if len(parts) > 1 else ""
receivers = _peers_receiving_tgid(
connected,
slot=voice_slot,
@ -389,9 +396,10 @@ def remap_inject_proxy_voice_events(
sys_cfg=sys_cfg,
downlink_ctx=downlink_ctx,
stream_id=stream_id,
for_end_tx=(action == "END"),
)
if not receivers:
return [event]
return []
remapped: list[str] = []
for peer_key, _peer in receivers:
mapped_slot = slot_map.get(peer_key)
@ -402,7 +410,7 @@ def remap_inject_proxy_voice_events(
parts, target=target, slot=mapped_slot, peer_key=peer_key
)
)
return remapped if remapped else [event]
return remapped
peer_key = _peer_key_from_voice_csv(parts, peers)
if peer_key is None:
@ -492,6 +500,7 @@ def remap_inject_proxy_voice_event_for_peer(
return None
tgid, voice_slot = tgid_slot
stream_id = _voice_event_stream_id(parts)
action = parts[1].strip() if len(parts) > 1 else ""
if not peer_would_show_group_voice_on_monitor(
downlink_ctx,
pk,
@ -511,6 +520,10 @@ def remap_inject_proxy_voice_event_for_peer(
stream_id=stream_id,
):
return None
if action == "END" and peer_monitor_end_tx_conflicts_with_session(
downlink_ctx, pk, peer, voice_slot, tgid, stream_id,
):
return None
max_slots = int(sys_cfg.get("MAX_PEERS", 1))
slot_map = _resolve_slot_map(_connected_peers(peers), peer_slots, max_slots=max_slots)
mapped_slot = slot_map.get(pk)

@ -26,14 +26,13 @@ import re
import time
from typing import Any
from adn_server.domain.ua_timer import normalize_ua_timer_minutes, ua_timer_is_infinite
from adn_server.application.routing.helpers import (
export_peer_ua_multi_tgs,
export_peer_ua_sessions,
peer_rf_mode,
)
from adn_server.domain import int_id
from adn_server.domain.ua_timer import normalize_ua_timer_minutes, ua_timer_is_infinite
REPORT_PROTOCOL = 2
REPORT_FEATURES = (

@ -26,12 +26,15 @@ import time
from dataclasses import dataclass, field
from typing import Any
from adn_server.domain import HBPF_DATA_SYNC, HBPF_SLT_VTERM, bytes_3, bytes_4, int_id
from adn_server.domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, bytes_3, bytes_4, int_id
from adn_server.domain.hbp_protocol import STREAM_TO
from .helpers import (
SIMPLEX_VOICE_SLOT,
clear_peer_ua_sessions,
hbp_slot_blocks_group_voice_for_peer,
is_special_tg,
is_ua_session_tgid,
master_per_peer_slot_contention,
parse_dmrd_burst_fields,
parse_dmrd_route_fields,
@ -39,7 +42,12 @@ from .helpers import (
peer_is_simplex,
peer_receives_group_tgid,
peer_should_receive_group_voice,
peer_single_exclusive_tgid,
peer_wants_downlink_single_listen_lock,
register_peer_ua_session,
remap_dmrd_to_peer_static_slot,
slot_has_active_voice,
slot_status_hotspot_owner,
synthetic_group_dmrd_route_packet,
)
@ -164,13 +172,50 @@ def peer_slot_blocks_downlink(
wire_slot, tgid, call_type = parsed
if call_type not in ("group", "vcsbk"):
return False
if not ctx.per_peer_contention():
return False
pk = bytes_4(int_id(peer_id))
burst = parse_dmrd_burst_fields(packet)
stream_id = burst[3] if burst is not None else b""
if burst is not None:
_wire_slot, frame_type, dtype_vseq, _sid, dst_id, _call_type = burst
if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM:
now_vt = time.time() if pkt_time is None else float(pkt_time)
listen_slot = peer_downlink_voice_slot(
peer, wire_slot, int_id(dst_id), ctx.sys_cfg, peer_id=peer_id,
)
slot_st = ctx.status.get(int(listen_slot), {})
per_slot = ctx.peer_voice_slots.get(pk, {})
for row in per_slot.values():
if not isinstance(row, dict):
continue
active_stream = row.get("stream_id")
if stream_id and active_stream and active_stream != stream_id:
active_time = float(row.get("time", 0) or 0)
if (now_vt - active_time) < STREAM_TO:
active_tg = int(row.get("tgid", 0) or 0)
incoming_tg = int_id(dst_id)
if (
active_tg
and incoming_tg
and active_tg != incoming_tg
and not row.get("ingress")
):
owner = slot_status_hotspot_owner(slot_st, ctx.peers)
pk_vt = bytes_4(int_id(peer_id))
rx_listening = (
owner is not None
and bytes_4(int_id(owner)) == pk_vt
and slot_has_active_voice(slot_st, now_vt)
and int_id(slot_st.get("RX_TGID", b"")) == active_tg
)
if not rx_listening:
return True
active = per_slot.get(int(listen_slot))
if not isinstance(active, dict) or active.get("stream_id") != stream_id:
return True
if not ctx.per_peer_contention():
return False
hang = float(ctx.sys_cfg.get("GROUP_HANGTIME", 0) or 0)
now = time.time() if pkt_time is None else float(pkt_time)
pk = bytes_4(int_id(peer_id))
peer_slots = ctx.peer_voice_slots.get(pk)
voice_slots = peer_hangtime_voice_slots(
peer, wire_slot, tgid, ctx.sys_cfg, peer_id=peer_id,
@ -191,6 +236,7 @@ def peer_slot_blocks_downlink(
peer_slots=peer_slots,
peer_hang_row=hang_row,
voice_slot=voice_slot,
sys_cfg=ctx.sys_cfg,
):
return True
return False
@ -245,15 +291,19 @@ def touch_peer_voice_slot(
*,
pkt_time: float | None = None,
clear_hangtime: bool = True,
ingress: bool = False,
) -> None:
"""Open per-hotspot voice session until VTERM."""
now = time.time() if pkt_time is None else float(pkt_time)
pk = bytes_4(int_id(peer_id))
ctx.peer_voice_slots.setdefault(pk, {})[int(voice_slot)] = {
row: dict[str, Any] = {
"stream_id": stream_id,
"tgid": int_id(tgid),
"time": float(now),
}
if ingress:
row["ingress"] = True
ctx.peer_voice_slots.setdefault(pk, {})[int(voice_slot)] = row
if clear_hangtime:
ctx.peer_voice_hangtime.get(pk, {}).pop(int(voice_slot), None)
@ -272,6 +322,11 @@ def end_peer_voice_slot(
now = time.time() if pkt_time is None else float(pkt_time)
pk = bytes_4(int_id(peer_id))
per_slot = ctx.peer_voice_slots.get(pk, {})
active = per_slot.get(int(voice_slot))
if isinstance(active, dict):
active_stream = active.get("stream_id")
if stream_id and active_stream and active_stream != stream_id:
return
active = per_slot.pop(int(voice_slot), None)
if not apply_hangtime:
return
@ -301,6 +356,20 @@ def track_peer_group_dmrd(
peer, wire_slot, int_id(dst_id), ctx.sys_cfg, peer_id=peer_id,
)
if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM:
if (
not from_ingress
and peer_wants_downlink_single_listen_lock(peer, ctx.sys_cfg)
):
pk = bytes_4(int_id(peer_id))
per_slot = ctx.peer_voice_slots.get(pk, {})
active = per_slot.get(int(voice_slot))
if isinstance(active, dict) and not active.get("ingress"):
ended_tg = int_id(dst_id)
locked = peer_single_exclusive_tgid(
peer, voice_slot, ctx.sys_cfg, peer_id=peer_id, now=pkt_time,
)
if locked is not None and locked == ended_tg:
clear_peer_ua_sessions(peer, ctx.sys_cfg, peer_id, slot=voice_slot)
end_peer_voice_slot(
ctx,
peer_id,
@ -311,6 +380,21 @@ def track_peer_group_dmrd(
apply_hangtime=from_ingress,
)
return
if (
not from_ingress
and frame_type == HBPF_DATA_SYNC
and dtype_vseq == HBPF_SLT_VHEAD
and peer_wants_downlink_single_listen_lock(peer, ctx.sys_cfg)
and is_ua_session_tgid(int_id(dst_id))
):
pk = bytes_4(int_id(peer_id))
per_slot = ctx.peer_voice_slots.get(pk, {})
active = per_slot.get(int(voice_slot))
if not isinstance(active, dict) or active.get("stream_id") != stream_id:
now = time.time() if pkt_time is None else float(pkt_time)
register_peer_ua_session(
peer, peer_id, voice_slot, int_id(dst_id), ctx.sys_cfg, now=now,
)
touch_peer_voice_slot(
ctx,
peer_id,
@ -319,6 +403,7 @@ def track_peer_group_dmrd(
dst_id,
pkt_time=pkt_time,
clear_hangtime=from_ingress,
ingress=from_ingress,
)
@ -368,6 +453,49 @@ def peer_would_show_group_voice_on_monitor(
return not peer_slot_blocks_downlink(ctx, peer_id, peer, remapped)
def peer_monitor_end_tx_conflicts_with_session(
ctx: DownlinkContext | None,
peer_id: bytes,
peer: dict[str, Any],
wire_slot: int,
tgid: int,
stream_id: bytes | None,
) -> bool:
"""True when END/TX must not touch this hotspot (active QSO on another stream/TG)."""
if ctx is None or not stream_id:
return False
pk = bytes_4(int_id(peer_id))
peer_slots = ctx.peer_voice_slots.get(pk)
if not isinstance(peer_slots, dict):
return False
incoming_tg = int(tgid)
now = time.time()
for voice_slot in peer_hangtime_voice_slots(
peer, wire_slot, tgid, ctx.sys_cfg, peer_id=peer_id,
):
row = peer_slots.get(int(voice_slot))
if not isinstance(row, dict) or row.get("ingress"):
continue
active_stream = row.get("stream_id")
active_tg = int(row.get("tgid", 0) or 0)
if not active_stream or active_stream == stream_id:
continue
if (now - float(row.get("time", 0) or 0)) >= STREAM_TO:
continue
if not active_tg or active_tg == incoming_tg:
continue
slot_st = ctx.status.get(int(voice_slot), {})
owner = slot_status_hotspot_owner(slot_st, ctx.peers)
if (
owner is not None
and bytes_4(int_id(owner)) == pk
and slot_has_active_voice(slot_st, now)
and int_id(slot_st.get("RX_TGID", b"")) == active_tg
):
return True
return False
def iter_downlink_voice_slots(
peer: dict[str, Any],
wire_slot: int,

@ -164,6 +164,7 @@ def hbp_slot_blocks_group_voice(
group_hangtime: float,
*,
allow_same_stream: bool = True,
is_vterm: bool = False,
) -> bool:
"""True when group voice must not be routed or repeated to this slot.
@ -171,7 +172,19 @@ def hbp_slot_blocks_group_voice(
Post-VTERM: other TGs are blocked for ``group_hangtime`` seconds from config.
"""
if allow_same_stream and stream_id:
if stream_id == slot_st.get("RX_STREAM_ID") or stream_id == slot_st.get("TX_STREAM_ID"):
if stream_id == slot_st.get("RX_STREAM_ID"):
return False
if stream_id == slot_st.get("TX_STREAM_ID"):
rx_stream = slot_st.get("RX_STREAM_ID")
# Bridge TX stamp must not preempt active RX on a different stream.
if slot_has_active_voice(slot_st, pkt_time):
if rx_stream and stream_id != rx_stream:
pass
else:
return False
elif is_vterm and rx_stream and int_id(rx_stream) != 0 and stream_id != rx_stream:
pass
else:
return False
if slot_has_active_voice(slot_st, pkt_time):
return True
@ -258,6 +271,26 @@ def _peer_status_rx_hangtime_blocks(
return (pkt_time - rx_t) < hang
def peer_cross_static_tg_allowed_on_slot(
peer: dict[str, Any],
voice_slot: int,
incoming_tgid_b: bytes,
sys_cfg: dict[str, Any] | None,
*,
peer_id: bytes | None = None,
now: float | None = None,
) -> bool:
"""True when another static OPTIONS TG may share this RF slot (SINGLE=0 or no UA lock)."""
if not sys_cfg:
return False
incoming = int_id(incoming_tgid_b)
if not peer_receives_group_tgid(peer, voice_slot, incoming):
return False
return not peer_single_blocks_group_voice(
peer, voice_slot, incoming, sys_cfg, peer_id=peer_id, now=now,
)
def peer_hotspot_voice_slot_busy(
peer_id: bytes,
voice_slot: int,
@ -270,23 +303,77 @@ def peer_hotspot_voice_slot_busy(
group_hangtime: float,
*,
peers: dict[Any, Any] | None = None,
peer: dict[str, Any] | None = None,
sys_cfg: dict[str, Any] | None = None,
) -> bool:
"""True when this hotspot must not receive another group stream on ``voice_slot``."""
pk = bytes_4(int_id(peer_id))
if peer is None and peers is not None:
peer = peers.get(peer_id) or peers.get(pk)
cross_static = (
isinstance(peer, dict)
and peer_cross_static_tg_allowed_on_slot(
peer, voice_slot, incoming_tgid_b, sys_cfg, peer_id=peer_id, now=pkt_time,
)
)
# Ingress-owned post-VTERM window: must win over OBP bridge TX stamp on STATUS[slot].
if _peer_transmit_hangtime_blocks(hang_row, incoming_tgid_b, pkt_time, group_hangtime):
return True
# OBP bridge TX stamp on shared STATUS[slot] overrides stale per-peer session rows.
active = (peer_slots or {}).get(int(voice_slot))
if isinstance(active, dict) and active.get("ingress"):
active_stream = active.get("stream_id")
if stream_id and active_stream == stream_id:
return False
if cross_static:
return False
# Local RF TX: block foreign streams even when bridge TX stamp matches.
return True
if isinstance(active, dict):
active_stream = active.get("stream_id")
if stream_id and active_stream == stream_id:
return False
if stream_id and active_stream and active_stream != stream_id:
active_time = float(active.get("time", 0) or 0)
if (pkt_time - active_time) < STREAM_TO:
active_tg = int(active.get("tgid", 0) or 0)
incoming_tg = int_id(incoming_tgid_b)
if (
active_tg
and incoming_tg
and active_tg != incoming_tg
and not active.get("ingress")
):
if cross_static:
return False
owner = slot_status_hotspot_owner(slot_st, peers)
rx_listening = (
owner is not None
and bytes_4(int_id(owner)) == pk
and slot_has_active_voice(slot_st, pkt_time)
and int_id(slot_st.get("RX_TGID", b"")) == active_tg
)
if not rx_listening:
return True
tx_matches = (
stream_id == slot_st.get("TX_STREAM_ID")
and slot_st.get("TX_PEER") is not None
and int_id(slot_st.get("TX_PEER")) != 0
and (peers is None or not peer_key_in_peers(slot_st.get("TX_PEER"), peers))
)
if not tx_matches:
return True
# OBP bridge TX stamp on shared STATUS[slot] overrides stale downlink-only session rows.
if stream_id and stream_id == slot_st.get("TX_STREAM_ID"):
tx_peer = slot_st.get("TX_PEER")
if tx_peer is not None and int_id(tx_peer) != 0:
if peers is None or not peer_key_in_peers(tx_peer, peers):
return False
active = (peer_slots or {}).get(int(voice_slot))
if isinstance(active, dict):
active_stream = active.get("stream_id")
if stream_id and active_stream == stream_id:
return False
if cross_static:
return False
# Session stays open until VTERM clears ``peer_slots``; DMR voice has
# inter-burst gaps longer than STREAM_TO so time-since-last-packet must
# not release the slot to another TG mid-QSO.
@ -309,13 +396,8 @@ def master_per_peer_slot_contention(
connected_count: int = 0,
) -> bool:
"""True when slot busy/hangtime applies per hotspot, not globally on the MASTER row."""
if system_cfg.get("MODE") != "MASTER":
return False
from ..proxy.deployment import is_proxy_inject_only
if is_proxy_inject_only(config, system_name):
return True
return connected_count > 1
del config, system_name, connected_count
return system_cfg.get("MODE") == "MASTER"
def inject_only_defer_obp_hbp_slot_contention(
@ -324,21 +406,24 @@ def inject_only_defer_obp_hbp_slot_contention(
target_system_cfg: dict[str, Any],
*,
source_is_obp: bool,
source_is_hbp: bool = False,
connected_count: int = 0,
) -> bool:
"""Whether OBP→MASTER ``to_target`` should skip global slot STATUS contention.
"""Whether ``to_target`` should skip global MASTER slot STATUS contention.
Defer to ``send_peer`` (same as REPEAT): per-peer ``hbp_slot_blocks_group_voice_for_peer``
+ OPTIONS/UA slot remap. Global STATUS on the bridge wire TS would block cross-slot
downlink while another peer is active on that TS even though recipients listen elsewhere.
+ OPTIONS/UA slot remap. Global STATUS on the bridge wire TS would block another
hotspot's TG while a different peer is active on that TS.
"""
if not source_is_obp:
return False
if target_system_cfg.get("MODE") != "MASTER":
return False
return master_per_peer_slot_contention(
if not master_per_peer_slot_contention(
config, target_system, target_system_cfg, connected_count=connected_count,
)
):
return False
if source_is_obp:
return True
return source_is_hbp and connected_count > 1
def _downlink_same_stream_for_peer(
@ -374,6 +459,7 @@ def hbp_slot_blocks_group_voice_for_peer(
peer_slots: PeerVoiceSlotMap | None = None,
peer_hang_row: tuple[int, float] | None = None,
voice_slot: int | None = None,
sys_cfg: dict[str, Any] | None = None,
) -> bool:
"""Slot contention scoped to one hotspot when ``per_peer`` (inject-only multi-HS).
@ -387,6 +473,10 @@ def hbp_slot_blocks_group_voice_for_peer(
if per_peer:
if voice_slot is None:
return False
peer = None
if peers is not None:
pk = bytes_4(int_id(peer_id))
peer = peers.get(peer_id) or peers.get(pk)
return peer_hotspot_voice_slot_busy(
peer_id,
int(voice_slot),
@ -398,6 +488,8 @@ def hbp_slot_blocks_group_voice_for_peer(
pkt_time,
group_hangtime,
peers=peers,
peer=peer if isinstance(peer, dict) else None,
sys_cfg=sys_cfg,
)
return hbp_slot_blocks_group_voice(
slot_st, incoming_tgid_b, stream_id, pkt_time, group_hangtime,
@ -598,6 +690,24 @@ def is_special_tg(relay_table_key: str) -> bool:
return False
def is_on_demand_service_dst(dst_id: int) -> bool:
"""True for private-call destinations 9991-9999 (on-demand audio trigger)."""
return 9991 <= int(dst_id) <= 9999
def is_server_originated_voice(packet: bytes) -> bool:
"""True for server group playback (src 5000 -> TG 9 TS2), e.g. on-demand / disconnected."""
if len(packet) < 11:
return False
burst = parse_dmrd_burst_fields(packet)
if burst is None:
return False
slot, _, _, _, dst_id, call_type = burst
if call_type not in ("group", "vcsbk"):
return False
return int_id(packet[5:8]) == 5000 and int_id(dst_id) == 9 and slot == 2
def parse_dmrd_route_fields(packet: bytes) -> tuple[int, int, str] | None:
"""Parse HBP DMRD slot, destination TG, and call type for downlink OPTIONS filter."""
burst = parse_dmrd_burst_fields(packet)
@ -1069,6 +1179,21 @@ def peer_single_blocks_group_voice(
return False
def peer_static_options_tg_count(peer: dict[str, Any]) -> int:
"""Count distinct static group TGs listed in peer OPTIONS (TS1 ∪ TS2)."""
from adn_server.application.report.payloads import parse_peer_options_static
ts1, ts2 = parse_peer_options_static(peer.get("OPTIONS"))
return len(set(ts1) | set(ts2))
def peer_wants_downlink_single_listen_lock(peer: dict[str, Any], sys_cfg: dict[str, Any]) -> bool:
"""SINGLE=1 downlink listen lock for overlap — not full-table lab witnesses."""
if not peer_single_mode(peer, sys_cfg):
return False
return peer_static_options_tg_count(peer) <= 6
def peer_receives_group_tgid(peer: dict[str, Any], slot: int, tgid: int) -> bool:
"""True when peer RPTO OPTIONS list the group TG on TS1 or TS2 (legacy REPEAT parity).

@ -48,9 +48,9 @@ from typing import Any
from bitarray import bitarray
from ...domain import int_id
from ...domain.dmr.const import LC_OPT
from ...domain.talker_alias import DMRA_BLOCK_COUNT
from ...domain import int_id
from ..talker_alias_use_cases import passthrough_complete, talker_alias_settings
from .helpers import EMB_LC_SLICE

@ -49,9 +49,9 @@ from hashlib import blake2b
from time import perf_counter
from typing import Any
from ...domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, int_id
from ...domain.dmr import decode
from ...domain.dmr.const import LC_OPT
from ...domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, int_id
logger = logging.getLogger(__name__)

@ -45,10 +45,10 @@ from __future__ import annotations
import logging
from ...domain.voice_routing import ForwardLeg, VoiceIngress
from ..ports import SubscriptionStore
from ..subscription.ingress import build_voice_ingress
from ..subscription.router import SubscriptionRouter
from ...domain.voice_routing import ForwardLeg, VoiceIngress
logger = logging.getLogger(__name__)

@ -34,32 +34,31 @@ import time
from time import perf_counter
from typing import Any
from .reporting_use_cases import ReportingUseCases
from bitarray import bitarray
from ..domain.dmr import bptc
from ..domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, STREAM_TO, bytes_3, bytes_4, int_id
from ..domain.dmr import bptc
from .ports import AclRouter, DmrEmbeddedLcEncoder, SubscriptionStore, TalkerAliasEmblcEncoder
from .talker_alias_use_cases import TalkerAliasUseCases
from .routing.peer_downlink_index import count_connected_peers
from .reporting_use_cases import ReportingUseCases
from .routing.hbp_forward import HbpForwardMixin
from .routing.helpers import (
hbp_slot_blocks_group_voice,
inject_only_defer_obp_hbp_slot_contention,
slot_has_active_voice,
is_private_subscriber_dst,
is_unit_data_ingress,
obp_target_bcsq_quenches_stream,
resolve_voice_peer_id,
slot_has_active_voice,
unit_data_hbp_target_idle,
)
from .routing.timers import RoutingTimerMixin
from .routing.obp_forward import ObpForwardMixin
from .routing.hbp_forward import HbpForwardMixin
from .routing.lc_ta import LcTaMixin
from .routing.subscription_table import SubscriptionTableMixin
from .routing.obp_forward import ObpForwardMixin
from .routing.peer_downlink_index import count_connected_peers
from .routing.store_authority_mixin import StoreAuthorityMixin
from .routing.subscription_table import SubscriptionTableMixin
from .routing.timers import RoutingTimerMixin
from .routing.voice_subscription import VoiceSubscriptionMixin
from .talker_alias_use_cases import TalkerAliasUseCases
logger = logging.getLogger(__name__)
@ -635,17 +634,21 @@ class RoutingUseCases(
entry["SYSTEM"],
_target_system,
source_is_obp=source_is_obp,
source_is_hbp=not source_is_obp,
connected_count=_target_connected,
)
if (
not _closing_bridge_leg
and not _defer_slot_contention
not _defer_slot_contention
and hbp_slot_blocks_group_voice(
_ts_st,
entry_tgid_b,
stream_id,
pkt_time,
_group_hangtime,
is_vterm=(
frame_type == HBPF_DATA_SYNC
and dtype_vseq == HBPF_SLT_VTERM
),
)
):
if not _src_stream_st.get("CONTENTION"):

@ -20,17 +20,17 @@
"""Subscription application helpers."""
from .ingress import build_voice_ingress
from .router import SubscriptionRouter
from .routing_table_export import export_routing_table, subscription_to_legacy_row
from .routing_table_import import subscriptions_from_routing_table
from .routing_table_legacy_view import RoutingTableLegacyView
from .store_sync import replace_store_from_routing_table
from .subscription_queries import (
active_system_slots_for_tg_in_store,
store_has_table,
system_has_active_leg_in_store,
)
from .routing_table_legacy_view import RoutingTableLegacyView
from .ingress import build_voice_ingress
from .router import SubscriptionRouter
__all__ = [
"RoutingTableLegacyView",

@ -23,8 +23,8 @@
from __future__ import annotations
from adn_server.domain import int_id
from adn_server.domain.voice_routing import VoiceIngress
from adn_server.domain.value_objects import DmrId, TgId
from adn_server.domain.voice_routing import VoiceIngress
_BRIDGE_CALL_TYPES = frozenset({"group", "vcsbk"})

@ -28,8 +28,8 @@ from collections.abc import Callable
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.routing_table_export import _legacy_to_type
from adn_server.application.routing.helpers import is_special_tg
from adn_server.application.subscription.routing_table_export import _legacy_to_type
from adn_server.domain.subscription import Subscription, SubscriptionPhase
logger = logging.getLogger(__name__)

@ -22,17 +22,6 @@
###############################################################################
from .entities import BridgeEntry, StreamState, SystemConfig
from .subscription import (
ActivationPolicy,
AudioChannel,
Subscription,
SubscriptionId,
SubscriptionPhase,
SubscriptionRole,
SubscriptionState,
SystemId,
)
from .voice_routing import ForwardLeg, VoiceIngress
from .errors import ACLError, ConfigError, DomainError
from .hbp_protocol import (
HBPF_DATA_SYNC,
@ -45,7 +34,18 @@ from .hbp_protocol import (
VER,
)
from .result import Fail, Result, Success, is_fail, is_ok, unwrap_or
from .subscription import (
ActivationPolicy,
AudioChannel,
Subscription,
SubscriptionId,
SubscriptionPhase,
SubscriptionRole,
SubscriptionState,
SystemId,
)
from .value_objects import ID_MAX, ID_MIN, PEER_MAX, CallType, DmrId, Slot, TgId, bytes_3, bytes_4, int_id
from .voice_routing import ForwardLeg, VoiceIngress
__all__ = [
"BridgeEntry",

@ -43,6 +43,7 @@
from bitarray import bitarray
from . import crc, hamming, rs129
# Does anybody read this stuff? There's a PEP somewhere that says I should do this.

@ -43,7 +43,6 @@
from bitarray import bitarray
__author__ = 'Cortney T. Buffington, N0MJS'
__copyright__ = 'Copyright (c) 2016-2018 Cortney T. Buffington, N0MJS and the K0USY Group'
__credits__ = ''

@ -43,6 +43,7 @@
from bitarray import bitarray
from . import bptc
# Does anybody read this stuff? There's a PEP somewhere that says I should do this.

@ -30,14 +30,14 @@ from typing import Any
from twisted.internet import reactor, task, threads
from adn_server.application.dynamic_tg_use_cases import DynamicTgUseCases
from adn_server.application import (
RoutingUseCases,
IdentUseCases,
ReportingUseCases,
ReportSender,
RoutingUseCases,
VoiceUseCases,
)
from adn_server.application.dynamic_tg_use_cases import DynamicTgUseCases
from adn_server.application.proxy.deployment import (
is_proxy_inject_only,
normalize_proxy_target,
@ -51,11 +51,10 @@ from adn_server.application.runtime_context import (
prepare_reload_config,
swap_runtime_config,
)
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain import bytes_3
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.infrastructure.acl_router import InMemoryAclRouter
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from adn_server.infrastructure.config_normalizer import (
ensure_system_runtime_config as _ensure_system_runtime_config,
)
@ -71,17 +70,18 @@ from adn_server.infrastructure.config_normalizer import (
from adn_server.infrastructure.config_reload import BindSpec, reload_server_config
from adn_server.infrastructure.logging_config import reopen_file_handlers
from adn_server.infrastructure.persistence import PickleSubMapStore
from adn_server.infrastructure.persistence.alias_loader import DefaultAliasLoader
from adn_server.infrastructure.persistence.database_config import database_settings
from adn_server.infrastructure.persistence.dynamic_tg_repository import MysqlDynamicTgRepository
from adn_server.infrastructure.persistence.mysql_pool import create_mysql_pool, ensure_database_sync
from adn_server.infrastructure.persistence.alias_loader import DefaultAliasLoader
from adn_server.infrastructure.persistence.keys_store import JsonKeysStore
from adn_server.infrastructure.persistence.mysql_pool import create_mysql_pool, ensure_database_sync
from adn_server.infrastructure.proxy import apply_proxy_config_reload, start_proxy_service
from adn_server.infrastructure.security.password_download import (
DefaultSecurityDownloader,
StubSecurityDownloader,
)
from adn_server.infrastructure.security.user_passwords_loader import UserPasswordsLoader
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder
from adn_server.infrastructure.twisted_adapters.report.mqtt_config import mqtt_settings_from_config
from adn_server.infrastructure.twisted_adapters.report.mqtt_publisher import (

@ -29,6 +29,8 @@ from typing import Any, Callable
from twisted.internet import defer
from adn_server.application.proxy.deployment import normalize_proxy_target
from ..domain.errors import ConfigError
from .config_loader import YamlConfigLoader
from .config_normalizer import (
@ -38,7 +40,6 @@ from .config_normalizer import (
normalize_obp_config,
normalize_peer_config,
)
from adn_server.application.proxy.deployment import normalize_proxy_target
from .logging_config import reapply_log_level
logger = logging.getLogger(__name__)

@ -26,7 +26,7 @@ import time
from adn_server.application.ports import PeerTransport
from adn_server.domain.mesh_routing import MeshEgress, MeshIngress, PeerMeshConfig
from adn_server.infrastructure.hbp_constants import DMRE, DMRD
from adn_server.infrastructure.hbp_constants import DMRD, DMRE
from adn_server.infrastructure.mesh.dmre_v5 import build_dmre, parse_dmre_trailer, verify_dmre_mac
from adn_server.infrastructure.mesh.obp_v1 import build_dmrd_v1, verify_dmrd_v1

@ -21,12 +21,12 @@
"""Proxy infrastructure adapters (Phase 3)."""
from .config import apply_proxy_env_overrides, proxy_settings
from .self_service_config import self_service_settings
from .hbp_adapters import FanInClientSender, HbpMasterPeerRegistry, InProcessHbpSink
from .ip_blacklist import InMemoryProxyIpBlacklist
from .reply_transport import ProxyReplyTransport
from .runtime import ProxyServiceState, apply_proxy_config_reload, start_proxy_service
from .rpto_queue import InMemoryPendingRptoQueue
from .runtime import ProxyServiceState, apply_proxy_config_reload, start_proxy_service
from .self_service_config import self_service_settings
from .session_executor import apply_session_teardown
from .slot_store import InMemoryProxySlotStore
from .udp_fanin import ProxyFanInProtocol, listen_proxy_fanin

@ -29,8 +29,8 @@ from typing import Any
from twisted.internet import reactor
from twisted.internet.interfaces import IDelayedCall
from adn_server.application.proxy import ProxyUseCases
from adn_server.application.ports import ProxyClientSender, ProxyMasterSink
from adn_server.application.proxy import ProxyUseCases
from adn_server.domain.value_objects import int_id
from adn_server.infrastructure.proxy.config import proxy_settings
from adn_server.infrastructure.proxy.hbp_adapters import (
@ -39,13 +39,13 @@ from adn_server.infrastructure.proxy.hbp_adapters import (
InProcessHbpSink,
)
from adn_server.infrastructure.proxy.ip_blacklist import InMemoryProxyIpBlacklist
from adn_server.infrastructure.proxy.reply_transport import ProxyReplyTransport
from adn_server.infrastructure.proxy.rpto_queue import InMemoryPendingRptoQueue
from adn_server.infrastructure.proxy.session_executor import apply_session_teardown
from adn_server.infrastructure.proxy.slot_store import InMemoryProxySlotStore
from adn_server.infrastructure.proxy.self_service_bridge import ProxySelfServiceBridge
from adn_server.infrastructure.proxy.self_service_config import self_service_settings
from adn_server.infrastructure.proxy.session_executor import apply_session_teardown
from adn_server.infrastructure.proxy.slot_store import InMemoryProxySlotStore
from adn_server.infrastructure.proxy.udp_fanin import ProxyFanInProtocol, listen_proxy_fanin
from adn_server.infrastructure.proxy.reply_transport import ProxyReplyTransport
def _proxy_runtime_snapshot(config: dict[str, Any]) -> dict[str, Any]:

@ -25,6 +25,7 @@ from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING
from ..domain.dmr.bptc import encode_emblc
from ..domain.talker_alias import (
blocks_from_buffer,
buffer_from_blocks,
@ -35,7 +36,6 @@ from ..domain.talker_alias import (
talker_alias_decode_complete,
talker_alias_lc_bytes,
)
from ..domain.dmr.bptc import encode_emblc
if TYPE_CHECKING:
from bitarray import bitarray

@ -27,8 +27,8 @@ import logging
from collections.abc import Callable
from typing import Any
from adn_server.application.report.dashboard_state import build_dashboard_state
from adn_server.application.ports import ReportMqttPublisher, ReportWireEncoder
from adn_server.application.report.dashboard_state import build_dashboard_state
from .mqtt_config import MQTT_PUBLISH_VOICE_EVENT, MqttSettings, mqtt_settings_from_config
from .mqtt_topics import frame_message_type, mqtt_shared_state_topic, topic_for_frame

@ -27,8 +27,8 @@ from typing import Any, Callable
from twisted.internet import task
from adn_server.application.report.queue import BoundedReportQueue
from adn_server.application.ports import ReportSender
from adn_server.application.report.queue import BoundedReportQueue
logger = logging.getLogger(__name__)

@ -35,11 +35,10 @@ from hashlib import sha256
from random import randint
from typing import Any, Callable
from ...domain.dmr import decode
from ...domain.dmr.const import LC_OPT
from twisted.internet import reactor, task
from twisted.internet.protocol import DatagramProtocol
from ...application.proxy.deployment import is_proxy_inject_only
from ...application.routing.downlink import (
DownlinkContext,
build_dmra_route_packet,
@ -53,6 +52,8 @@ from ...application.routing.downlink import (
from ...application.routing.helpers import (
clear_peer_rx_status_slots,
clear_peer_ua_sessions,
is_on_demand_service_dst,
is_server_originated_voice,
is_special_tg,
is_unit_data_ingress,
parse_dmrd_route_fields,
@ -70,8 +71,10 @@ from ...application.routing.peer_downlink_index import (
count_connected_peers,
invalidate_peer_options_cache,
)
from ...application.proxy.deployment import is_proxy_inject_only
from ...domain import bytes_3, bytes_4, int_id
from ...domain.dmr import decode
from ...domain.dmr.const import LC_OPT
from ...domain.mesh_routing import MeshEgress, MeshIngress, PeerMeshConfig
from ...domain.talker_alias import (
DMRA_PACKET_LEN,
decode_ta_from_blocks,
@ -79,19 +82,7 @@ from ...domain.talker_alias import (
store_ta_block,
try_buffer_ta_from_voice_fragments,
)
from ...domain.mesh_routing import MeshEgress, MeshIngress, PeerMeshConfig
from ..mesh.dmre_v5 import parse_dmre_trailer
from ..mesh.registry import MeshCodecRegistry
from ..config_push_throttle import ConfigPushThrottle
from ..mesh.obp_v1 import (
build_bcka,
build_bcve,
build_bcsq,
verify_bcka,
verify_bcsq,
verify_bcst,
verify_bcve,
)
from ..hbp_constants import (
BC,
BCKA,
@ -125,6 +116,17 @@ from ..hbp_constants import (
RPTPING,
VER,
)
from ..mesh.dmre_v5 import parse_dmre_trailer
from ..mesh.obp_v1 import (
build_bcka,
build_bcsq,
build_bcve,
verify_bcka,
verify_bcsq,
verify_bcst,
verify_bcve,
)
from ..mesh.registry import MeshCodecRegistry
logger = logging.getLogger(__name__)
@ -390,11 +392,19 @@ class HBPProtocol(DatagramProtocol):
slot, tgid, call_type = parsed
if call_type not in ("group", "vcsbk"):
return self._peers.keys()
if is_special_tg(str(tgid)):
return self._peers.keys()
connected = self._cached_connected_peer_count()
if connected <= 0:
return ()
if is_server_originated_voice(packet):
slot_st = self.STATUS.get(2, {})
rx_peer = slot_st.get("RX_PEER", b"")
if rx_peer and rx_peer in self._peers:
return (rx_peer,)
if connected == 1:
return self._peers.keys()
return ()
if is_special_tg(str(tgid)):
return self._peers.keys()
index = self._ensure_downlink_index()
return index.candidates(slot, tgid, connected_count=connected)
@ -549,6 +559,13 @@ class HBPProtocol(DatagramProtocol):
slot, tgid, call_type = parsed
if call_type not in ("group", "vcsbk"):
return True
# Server playback (5000 -> TG 9): not in OPTIONS; deliver to requesting RX peer on TS2.
if is_server_originated_voice(packet):
slot_st = self.STATUS.get(2, {})
rx_peer = slot_st.get("RX_PEER", b"")
if rx_peer and rx_peer != b"\x00\x00\x00\x00":
return bytes_4(int_id(peer_id)) == bytes_4(int_id(rx_peer))
return self._cached_connected_peer_count() == 1
# Parrot / echo (9990–9999): not in per-hotspot OPTIONS; deliver to last RX peer on slot.
if is_special_tg(str(tgid)):
slot_st = self.STATUS.get(slot, {})
@ -587,16 +604,15 @@ class HBPProtocol(DatagramProtocol):
) -> None:
if call_type not in ("group", "vcsbk"):
return
ctx = self._downlink_ctx()
if not ctx.per_peer_contention():
return
peer = self._peers.get(peer_id)
if peer is None:
return
from ...application.routing.downlink import normalize_ua_voice_slot
# Track transmit hangtime on the RF timeslot keyed, not OPTIONS remap slot.
voice_slot = normalize_ua_voice_slot(peer, wire_slot)
ctx = self._downlink_ctx()
if not ctx.per_peer_contention():
return
if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VTERM:
from ...application.routing.downlink import end_peer_voice_slot
@ -607,7 +623,7 @@ class HBPProtocol(DatagramProtocol):
from ...application.routing.downlink import touch_peer_voice_slot
touch_peer_voice_slot(
ctx, peer_id, voice_slot, stream_id, dst_id, pkt_time=pkt_time,
ctx, peer_id, voice_slot, stream_id, dst_id, pkt_time=pkt_time, ingress=True,
)
def _peer_would_accept_group_dmrd(self, peer_id: bytes, packet: bytes) -> bool:
@ -656,7 +672,7 @@ class HBPProtocol(DatagramProtocol):
self._downlink_drop_logged.discard(self._downlink_drop_key(_peer, route_pkt))
_packet = b"".join([route_pkt[:11], _peer, route_pkt[15:]])
ctx = self._downlink_ctx()
if ctx.per_peer_contention() and isinstance(peer, dict):
if isinstance(peer, dict):
track_peer_group_dmrd(ctx, _peer, _packet, peer, from_ingress=False)
self.transport.write(_packet, self._peers[_peer]["SOCKADDR"])
@ -1068,6 +1084,7 @@ class HBPProtocol(DatagramProtocol):
return
pkt_time = time.time()
_int_dst_id = int_id(_dst_id)
_unit_service_dst = _call_type == "unit" and is_on_demand_service_dst(_int_dst_id)
# ACL (legacy order and _laststrid)
if self._router and _global.get("USE_ACL"):
if not self._router.acl_check(_rf_src, _global.get("SUB_ACL", (True, []))):
@ -1075,12 +1092,12 @@ class HBPProtocol(DatagramProtocol):
logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY GLOBAL ACL", self._system, int_id(_stream_id), int_id(_rf_src))
self._laststrid[_slot] = _stream_id
return
if _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))):
if not _unit_service_dst and _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))):
if self._laststrid[_slot] != _stream_id:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._laststrid[_slot] = _stream_id
return
if _slot == 2 and not self._router.acl_check(_dst_id, _global.get("TG2_ACL", (True, []))):
if not _unit_service_dst and _slot == 2 and not self._router.acl_check(_dst_id, _global.get("TG2_ACL", (True, []))):
if self._laststrid[_slot] != _stream_id:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS2 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._laststrid[_slot] = _stream_id
@ -1091,12 +1108,12 @@ class HBPProtocol(DatagramProtocol):
logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_rf_src))
self._laststrid[_slot] = _stream_id
return
if _slot == 1 and not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))):
if not _unit_service_dst and _slot == 1 and not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))):
if self._laststrid[_slot] != _stream_id:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._laststrid[_slot] = _stream_id
return
if _slot == 2 and not self._router.acl_check(_dst_id, self._config.get("TG2_ACL", (True, []))):
if not _unit_service_dst and _slot == 2 and not self._router.acl_check(_dst_id, self._config.get("TG2_ACL", (True, []))):
if self._laststrid[_slot] != _stream_id:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM TS2 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._laststrid[_slot] = _stream_id
@ -1233,8 +1250,11 @@ class HBPProtocol(DatagramProtocol):
self._on_handle_recording(dmrpkt, _frame_type, _dtype_vseq, _stream_id, pkt_time, _rf_src, _int_dst_id, _slot)
if (
_call_type == "unit"
and not _unit_data
and _frame_type == HBPF_DATA_SYNC
and _dtype_vseq == HBPF_SLT_VTERM
and _slot in self.STATUS
and self.STATUS[_slot].get("RX_TYPE") != HBPF_SLT_VTERM
and 9991 <= _int_dst_id <= 9999
and self._on_play_file_request
):
@ -1553,18 +1573,19 @@ class HBPProtocol(DatagramProtocol):
pkt_time = time.time()
_int_dst_id = int_id(_dst_id)
_global = self._CONFIG.get("GLOBAL", {})
_unit_service_dst = _call_type == "unit" and is_on_demand_service_dst(_int_dst_id)
if self._router and _global.get("USE_ACL"):
if not self._router.acl_check(_rf_src, _global.get("SUB_ACL", (True, []))):
if self._laststrid[_slot] != _stream_id:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY GLOBAL ACL", self._system, int_id(_stream_id), int_id(_rf_src))
self._laststrid[_slot] = _stream_id
return
if _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))):
if not _unit_service_dst and _slot == 1 and not self._router.acl_check(_dst_id, _global.get("TG1_ACL", (True, []))):
if self._laststrid[_slot] != _stream_id:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._laststrid[_slot] = _stream_id
return
if _slot == 2 and not self._router.acl_check(_dst_id, _global.get("TG2_ACL", (True, []))):
if not _unit_service_dst and _slot == 2 and not self._router.acl_check(_dst_id, _global.get("TG2_ACL", (True, []))):
if self._laststrid[_slot] != _stream_id:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY GLOBAL TS2 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._laststrid[_slot] = _stream_id
@ -1575,12 +1596,12 @@ class HBPProtocol(DatagramProtocol):
logger.info("(%s) CALL DROPPED WITH STREAM ID %s FROM SUBSCRIBER %s BY SYSTEM ACL", self._system, int_id(_stream_id), int_id(_rf_src))
self._laststrid[_slot] = _stream_id
return
if _slot == 1 and not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))):
if not _unit_service_dst and _slot == 1 and not self._router.acl_check(_dst_id, self._config.get("TG1_ACL", (True, []))):
if self._laststrid[_slot] != _stream_id:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM TS1 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._laststrid[_slot] = _stream_id
return
if _slot == 2 and not self._router.acl_check(_dst_id, self._config.get("TG2_ACL", (True, []))):
if not _unit_service_dst and _slot == 2 and not self._router.acl_check(_dst_id, self._config.get("TG2_ACL", (True, []))):
if self._laststrid[_slot] != _stream_id:
logger.info("(%s) CALL DROPPED WITH STREAM ID %s ON TGID %s BY SYSTEM TS2 ACL", self._system, int_id(_stream_id), int_id(_dst_id))
self._laststrid[_slot] = _stream_id
@ -1634,8 +1655,11 @@ class HBPProtocol(DatagramProtocol):
self._on_handle_recording(dmrpkt, _frame_type, _dtype_vseq, _stream_id, pkt_time, _rf_src, _int_dst_id, _slot)
if (
_call_type == "unit"
and not _unit_data
and _frame_type == HBPF_DATA_SYNC
and _dtype_vseq == HBPF_SLT_VTERM
and _slot in self.STATUS
and self.STATUS[_slot].get("RX_TYPE") != HBPF_SLT_VTERM
and 9991 <= _int_dst_id <= 9999
and self._on_play_file_request
):

@ -29,10 +29,11 @@ from random import randint
from typing import Any, Iterator
from bitarray import bitarray
from ...domain import bytes_4
from ...domain.dmr import bptc
from ...domain.dmr.bptc import encode_emblc
from ...domain.dmr.const import BS_DATA_SYNC, BS_VOICE_SYNC, EMB, LC_OPT, SLOT_TYPE
from ...domain import bytes_4
# Precalculated DMRD byte 15 (slot << 7 | this)
HEADBITS = 0b00100001

@ -22,7 +22,6 @@
from __future__ import annotations
from adn_server.application.routing.downlink import (
DownlinkContext,
iter_downlink_voice_slots,

@ -22,11 +22,12 @@
from __future__ import annotations
from tests.application.test_peer_single_downlink import _peer_id, _sys_cfg
from twisted.internet import defer
from adn_server.application.dynamic_tg_use_cases import DynamicTgUseCases
from adn_server.application.routing.helpers import register_peer_ua_session
from adn_server.domain.dynamic_tg import DynamicTgEntry
from tests.application.test_peer_single_downlink import _peer_id, _sys_cfg
from twisted.internet import defer
class _CaptureStore:

@ -23,6 +23,7 @@
from __future__ import annotations
import pytest
from tests.application.test_peer_single_downlink import _peer_id, _sys_cfg
from adn_server.application.routing.helpers import (
peer_should_receive_group_voice,
@ -30,7 +31,6 @@ from adn_server.application.routing.helpers import (
restore_peer_ua_entries_to_memory,
)
from adn_server.domain.dynamic_tg import DynamicTgEntry
from tests.application.test_peer_single_downlink import _peer_id, _sys_cfg
def test_restore_single_mode_respects_expiry() -> None:

@ -24,14 +24,15 @@ from __future__ import annotations
import copy
from adn_server.application.subscription.subscription_table_ops import make_static_tg_store
from tests.harness.deterministic import active_routing_table
from tests.harness.voice_helpers import reflector_routing_entry
from adn_server.application.subscription.in_band_signalling_ops import apply_in_band_signalling_store
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.application.subscription.subscription_table_ops import make_static_tg_store
from adn_server.domain import bytes_3
from adn_server.domain.subscription import SubscriptionPhase
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from tests.harness.deterministic import active_routing_table
from tests.harness.voice_helpers import reflector_routing_entry
def _store_from_bridges(bridges: dict) -> InMemorySubscriptionStore:

@ -128,3 +128,50 @@ def test_monitor_blocks_during_hangtime_allows_new_qso_with_stream_id() -> None:
raw, config, config["SYSTEMS"], peer_slots, downlink_ctx=ctx,
)
assert {ev.split(",")[3] for ev in after} == {"SYSTEM-7", "SYSTEM-8"}
def test_monitor_blocks_panama_end_tx_while_listening_chile() -> None:
"""END/TX for a foreign TG must not fan out when the hotspot is on another QSO."""
from adn_server.application.routing.downlink import touch_peer_voice_slot
peer_hs = bytes_4(714002301)
peers = {peer_hs: _peer(options=b"TS2=7141,71442;")}
sys_cfg = {"GROUP_HANGTIME": 5.0, "MODE": "MASTER", "MAX_PEERS": 8}
config = {"PROXY": {"TARGET_SYSTEM": "SYSTEM"}, "SYSTEMS": {"SYSTEM": sys_cfg}}
sys_cfg["PEERS"] = peers
now = time.time()
chile_stream = bytes_4(0x11111111)
panama_stream = bytes_4(0x22222222)
status = {
1: _empty_slot(),
2: {
**_empty_slot(),
"RX_PEER": peer_hs,
"RX_STREAM_ID": chile_stream,
"RX_TGID": bytes_3(7141),
"RX_TYPE": HBPF_SLT_VHEAD,
"RX_TIME": now,
"TX_PEER": bytes_4(73010),
"TX_STREAM_ID": panama_stream,
"TX_TGID": bytes_3(71442),
"TX_TYPE": HBPF_SLT_VHEAD,
"TX_TIME": now,
},
}
ctx = DownlinkContext(
config=config,
system_name="SYSTEM",
sys_cfg=sys_cfg,
peers=peers,
status=status,
connected_count=1,
)
touch_peer_voice_slot(ctx, peer_hs, 2, chile_stream, bytes_3(7141), pkt_time=now)
peer_slots = {peer_hs: 2}
end_raw = "GROUP VOICE,END,TX,SYSTEM,{},73010,7000002,2,71442,3.50".format(
int.from_bytes(panama_stream, "big"),
)
events = remap_inject_proxy_voice_events(
end_raw, config, config["SYSTEMS"], peer_slots, downlink_ctx=ctx,
)
assert events == []

@ -31,17 +31,6 @@ import json
from typing import Any
import pytest
from adn_server.application.report.monitor_topology import (
expand_inject_proxy_systems,
remap_inject_proxy_voice_event,
)
from adn_server.application.report.payloads import build_topology
from adn_server.domain.value_objects import bytes_4
from adn_server.infrastructure.twisted_adapters.report.opcodes import REPORT_OPCODES
from adn_server.infrastructure.twisted_adapters.report.wire import ReportWire
from adn_server.infrastructure.twisted_adapters.report_server import ReportServerFactory
from adn_server.application.report.dashboard_state import build_dashboard_state
from tests.support.monitor_ctable_sim import (
apply_config_to_ctable,
apply_slim_dashboard_state,
@ -54,6 +43,17 @@ from tests.support.monitor_ctable_sim import (
update_ctable_from_config,
)
from adn_server.application.report.dashboard_state import build_dashboard_state
from adn_server.application.report.monitor_topology import (
expand_inject_proxy_systems,
remap_inject_proxy_voice_event,
)
from adn_server.application.report.payloads import build_topology
from adn_server.domain.value_objects import bytes_4
from adn_server.infrastructure.twisted_adapters.report.opcodes import REPORT_OPCODES
from adn_server.infrastructure.twisted_adapters.report.wire import ReportWire
from adn_server.infrastructure.twisted_adapters.report_server import ReportServerFactory
MAX_PEERS = 102
BASE_PORT = 56400

@ -289,13 +289,14 @@ def test_remap_voice_event_passes_through_non_proxy_systems() -> None:
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
import time
def _empty_slot() -> dict:
return {

@ -4,11 +4,12 @@
from __future__ import annotations
from adn_server.application.routing.helpers import inject_only_defer_obp_hbp_slot_contention
from adn_server.domain import HBPF_SLT_VHEAD, bytes_3, bytes_4
from tests.harness.deterministic import DeterministicScenario, PacketSpec, patch_routing_wall_time
from tests.harness.scenarios import obp_bridge_scenario
from adn_server.application.routing.helpers import inject_only_defer_obp_hbp_slot_contention
from adn_server.domain import HBPF_SLT_VHEAD, bytes_3, bytes_4
def _two_peer_master(scenario: DeterministicScenario) -> None:
master = scenario.config["SYSTEMS"]["MASTER-A"]
@ -19,21 +20,23 @@ def _two_peer_master(scenario: DeterministicScenario) -> None:
}
def test_defer_helper_requires_inject_only_obp_to_master() -> None:
cfg = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": {"MODE": "MASTER"}}}
def test_defer_helper_obp_to_master_defers_global_slot_contention() -> None:
sys_cfg = {"MODE": "MASTER", "PEERS": {bytes_4(1): {"CONNECTION": "YES"}}}
assert inject_only_defer_obp_hbp_slot_contention(
cfg, "MASTER-A", sys_cfg, source_is_obp=True,
{}, "MASTER-A", sys_cfg, source_is_obp=True,
)
assert inject_only_defer_obp_hbp_slot_contention(
{"PROXY": {"TARGET_SYSTEM": "MASTER-A"}}, "MASTER-A", sys_cfg, source_is_obp=True,
)
assert not inject_only_defer_obp_hbp_slot_contention(
cfg, "MASTER-A", sys_cfg, source_is_obp=False,
{}, "MASTER-A", sys_cfg, source_is_obp=False,
)
assert not inject_only_defer_obp_hbp_slot_contention(
{}, "MASTER-A", sys_cfg, source_is_obp=True,
{}, "OPENBRIDGE", {"MODE": "OPENBRIDGE"}, source_is_obp=True,
)
def test_defer_helper_multi_peer_master_without_inject_proxy() -> None:
def test_defer_helper_hbp_multi_peer_defers_global_slot_contention() -> None:
sys_cfg = {
"MODE": "MASTER",
"PEERS": {
@ -42,9 +45,25 @@ def test_defer_helper_multi_peer_master_without_inject_proxy() -> None:
},
}
assert inject_only_defer_obp_hbp_slot_contention(
{}, "MASTER-A", sys_cfg, source_is_obp=True, connected_count=2,
{}, "MASTER-A", sys_cfg, source_is_obp=False, source_is_hbp=True, connected_count=3,
)
assert not inject_only_defer_obp_hbp_slot_contention(
{}, "MASTER-A", sys_cfg, source_is_obp=False, source_is_hbp=True, connected_count=1,
)
def test_defer_helper_single_peer_master_still_defers_to_send_peer() -> None:
sys_cfg = {
"MODE": "MASTER",
"PEERS": {
bytes_4(1): {"CONNECTION": "YES"},
bytes_4(2): {"CONNECTION": "YES"},
},
}
assert inject_only_defer_obp_hbp_slot_contention(
{}, "MASTER-A", sys_cfg, source_is_obp=True, connected_count=2,
)
assert inject_only_defer_obp_hbp_slot_contention(
{}, "MASTER-A", sys_cfg, source_is_obp=True, connected_count=1,
)

@ -4,6 +4,8 @@
from __future__ import annotations
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from adn_server.application.report.payloads import _topology_peer_row
from adn_server.application.routing.helpers import (
RF_MODE_DUPLEX,
@ -16,7 +18,6 @@ from adn_server.application.routing.helpers import (
peer_options_static_tg_slot,
remap_dmrd_to_peer_static_slot,
)
from tests.harness.deterministic import DeterministicScenario, PacketSpec
def _simplex_peer() -> dict:

@ -22,13 +22,9 @@
from __future__ import annotations
from adn_server.domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, bytes_3, bytes_4, int_id
from adn_server.application.routing.helpers import (
clear_peer_ua_sessions,
tg4000_reset_on_vhead,
clear_peer_rx_status_slots,
clear_peer_ua_sessions,
peer_options_static_tg_slot,
peer_receives_group_tgid,
peer_should_receive_group_voice,
@ -38,7 +34,9 @@ from adn_server.application.routing.helpers import (
register_peer_ua_multi_tg,
register_peer_ua_session,
seed_peer_ua_session_from_status,
tg4000_reset_on_vhead,
)
from adn_server.domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, bytes_3, bytes_4, int_id
def _sys_cfg() -> dict:
@ -423,3 +421,91 @@ def test_new_tx_replaces_single_session_tg() -> None:
assert not peer_should_receive_group_voice(
peer, 2, 7305, peer_id=peer_id, connected_count=8, sys_cfg=sys_cfg, now=now + 121
)
def test_downlink_listen_registers_single_session() -> None:
"""SINGLE=1: first downlink VHEAD on a static TG locks out other TGs."""
from adn_server.application.routing.downlink import DownlinkContext, track_peer_group_dmrd
sys_cfg = {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 10, "MODE": "MASTER", "MAX_PEERS": 8}
config = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": sys_cfg}}
peer_id = _peer_id()
peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"}
ctx = DownlinkContext(
config=config,
system_name="MASTER-A",
sys_cfg=sys_cfg,
peers={peer_id: peer},
status={1: {}, 2: {}},
connected_count=3,
)
now = 1_000_000.0
vhead_7305 = b"".join([
b"DMRD", b"\x00", bytes_3(100), bytes_3(7305), b"\x00\x00\x00\x00",
bytes([0x80 | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VHEAD]), bytes_4(0x11111111),
] + [b"\x00"] * 33)
track_peer_group_dmrd(ctx, peer_id, vhead_7305, peer, pkt_time=now)
assert not peer_should_receive_group_voice(
peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 1,
)
def test_downlink_vterm_clears_listen_session_not_local_tx() -> None:
"""Listen lock from downlink ends on VTERM; local TX session survives."""
from adn_server.application.routing.downlink import (
DownlinkContext,
touch_peer_voice_slot,
track_peer_group_dmrd,
)
sys_cfg = {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 10, "MODE": "MASTER", "MAX_PEERS": 8}
config = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": sys_cfg}}
peer_id = _peer_id()
peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"}
ctx = DownlinkContext(
config=config,
system_name="MASTER-A",
sys_cfg=sys_cfg,
peers={peer_id: peer},
status={1: {}, 2: {}},
connected_count=3,
)
now = 1_000_000.0
stream = bytes_4(0x11111111)
vhead = b"".join([
b"DMRD", b"\x00", bytes_3(100), bytes_3(7305), b"\x00\x00\x00\x00",
bytes([0x80 | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VHEAD]), stream,
] + [b"\x00"] * 33)
vterm = b"".join([
b"DMRD", b"\x00", bytes_3(100), bytes_3(7305), b"\x00\x00\x00\x00",
bytes([0x80 | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VTERM]), stream,
] + [b"\x00"] * 33)
track_peer_group_dmrd(ctx, peer_id, vhead, peer, pkt_time=now)
track_peer_group_dmrd(ctx, peer_id, vterm, peer, pkt_time=now + 8)
assert peer_should_receive_group_voice(
peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 9,
)
register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now + 20)
touch_peer_voice_slot(
ctx, peer_id, 2, bytes_4(0x22222222), bytes_3(7305), pkt_time=now + 21, ingress=True,
)
track_peer_group_dmrd(ctx, peer_id, vterm, peer, pkt_time=now + 30, from_ingress=False)
assert not peer_should_receive_group_voice(
peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 31,
)
def test_single_lock_persists_after_vterm_until_timer_expires() -> None:
"""SINGLE=1: ingress VTERM does not clear TIMER session; other TGs blocked until expiry."""
peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=1;"}
sys_cfg = _sys_cfg()
peer_id = _peer_id()
now = 1_000_000.0
register_peer_ua_session(peer, peer_id, 2, 7305, sys_cfg, now=now)
assert not peer_should_receive_group_voice(
peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 30,
)
assert peer_should_receive_group_voice(
peer, 2, 730, peer_id=peer_id, connected_count=3, sys_cfg=sys_cfg, now=now + 61,
)

@ -35,11 +35,11 @@ from adn_server.application.report import (
parse_bridge_event_csv,
routing_table_delta,
)
from adn_server.application.routing.helpers import peer_should_receive_group_voice
from adn_server.application.report.payloads import (
parse_peer_options_static,
resolve_peer_single_and_timer,
)
from adn_server.application.routing.helpers import peer_should_receive_group_voice
from adn_server.domain import bytes_3, bytes_4
_SCHEMA_PATH = Path(__file__).resolve().parents[2] / "schemas" / "report-v2.json"

@ -26,7 +26,6 @@ import pickle
from adn_server.application.subscription.routing_table_export import export_routing_table
from adn_server.application.subscription.routing_table_legacy_view import RoutingTableLegacyView
from adn_server.infrastructure.twisted_adapters.report.pickle_legacy import encode_bridge_snd_frame
from adn_server.domain import bytes_3
from adn_server.domain.subscription import (
ActivationPolicy,
@ -40,6 +39,7 @@ from adn_server.domain.subscription import (
)
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from adn_server.infrastructure.twisted_adapters.report.opcodes import REPORT_OPCODES
from adn_server.infrastructure.twisted_adapters.report.pickle_legacy import encode_bridge_snd_frame
def _sample_store() -> InMemorySubscriptionStore:

@ -22,11 +22,12 @@
from __future__ import annotations
from tests.harness.deterministic import active_routing_table
from adn_server.application.subscription.rule_timer_ops import apply_rule_timer_store
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain.subscription import SubscriptionPhase
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from tests.harness.deterministic import active_routing_table
def test_apply_rule_timer_store_deactivates_expired_on() -> None:

@ -4,7 +4,6 @@
from __future__ import annotations
from adn_server.application.routing.helpers import (
hbp_ingress_new_stream_collision,
hbp_slot_blocks_group_voice,
@ -14,7 +13,7 @@ from adn_server.application.routing.helpers import (
slot_in_group_hangtime,
slot_status_hotspot_owner,
)
from adn_server.domain import bytes_3, bytes_4, HBPF_DATA_SYNC
from adn_server.domain import HBPF_DATA_SYNC, bytes_3, bytes_4
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM, STREAM_TO
_TG_A = bytes_3(7144)
@ -189,6 +188,28 @@ def test_downlink_vterm_does_not_reset_transmit_hangtime() -> None:
assert peer_slot_blocks_downlink(ctx, peer_id, peer, foreign, pkt_time=now + 3)
def test_obp_tx_stamp_does_not_bypass_fresh_chile_downlink_session() -> None:
"""OBP TX stamp for Panama VTERM must not flash over active Chile listen (714002301 lab)."""
now = 1_000_000.0
hs = bytes_4(714002301)
chile_stream = bytes_4(0x11111111)
panama_stream = bytes_4(0x22222222)
slot = {
"TX_PEER": bytes_4(73010),
"TX_STREAM_ID": panama_stream,
"TX_TYPE": HBPF_SLT_VHEAD,
"TX_TIME": now,
"RX_TYPE": HBPF_SLT_VTERM,
}
peers = {hs: {"CONNECTION": "YES", "OPTIONS": b"TS2=7141,71442;"}}
peer_slots = {
2: {"stream_id": chile_stream, "tgid": 7141, "time": now - 0.1},
}
assert peer_hotspot_voice_slot_busy(
hs, 2, panama_stream, bytes_3(71442), slot, peer_slots, None, now, 10.0, peers=peers,
)
def test_obp_bridge_tx_overrides_stale_peer_slot_session() -> None:
"""Stale HS session must not block OBP bridged downlink on the same TG."""
now = 1_000_000.0
@ -235,6 +256,94 @@ def test_peer_hotspot_hangtime_blocks_other_tg() -> None:
)
def test_single0_ingress_tx_allows_other_static_tg() -> None:
"""SINGLE=0: local TX on one static TG must not block RX on another in OPTIONS."""
now = 1_000_000.0
hs = bytes_4(730039253)
peer = {"OPTIONS": b"TS2=730507,730508;SINGLE=0;"}
sys_cfg = {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 10}
slot = {"RX_TYPE": HBPF_SLT_VTERM, "TX_TYPE": HBPF_SLT_VTERM}
peer_slots = {
2: {
"stream_id": bytes_4(0x11111111),
"tgid": 730507,
"time": now,
"ingress": True,
},
}
assert not peer_hotspot_voice_slot_busy(
hs,
2,
bytes_4(0x22222222),
bytes_3(730508),
slot,
peer_slots,
None,
now + 0.1,
0.0,
peer=peer,
sys_cfg=sys_cfg,
)
def test_monitor_peer_allows_second_static_tg_during_listen() -> None:
"""Lab witness (many static TGs) must hear concurrent calls on different TGs."""
now = 1_000_000.0
hs = bytes_4(730039257)
peer = {
"OPTIONS": b"TS2=730500,730501,730502,730503,730504,730505,730506,730507,730508;",
}
sys_cfg = {"SINGLE_MODE": False, "DEFAULT_UA_TIMER": 10}
slot = {
"RX_PEER": bytes_4(730039253),
"RX_TGID": bytes_3(730507),
"RX_STREAM_ID": bytes_4(0x11111111),
"RX_TIME": now,
"RX_TYPE": HBPF_SLT_VHEAD,
}
peer_slots = {
2: {"stream_id": bytes_4(0x11111111), "tgid": 730507, "time": now, "ingress": False},
}
assert not peer_hotspot_voice_slot_busy(
hs,
2,
bytes_4(0x22222222),
bytes_3(730508),
slot,
peer_slots,
None,
now + 0.1,
0.0,
peer=peer,
sys_cfg=sys_cfg,
)
def test_ingress_tx_blocks_same_tg_foreign_stream_despite_bridge_stamp() -> None:
"""Local RF TX must block downlink even when OBP bridge TX stamp matches incoming."""
now = 1_000_000.0
hs = bytes_4(730039101)
slot = {
"TX_PEER": bytes_4(73010),
"TX_STREAM_ID": bytes_4(0x22222222),
"TX_TYPE": HBPF_SLT_VHEAD,
"TX_TIME": now,
"RX_TYPE": HBPF_SLT_VTERM,
}
peers = {hs: {"CONNECTION": "YES", "OPTIONS": b"TS2=730,7305;"}}
peer_slots = {
2: {
"stream_id": bytes_4(0x11111111),
"tgid": 7306,
"time": now,
"ingress": True,
},
}
assert peer_hotspot_voice_slot_busy(
hs, 2, bytes_4(0x22222222), bytes_3(7306), slot, peer_slots, None, now, 5.0, peers=peers,
)
def test_global_scope_still_blocks_any_peer_on_busy_slot() -> None:
now = 1_000_000.0
peer_b = bytes_4(714002301)
@ -295,3 +404,56 @@ def test_stream_timeout_ends_active_busy() -> None:
now = 1_000_000.0
slot = _active_rx_slot(t=now - STREAM_TO - 0.01)
assert not slot_has_active_voice(slot, now)
def test_foreign_vterm_dropped_while_listening_other_tg() -> None:
"""VTERM for a stream never delivered must not reach the hotspot."""
from adn_server.application.routing.downlink import (
DownlinkContext,
peer_slot_blocks_downlink,
touch_peer_voice_slot,
)
sys_cfg = {"GROUP_HANGTIME": 5.0, "MODE": "MASTER", "MAX_PEERS": 8}
config = {"PROXY": {"TARGET_SYSTEM": "MASTER-A"}, "SYSTEMS": {"MASTER-A": sys_cfg}}
hs = bytes_4(714002301)
peer = {"OPTIONS": b"TS2=7141,71442;"}
ctx = DownlinkContext(
config=config,
system_name="MASTER-A",
sys_cfg=sys_cfg,
peers={hs: peer},
status={1: {"RX_TYPE": HBPF_SLT_VTERM, "TX_TYPE": HBPF_SLT_VTERM}, 2: {"RX_TYPE": HBPF_SLT_VTERM, "TX_TYPE": HBPF_SLT_VTERM}},
connected_count=2,
)
now = 1_000_000.0
chile_stream = bytes_4(0x11111111)
panama_stream = bytes_4(0x22222222)
touch_peer_voice_slot(ctx, hs, 2, chile_stream, bytes_3(7141), pkt_time=now)
panama_vterm = b"".join([
b"DMRD", b"\x00", bytes_3(100), bytes_3(71442), b"\x00\x00\x00\x00",
bytes([0x80 | (HBPF_DATA_SYNC << 4) | HBPF_SLT_VTERM]),
panama_stream,
] + [b"\x00"] * 33)
assert peer_slot_blocks_downlink(ctx, hs, peer, panama_vterm, pkt_time=now + 0.5)
def test_global_slot_blocks_foreign_vterm_during_active_rx() -> None:
"""Bridge TX stamp must not let foreign VTERM through during active RX."""
now = 1_000_000.0
chile_stream = bytes_4(0x11111111)
panama_stream = bytes_4(0x22222222)
slot = {
"RX_PEER": bytes_4(714002301),
"RX_STREAM_ID": chile_stream,
"RX_TYPE": HBPF_SLT_VHEAD,
"RX_TIME": now,
"TX_PEER": bytes_4(73010),
"TX_STREAM_ID": panama_stream,
"TX_TYPE": HBPF_SLT_VHEAD,
"TX_TIME": now,
}
assert hbp_slot_blocks_group_voice(
slot, bytes_3(71442), panama_stream, now + 0.1, 0.0, is_vterm=True,
)
assert not hbp_slot_blocks_group_voice(slot, _TG_A, chile_stream, now + 0.1, 0.0)

@ -22,9 +22,9 @@
from __future__ import annotations
from adn_server.application.subscription.subscription_table_ops import ensure_stat_relay_store
from adn_server.application.subscription.stat_trimmer_ops import apply_stat_trimmer_store
from adn_server.application.subscription.subscription_queries import store_has_table
from adn_server.application.subscription.subscription_table_ops import ensure_stat_relay_store
from adn_server.domain import bytes_3
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore

@ -22,9 +22,10 @@
from __future__ import annotations
from adn_server.domain.subscription import SubscriptionPhase
from tests.harness.deterministic import DeterministicScenario, active_routing_table, patch_routing_wall_time
from adn_server.domain.subscription import SubscriptionPhase
def test_rule_timer_syncs_subscription_store() -> None:
bridges = active_routing_table(52090, (("MASTER-A", 1), ("MASTER-B", 2)))

@ -24,8 +24,8 @@ from __future__ import annotations
from typing import Any
from adn_server.application.subscription.routing_table_import import subscriptions_from_routing_table
from adn_server.application.subscription.router import SubscriptionRouter
from adn_server.application.subscription.routing_table_import import subscriptions_from_routing_table
from adn_server.domain import bytes_3, int_id
from adn_server.domain.subscription import TgId
from adn_server.domain.voice_routing import ForwardLeg, VoiceIngress

@ -22,15 +22,16 @@
from __future__ import annotations
from tests.harness.deterministic import active_routing_table, minimal_config
from adn_server.application.subscription.obp_source_ops import ensure_obp_source_for_tg_store
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.application.subscription.subscription_table_ops import (
ensure_dynamic_relay_store,
make_static_tg_store,
)
from adn_server.application.subscription.obp_source_ops import ensure_obp_source_for_tg_store
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain import bytes_3
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from tests.harness.deterministic import active_routing_table, minimal_config
def _store(bridges: dict | None = None) -> InMemorySubscriptionStore:

@ -22,17 +22,18 @@
from __future__ import annotations
from tests.harness.deterministic import active_routing_table
from adn_server.application.subscription.stat_trimmer_ops import apply_stat_trimmer_store
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.application.subscription.subscription_debug_ops import apply_subscription_debug_store
from adn_server.application.subscription.subscription_reset_ops import (
deactivate_system_legs_store,
restore_prohibited_static_legs_store,
)
from adn_server.application.subscription.stat_trimmer_ops import apply_stat_trimmer_store
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain import bytes_3
from adn_server.domain.subscription import SubscriptionPhase
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from tests.harness.deterministic import active_routing_table
def _store(bridges: dict) -> InMemorySubscriptionStore:

@ -22,17 +22,18 @@
from __future__ import annotations
from tests.application.test_subscription_router import _row
from tests.harness.deterministic import minimal_config
from adn_server.application.routing_use_cases import RoutingUseCases
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain import bytes_3
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.domain.subscription import TgId
from adn_server.domain.voice_routing import ForwardLeg
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.infrastructure.acl_router import InMemoryAclRouter
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder
from tests.application.test_subscription_router import _row
from tests.harness.deterministic import minimal_config
def _routing(routing_table: dict) -> RoutingUseCases:

@ -0,0 +1,38 @@
"""Config reload merge preserves runtime MASTER state."""
from __future__ import annotations
from adn_server.infrastructure.config_reload import merge_system_config
def test_merge_system_config_preserves_peers_and_static_tgs() -> None:
old = {
"MODE": "MASTER",
"ENABLED": True,
"IP": "127.0.0.1",
"PORT": 62031,
"PEERS": {"1001": {"IP": "10.0.0.1"}},
"OPTIONS": "TS2=52090;TIMER=10",
"TS1_STATIC": "91",
"TS2_STATIC": "52090",
"DEFAULT_UA_TIMER": 10,
"_options_static_apply_fp": "52090|10",
}
new = {
"MODE": "MASTER",
"ENABLED": True,
"IP": "127.0.0.1",
"PORT": 62031,
"GROUP_HANGTIME": 3,
"TS1_STATIC": "",
"TS2_STATIC": "",
}
merged = merge_system_config(old, new)
assert merged["PEERS"] == old["PEERS"]
assert merged["OPTIONS"] == old["OPTIONS"]
assert merged["TS1_STATIC"] == old["TS1_STATIC"]
assert merged["TS2_STATIC"] == old["TS2_STATIC"]
assert merged["_options_static_apply_fp"] == old["_options_static_apply_fp"]
assert merged["GROUP_HANGTIME"] == 3

@ -0,0 +1,62 @@
"""CRC payload dedup: only seq > 0 triggers drop."""
from __future__ import annotations
import pytest
from tests.harness.assertions import assert_forwarded, assert_inject_ok
from tests.harness.deterministic import (
DeterministicScenario,
PacketSpec,
active_bridge,
add_openbridge_system,
patch_bridge_wall_time,
)
def test_hbp_crc_dedup_drops_duplicate_seq_gt_zero() -> None:
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
base = PacketSpec(dst_id=91, stream_id=0xABCD1234)
burst = DeterministicScenario.voice_burst_spec(base, seq=2, dtype_vseq=2)
t0 = scenario.clock.time()
scenario.inject_hbp(
"MASTER-A",
DeterministicScenario.voice_head_spec(base),
ingress_pkt_time=t0,
)
scenario.inject_hbp("MASTER-A", burst, ingress_pkt_time=t0 + 0.06)
ok = scenario.inject_hbp("MASTER-A", burst, ingress_pkt_time=t0 + 0.12)
assert not ok
@pytest.mark.behavior
def test_hbp_crc_dedup_allows_seq_zero_repeat() -> None:
"""Regression: duplicate VHEAD with seq 0 still forwards (not subject to crc dedup)."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
base = PacketSpec(dst_id=91, stream_id=0xDEADBEEF, seq=0)
vhead = DeterministicScenario.voice_head_spec(base)
t0 = scenario.clock.time()
ok1 = scenario.inject_hbp("MASTER-A", vhead, ingress_pkt_time=t0)
ok2 = scenario.inject_hbp("MASTER-A", vhead, ingress_pkt_time=t0 + 0.06)
assert_inject_ok(ok1)
assert_inject_ok(ok2)
assert_forwarded(scenario, "MASTER-B", count=2, dst_id=91)
def test_obp_crc_dedup_drops_duplicate_seq_gt_zero() -> None:
bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2)))
config = DeterministicScenario().config
add_openbridge_system(config, "OBP-CL")
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
scenario = DeterministicScenario(config=config, bridges=bridges)
base = PacketSpec(dst_id=52090, stream_id=0xCAFEBABE, slot=1)
burst = DeterministicScenario.voice_burst_spec(base, seq=3, dtype_vseq=3)
with patch_bridge_wall_time(scenario.clock):
scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base))
scenario.inject_obp("OBP-CL", burst)
ok = scenario.inject_obp("OBP-CL", burst)
assert not ok

@ -0,0 +1,141 @@
"""ECHO / parrot bridge leg restore after BRIDGERESET (regression for production outage)."""
from __future__ import annotations
import pytest
from tests.harness.assertions import assert_forwarded
from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config
from adn_server.domain import ID_MAX, PEER_MAX
from adn_server.infrastructure.config_loader import acl_build
from adn_server.main import _make_echo_bridges
def _echo_scenario_config() -> dict:
config = minimal_config(("SYSTEM-82",))
config["SYSTEMS"]["ECHO"] = {
"MODE": "MASTER",
"ENABLED": True,
"REPEAT": True,
"MAX_PEERS": 1,
"IP": "127.0.0.1",
"PORT": 54917,
"PASSPHRASE": b"passw0rd",
"GROUP_HANGTIME": 5,
"USE_ACL": True,
"REG_ACL": "DENY:1",
"SUB_ACL": "DENY:1",
"TGID_TS1_ACL": "DENY:ALL",
"TGID_TS2_ACL": "PERMIT:9990",
"DEFAULT_UA_TIMER": 1,
"SINGLE_MODE": True,
"VOICE_IDENT": False,
"TS1_STATIC": "",
"TS2_STATIC": "9990",
"DEFAULT_REFLECTOR": 0,
"GENERATOR": 0,
"ALLOW_UNREG_ID": True,
"PEERS": {},
}
config["SYSTEMS"]["SYSTEM-82"]["TS2_STATIC"] = ""
echo = config["SYSTEMS"]["ECHO"]
echo["REG_ACL"] = acl_build(str(echo["REG_ACL"]), PEER_MAX)
echo["SUB_ACL"] = acl_build(str(echo["SUB_ACL"]), ID_MAX)
echo["TG1_ACL"] = acl_build(str(echo["TGID_TS1_ACL"]), ID_MAX)
echo["TG2_ACL"] = acl_build(str(echo["TGID_TS2_ACL"]), ID_MAX)
return config
def _bridges_after_bridgereset(config: dict) -> dict:
bridges = _make_echo_bridges(config)
for entry in bridges["9990"]:
if entry["SYSTEM"] == "ECHO":
entry["ACTIVE"] = False
entry["TO_TYPE"] = "ON"
for entry in bridges["9990"]:
if entry["SYSTEM"] == "SYSTEM-82":
entry["ACTIVE"] = True
entry["TO_TYPE"] = "ON"
return bridges
def test_bridge_reset_restores_echo_leg_with_use_acl() -> None:
"""BRIDGERESET must restore ECHO service leg when USE_ACL and TG2_ACL are processed."""
config = _echo_scenario_config()
config["SYSTEMS"]["ECHO"]["_reset"] = True
bridges = _bridges_after_bridgereset(config)
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario.bridge.bridge_reset_loop()
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
assert echo_leg["ACTIVE"] is True
assert echo_leg["TO_TYPE"] == "NONE"
def test_options_config_restores_echo_after_bridgereset() -> None:
"""Peer RPTO (options_config_for_system) re-enables ECHO leg after BRIDGERESET."""
config = _echo_scenario_config()
echo_cfg = config["SYSTEMS"]["ECHO"]
echo_cfg["OPTIONS"] = "TS2=9990;"
bridges = _bridges_after_bridgereset(config)
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario.bridge.options_config_for_system("ECHO")
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
assert echo_leg["ACTIVE"] is True
assert echo_leg["TO_TYPE"] == "NONE"
@pytest.mark.behavior
def test_echo_voice_forwards_after_bridgereset_and_options() -> None:
"""Regression: voice on TG 9990 reaches ECHO master after reset + RPTO restore."""
config = _echo_scenario_config()
echo_cfg = config["SYSTEMS"]["ECHO"]
echo_cfg["OPTIONS"] = "TS2=9990;"
bridges = _bridges_after_bridgereset(config)
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario.bridge.options_config_for_system("ECHO")
base = PacketSpec(dst_id=9990, stream_id=0xDEADBEEF, slot=2)
scenario.inject_hbp("SYSTEM-82", DeterministicScenario.voice_head_spec(base))
scenario.inject_hbp(
"SYSTEM-82",
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
)
assert_forwarded(scenario, "ECHO", count=2, dst_id=9990)
def test_bridge_reset_with_yaml_tgid_ts_acl_only() -> None:
"""Restore reads TG2_ACL tuple (as after process_acls), not TGID_TS2_ACL YAML string."""
config = _echo_scenario_config()
echo = config["SYSTEMS"]["ECHO"]
echo["TG1_ACL"] = acl_build(str(echo["TGID_TS1_ACL"]), ID_MAX)
echo["TG2_ACL"] = acl_build(str(echo["TGID_TS2_ACL"]), ID_MAX)
echo["_reset"] = True
bridges = _bridges_after_bridgereset(config)
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario.bridge.bridge_reset_loop()
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
assert echo_leg["ACTIVE"] is True
assert echo_leg["TO_TYPE"] == "NONE"
def test_bridge_reset_acl_uses_processed_tg2_acl_not_yaml_string() -> None:
"""_restore must not read raw TGID_TS2_ACL strings (TypeError in acl_check)."""
config = _echo_scenario_config()
echo_cfg = config["SYSTEMS"]["ECHO"]
echo_cfg["TGID_TS2_ACL"] = "PERMIT:9990"
echo_cfg["TG2_ACL"] = acl_build("PERMIT:9990", 16776415)
echo_cfg["_reset"] = True
bridges = _bridges_after_bridgereset(config)
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario.bridge.bridge_reset_loop()
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
assert echo_leg["ACTIVE"] is True

@ -0,0 +1,32 @@
"""OPTIONS timer loop (options_config_loop, 26s refresh)."""
from __future__ import annotations
from tests.harness.deterministic import DeterministicScenario
def test_options_config_loop_applies_static_tg_from_options() -> None:
config = DeterministicScenario().config
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=52090;TIMER=10"
config["SYSTEMS"]["MASTER-A"]["TS1_STATIC"] = ""
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = ""
scenario = DeterministicScenario(config=config)
scenario.bridge.options_config_loop()
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
assert sys_cfg["TS2_STATIC"] == "52090"
assert "52090" in scenario.bridge.get_bridges()
def test_options_config_loop_skips_master_without_options() -> None:
config = DeterministicScenario().config
config["SYSTEMS"]["MASTER-A"].pop("OPTIONS", None)
config["SYSTEMS"]["MASTER-B"]["OPTIONS"] = "TS2=91;TIMER=10"
config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] = ""
scenario = DeterministicScenario(config=config)
scenario.bridge.options_config_loop()
assert scenario.config["SYSTEMS"]["MASTER-A"].get("TS2_STATIC", "") == ""
assert scenario.config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] == "91"

@ -0,0 +1,79 @@
"""Private (unit) voice routing via SUB_MAP."""
from __future__ import annotations
from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config, parse_dmr_fields
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD
def _private_call_scenario(dst_subscriber: int = 7123456) -> tuple[DeterministicScenario, int]:
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["PEERS"] = {
"1001": {"CALLSIGN": "SRC", "IP": "127.0.0.1", "PORT": 62032},
}
config["SYSTEMS"]["MASTER-B"]["PEERS"] = {
"1002": {"CALLSIGN": "DST", "IP": "127.0.0.1", "PORT": 62033},
}
config["_SUB_MAP"] = {bytes_3(dst_subscriber): ("MASTER-B", 2, 1000.0)}
return DeterministicScenario(config=config), dst_subscriber
def test_private_call_forwards_to_subscriber_system() -> None:
scenario, dst_sub = _private_call_scenario()
base = PacketSpec(
call_type="unit",
dst_id=dst_sub,
rf_src=3120001,
stream_id=0xAABBCCDD,
slot=2,
)
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_voice_head_spec(base))
scenario.inject_unit(
"MASTER-A",
DeterministicScenario.unit_voice_burst_spec(base, seq=1),
)
forwarded = scenario.capture.for_system("MASTER-B")
assert len(forwarded) >= 2
assert all(parse_dmr_fields(p.packet)["call_type"] == "unit" for p in forwarded)
def test_private_call_same_system_does_not_forward() -> None:
config = minimal_config(("MASTER-A", "MASTER-B"))
dst_sub = 7123456
config["_SUB_MAP"] = {bytes_3(dst_sub): ("MASTER-A", 2, 1000.0)}
scenario = DeterministicScenario(config=config)
base = PacketSpec(call_type="unit", dst_id=dst_sub, stream_id=0x11223344, slot=2)
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_voice_head_spec(base))
assert scenario.capture.packets == []
def test_private_call_collision_drops_new_stream() -> None:
scenario, dst_sub = _private_call_scenario()
t0 = scenario.clock.time()
slot = scenario.protocols["MASTER-A"].STATUS[2]
slot.update(
{
"RX_STREAM_ID": bytes_4(0x99998888),
"RX_TYPE": HBPF_SLT_VHEAD,
"RX_RFS": bytes_3(1111111),
"RX_TIME": t0,
}
)
scenario.clock.advance(0.1)
base = PacketSpec(
call_type="unit",
dst_id=dst_sub,
rf_src=2222222,
stream_id=0xDEADBEEF,
slot=2,
)
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_voice_head_spec(base))
assert scenario.capture.packets == []

@ -0,0 +1,68 @@
"""Startup bridge wiring (apply_startup_bridges)."""
from __future__ import annotations
import pytest
from tests.harness.assertions import assert_forwarded
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge, minimal_config
def test_apply_startup_bridges_creates_static_ts2_tg() -> None:
config = DeterministicScenario().config
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10
scenario = DeterministicScenario(config=config)
scenario.bridge.apply_startup_bridges()
assert "52090" in scenario.bridge.get_bridges()
leg = next(e for e in scenario.bridge.get_bridges()["52090"] if e["SYSTEM"] == "MASTER-A")
assert leg["TS"] == 2
assert leg["ACTIVE"] is True
def test_apply_startup_bridges_creates_default_reflector_bridge() -> None:
config = DeterministicScenario().config
config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 310
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10
scenario = DeterministicScenario(config=config)
scenario.bridge.apply_startup_bridges()
assert "#310" in scenario.bridge.get_bridges()
def test_options_config_for_system_matches_startup_static_tg() -> None:
"""RPTO path (options_config_for_system) and startup both materialize the same TS2 leg."""
config = DeterministicScenario().config
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=52090;TIMER=10"
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = ""
scenario = DeterministicScenario(config=config)
scenario.bridge.options_config_for_system("MASTER-A")
assert scenario.config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] == "52090"
assert "52090" in scenario.bridge.get_bridges()
scenario.bridge.apply_startup_bridges()
assert "52090" in scenario.bridge.get_bridges()
@pytest.mark.behavior
def test_startup_bridge_routes_voice_after_apply() -> None:
"""Regression: static TG from apply_startup_bridges forwards HBP voice to peer MASTER."""
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10
bridges = active_bridge(52090, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario.bridge.apply_startup_bridges()
base = PacketSpec(dst_id=52090, stream_id=0x80808080, slot=2)
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
scenario.inject_hbp(
"MASTER-A",
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
)
assert_forwarded(scenario, "MASTER-B", count=2, dst_id=52090)

@ -0,0 +1,99 @@
"""Static TG / OPTIONS handling (commits fdf45d3, 4e8a2d0 echo leg restore)."""
from __future__ import annotations
from tests.harness.deterministic import DeterministicScenario, active_bridge
from adn_server.domain import bytes_3
def _master_with_options(options: str) -> DeterministicScenario:
config = DeterministicScenario().config
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = options
config["SYSTEMS"]["MASTER-A"]["TS1_STATIC"] = ""
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = ""
return DeterministicScenario(config=config)
def test_options_ignores_malformed_ts_tokens() -> None:
scenario = _master_with_options("TS1=91,A92;TS2=52090;TIMER=10")
before_ts2 = scenario.config["SYSTEMS"]["MASTER-A"].get("TS2_STATIC", "")
scenario.bridge.options_config_for_system("MASTER-A")
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
assert sys_cfg.get("TS1_STATIC", "") in ("", before_ts2)
assert sys_cfg.get("TS2_STATIC", "") == before_ts2
def test_options_duplicate_fingerprint_restores_prohibited_legs() -> None:
"""Identical RPTO fingerprint still runs _restore_prohibited_static_bridge_legs."""
config = DeterministicScenario().config
config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=9990;TIMER=10"
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "9990"
config["SYSTEMS"]["MASTER-A"]["_options_static_apply_fp"] = "|9990|10"
bridges = active_bridge(9990, (("MASTER-A", 2),))
bridges["9990"] = [
{
"SYSTEM": "MASTER-A",
"TS": 2,
"TGID": bytes_3(9990),
"ACTIVE": False,
"TIMEOUT": 600,
"TO_TYPE": "STAT",
"ON": [],
"OFF": [],
"RESET": [],
"TIMER": 0,
}
]
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario.bridge.options_config_for_system("MASTER-A")
leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "MASTER-A")
assert leg["ACTIVE"] is True
assert leg["TO_TYPE"] == "NONE"
def test_bridge_reset_restores_prohibited_static_legs() -> None:
"""BRIDGERESET after peer loss re-enables YAML static TG 9990 on MASTER."""
config = DeterministicScenario().config
sys_cfg = config["SYSTEMS"]["MASTER-A"]
sys_cfg["TS2_STATIC"] = "9990"
sys_cfg["OPTIONS"] = "TS2=9990;TIMER=10"
sys_cfg["_reset"] = True
sys_cfg["_opt_key"] = "stale"
sys_cfg["_options_static_apply_fp"] = "|9990|10"
bridges = active_bridge(9990, (("MASTER-A", 2),))
for entry in bridges["9990"]:
if entry["SYSTEM"] == "MASTER-A":
entry["ACTIVE"] = False
entry["TO_TYPE"] = "ON"
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario.bridge.bridge_reset_loop()
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
assert sys_cfg["_reset"] is False
assert "_opt_key" not in sys_cfg
assert "_options_static_apply_fp" not in sys_cfg
leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "MASTER-A")
assert leg["ACTIVE"] is True
assert leg["TO_TYPE"] == "NONE"
def test_options_applies_valid_static_tg_once() -> None:
scenario = _master_with_options("TS2=52090;TIMER=10")
scenario.bridge.options_config_for_system("MASTER-A")
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
assert sys_cfg["TS2_STATIC"] == "52090"
assert "52090" in scenario.bridge.get_bridges()
fp = sys_cfg.get("_options_static_apply_fp")
assert fp == "|52090|10"
scenario.bridge.options_config_for_system("MASTER-A")
assert scenario.config["SYSTEMS"]["MASTER-A"].get("_options_static_apply_fp") == fp

@ -0,0 +1,161 @@
"""Unit data path (SMS/GPS/CSBK) via _unit_data_received."""
from __future__ import annotations
import pytest
from tests.harness.assertions import (
assert_forwarded,
assert_inject_ok,
assert_not_forwarded,
assert_report_event,
)
from tests.harness.deterministic import (
DeterministicScenario,
PacketSpec,
add_openbridge_system,
minimal_config,
parse_dmr_fields,
patch_bridge_wall_time,
)
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM
def _idle_hbp_slot() -> dict:
return {
"RX_TYPE": HBPF_SLT_VTERM,
"TX_TYPE": HBPF_SLT_VTERM,
"TX_TIME": 0.0,
}
@pytest.mark.behavior
def test_unit_data_header_accepted_from_hbp() -> None:
"""Regression: unit-data header (dtype 6) is accepted and reported."""
scenario = DeterministicScenario(enable_reporting=True)
base = PacketSpec(call_type="unit", dst_id=5001, stream_id=0x51515151, slot=2)
ok = scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
assert_inject_ok(ok)
assert_report_event(scenario, "UNIT DATA HEADER")
slot = scenario.protocols["MASTER-A"].STATUS[2]
assert slot.get("RX_STREAM_ID") == bytes_4(base.stream_id)
@pytest.mark.behavior
def test_unit_data_sub_map_forwards_to_idle_hbp() -> None:
config = minimal_config(("MASTER-A", "MASTER-B"))
dst_sub = 7123456
config["_SUB_MAP"] = {bytes_3(dst_sub): ("MASTER-B", 2, 1000.0)}
scenario = DeterministicScenario(config=config)
scenario.protocols["MASTER-B"].STATUS[2] = _idle_hbp_slot()
base = PacketSpec(call_type="unit", dst_id=dst_sub, stream_id=0x52525252, slot=2)
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
assert_forwarded(scenario, "MASTER-B", count=1, call_type="unit")
def test_unit_data_sub_map_skips_busy_hbp_target() -> None:
config = minimal_config(("MASTER-A", "MASTER-B"))
dst_sub = 7123456
config["_SUB_MAP"] = {bytes_3(dst_sub): ("MASTER-B", 2, 1000.0)}
scenario = DeterministicScenario(config=config)
scenario.protocols["MASTER-B"].STATUS[2] = {
"RX_TYPE": HBPF_SLT_VHEAD,
"TX_TYPE": HBPF_SLT_VTERM,
"TX_TIME": scenario.clock.time(),
}
base = PacketSpec(call_type="unit", dst_id=dst_sub, stream_id=0x53535353, slot=2)
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
assert_not_forwarded(scenario, "MASTER-B")
@pytest.mark.behavior
def test_unit_data_hotspot_peer_match_forwards_to_idle_hbp() -> None:
"""Regression: hotspot peer ID match forwards unit data to idle MASTER slot."""
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-B"]["PEERS"] = {
1234567890: {"CALLSIGN": "HS1", "IP": "127.0.0.1", "PORT": 62040},
}
config["SYSTEMS"]["MASTER-B"]["GROUP_HANGTIME"] = 0
scenario = DeterministicScenario(config=config)
scenario.protocols["MASTER-B"].STATUS[2] = _idle_hbp_slot()
base = PacketSpec(call_type="unit", dst_id=123456, stream_id=0x54545454, slot=2)
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
assert_forwarded(scenario, "MASTER-B", count=1, call_type="unit", dst_id=123456)
def test_unit_data_reports_rx_event_when_reporting_enabled() -> None:
config = minimal_config(("MASTER-A",))
scenario = DeterministicScenario(config=config, enable_reporting=True)
base = PacketSpec(call_type="unit", dst_id=5002, stream_id=0x56565656, slot=2)
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base, dtype_vseq=7))
assert scenario.report_factory is not None
assert any("UNIT VCSBK 1/2 DATA BLOCK" in ev for ev in scenario.report_factory.events)
@pytest.mark.behavior
def test_unit_data_csbk_new_stream_is_handled() -> None:
"""Regression: CSBK dtype 3 on new stream is accepted and reported."""
scenario = DeterministicScenario(enable_reporting=True)
base = PacketSpec(call_type="unit", dst_id=5003, stream_id=0x63636363, slot=2)
ok = scenario.inject_unit(
"MASTER-A",
DeterministicScenario.unit_data_header_spec(base, dtype_vseq=3),
)
assert_inject_ok(ok)
assert_report_event(scenario, "UNIT CSBK")
assert scenario.protocols["MASTER-A"].STATUS[2].get("RX_STREAM_ID") == bytes_4(base.stream_id)
def test_unit_data_csbk_ignored_when_stream_already_known() -> None:
scenario = DeterministicScenario()
base = PacketSpec(call_type="unit", dst_id=5004, stream_id=0x64646464, slot=2)
stream_bytes = base.data()[16:20]
scenario.protocols["MASTER-A"].STATUS[2]["RX_STREAM_ID"] = stream_bytes
ok = scenario.inject_unit(
"MASTER-A",
DeterministicScenario.unit_data_header_spec(base, dtype_vseq=3),
)
assert ok is True
assert scenario.capture.packets == []
def test_unit_data_forwards_to_data_gateway_when_enabled() -> None:
config = minimal_config(("MASTER-A",))
config["GLOBAL"]["DATA_GATEWAY"] = True
add_openbridge_system(config, "DATA-GATEWAY")
config["SYSTEMS"]["DATA-GATEWAY"]["ENABLED"] = True
scenario = DeterministicScenario(config=config)
base = PacketSpec(call_type="unit", dst_id=5005, stream_id=0x65656565, slot=2)
with patch_bridge_wall_time(scenario.clock):
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
assert len(scenario.capture.for_system("DATA-GATEWAY")) == 1
def test_unit_data_fanout_to_other_obp_with_ver_gt_1() -> None:
config = minimal_config(("MASTER-A",))
add_openbridge_system(config, "OBP-FAN")
scenario = DeterministicScenario(config=config)
base = PacketSpec(call_type="unit", dst_id=1000001, stream_id=0x66666666, slot=2)
with patch_bridge_wall_time(scenario.clock):
scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base))
assert len(scenario.capture.for_system("OBP-FAN")) == 1
assert parse_dmr_fields(scenario.capture.for_system("OBP-FAN")[0].packet)["call_type"] == "unit"

@ -25,7 +25,6 @@ from __future__ import annotations
from unittest.mock import patch
import pytest
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.harness.playback_helpers import FakePlaybackProtocol, install_reactor_capture, send_playback

@ -32,21 +32,21 @@ from dataclasses import dataclass, field
from time import perf_counter
from typing import Any
from adn_server.application.routing_use_cases import RoutingUseCases
from adn_server.application.reporting_use_cases import ReportingUseCases
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder
from adn_server.application.routing_use_cases import RoutingUseCases
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.domain.hbp_protocol import (
HBPF_DATA_SYNC,
HBPF_SLT_VHEAD,
HBPF_SLT_VTERM,
HBPF_VOICE,
)
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.infrastructure.acl_router import InMemoryAclRouter
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from adn_server.infrastructure.hbp_constants import DMRD
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder
ID_MAX = 16776415
PEER_MAX = 4294967295

@ -50,7 +50,6 @@ from adn_server.domain.hbp_protocol import (
HBPF_SLT_VTERM,
HBPF_VOICE,
)
from tests.harness.assertions import assert_forwarded
from tests.harness.deterministic import (
DeterministicScenario,

@ -27,9 +27,9 @@ from adn_server.infrastructure.hbp_constants import BCKA, BCSQ, BCST, BCVE, DMRD
from adn_server.infrastructure.mesh.obp_v1 import (
DMRD_V1_WIRE_LEN,
build_bcka,
build_bcsq,
build_bcst,
build_bcve,
build_bcsq,
build_dmrd_v1,
verify_bcka,
verify_bcsq,

@ -0,0 +1,65 @@
"""Tests for config_validator."""
from __future__ import annotations
import pytest
from adn_server.domain.errors import ConfigError
from adn_server.infrastructure.config_validator import validate_config
def _minimal_config(**global_overrides) -> dict:
return {
"GLOBAL": {
"PING_TIME": 10,
"SERVER_ID": 73010,
**global_overrides,
},
"REPORTS": {"REPORT": True},
"LOGGER": {"LOG_HANDLERS": "console-timed"},
"SYSTEMS": {},
}
def test_accepts_string_port_security() -> None:
validate_config(_minimal_config(PORT_SECURITY="7070", URL_SECURITY="10.0.0.1", PASS_SECURITY="x"))
def test_rejects_int_port_security() -> None:
with pytest.raises(ConfigError) as exc:
validate_config(
_minimal_config(PORT_SECURITY=7070, URL_SECURITY="10.0.0.1", PASS_SECURITY="x"),
config_path="/tmp/adn-server.yaml",
)
msg = str(exc.value)
assert "GLOBAL.PORT_SECURITY" in msg
assert "expected string" in msg
assert "int" in msg
def test_reports_missing_security_fields_when_url_set() -> None:
with pytest.raises(ConfigError) as exc:
validate_config(_minimal_config(URL_SECURITY="10.0.0.1"), config_path="adn-server.yaml")
msg = str(exc.value)
assert "GLOBAL.PORT_SECURITY" in msg
assert "GLOBAL.PASS_SECURITY" in msg
def test_collects_multiple_errors() -> None:
with pytest.raises(ConfigError) as exc:
validate_config(
{
"GLOBAL": {
"URL_SECURITY": 1,
"PORT_SECURITY": 7070,
"PING_TIME": "ten",
},
"REPORTS": {"REPORT": "yes"},
"LOGGER": {"LOG_HANDLERS": 123},
"SYSTEMS": {"ECHO": {"MODE": "MASTER", "PORT": "bad", "ENABLED": "True"}},
},
config_path="bad.yaml",
)
msg = str(exc.value)
assert "bad.yaml" in msg
assert msg.count(" - ") >= 4

@ -24,7 +24,6 @@ from __future__ import annotations
import textwrap
from adn_server.infrastructure.doctor import collect_findings, run_doctor

@ -45,9 +45,10 @@ def test_echo_flag_routes_to_run_echo() -> None:
def test_no_proxy_skips_proxy_startup(monkeypatch: pytest.MonkeyPatch) -> None:
"""--no-proxy must not call start_proxy_service at bridge startup."""
from twisted.internet import reactor
import adn_server.infrastructure.bootstrap.peer_server as peer_mod
import adn_server.main as main_mod
from twisted.internet import reactor
calls: list[str] = []

@ -24,7 +24,6 @@ from __future__ import annotations
from adn_server.infrastructure.config_validator import validate_config
MINIMAL_ECHO = {
"GLOBAL": {"SERVER_ID": 9990},
"LOGGER": {"LOG_FILE": "/var/log/adn-server/echo.log"},

@ -23,12 +23,12 @@
from __future__ import annotations
import pytest
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.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM
from adn_server.infrastructure.hbp_constants import DMRD
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
pytestmark = pytest.mark.integration
@ -167,6 +167,80 @@ def test_echo_tg_9990_reaches_caller_without_9990_in_options() -> None:
assert len(pkts) == 1
def test_server_playback_tg9_reaches_requesting_peer_without_tg9_in_options() -> None:
"""On-demand playback (5000 -> TG 9) must reach the peer that keyed 999x."""
stack = _inject_proxy_stack()
caller = bytes_4(730039101)
other = bytes_4(730039102)
addr_caller = ("10.0.0.20", 62020)
addr_other = ("10.0.0.21", 62021)
stack.register_peer(caller, addr_caller, options="TS2=730,730444;")
stack.register_peer(other, addr_other, options="TS2=91;")
spec = PacketSpec(
peer_id=5000,
rf_src=5000,
dst_id=9,
slot=2,
stream_id=0xCAFEBABE,
payload=b"\x00" * 33,
)
burst = DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1).data()
stack.hbp.STATUS[2]["RX_PEER"] = caller
stack.hbp.send_peers(burst)
caller_pkts = [p for p in stack.transport.for_addr(addr_caller) if p[:4] == DMRD]
other_pkts = [p for p in stack.transport.for_addr(addr_other) if p[:4] == DMRD]
assert len(caller_pkts) == 1
assert other_pkts == []
def test_on_demand_service_dst_skips_ts2_acl() -> None:
"""Private call to 9991 must not be dropped by TG2 ACL that denies service TGs."""
from adn_server.application.routing.helpers import is_on_demand_service_dst
assert is_on_demand_service_dst(9991)
assert is_on_demand_service_dst(9999)
assert not is_on_demand_service_dst(9990)
assert not is_on_demand_service_dst(730444)
def test_foreign_vterm_dropped_while_listening_chile_via_send_peers() -> None:
"""Panama VTERM must not reach a hotspot mid-QSO on Chile (inject-only lab)."""
from adn_server.domain.hbp_protocol import HBPF_DATA_SYNC, HBPF_SLT_VTERM
stack = _inject_proxy_stack()
listener = bytes_4(714002301)
addr = ("10.0.0.20", 62020)
stack.register_peer(listener, addr, options="TS2=7141,71442;")
chile_stream = bytes_4(0x11111111)
panama_stream = bytes_4(0x22222222)
chile_spec = PacketSpec(
peer_id=73010,
rf_src=100,
dst_id=7141,
slot=2,
stream_id=int.from_bytes(chile_stream, "big"),
)
chile_burst = DeterministicScenario.voice_burst_spec(chile_spec, seq=1, dtype_vseq=1).data()
stack.hbp.send_peer(listener, chile_burst)
panama_vterm = PacketSpec(
peer_id=73010,
rf_src=100,
dst_id=71442,
slot=2,
stream_id=int.from_bytes(panama_stream, "big"),
frame_type=HBPF_DATA_SYNC,
dtype_vseq=HBPF_SLT_VTERM,
).data()
stack.hbp.send_peers(panama_vterm)
pkts = [p for p in stack.transport.for_addr(addr) if p[:4] == DMRD]
assert len(pkts) == 1
assert pkts[0][8:11] == bytes_3(7141)
stack.transport.clear()
stack.hbp.send_peers(panama_vterm)
assert [p for p in stack.transport.for_addr(addr) if p[:4] == DMRD] == []
def test_rpto_does_not_overwrite_system_options_on_inject_proxy() -> None:
stack = _inject_proxy_stack()
config = stack.config

@ -24,11 +24,11 @@ from __future__ import annotations
import pytest
from bitarray import bitarray
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
from adn_server.infrastructure.hbp_constants import DMRA
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
pytestmark = pytest.mark.integration

@ -22,14 +22,14 @@
from __future__ import annotations
from tests.conftest import minimal_valid_config
from adn_server.infrastructure.config_validator import validate_config
from adn_server.infrastructure.twisted_adapters.report.mqtt_config import (
mqtt_settings_from_config,
parse_mqtt_broker,
)
from tests.conftest import minimal_valid_config
def test_mqtt_disabled_by_default():
config = {"REPORTS": {"REPORT": True}, "GLOBAL": {"SERVER_ID": 73010}}

@ -24,11 +24,11 @@ from __future__ import annotations
from unittest.mock import MagicMock
from adn_server.infrastructure.persistence.database_config import validate_database_settings
from adn_server.infrastructure.persistence.mysql_pool import (
_ensure_peer_dynamic_tgs_on_cursor,
describe_mysql_error,
)
from adn_server.infrastructure.persistence.database_config import validate_database_settings
def test_validate_database_settings_requires_db_name() -> None:

@ -23,11 +23,11 @@
from __future__ import annotations
import pytest
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
from adn_server.infrastructure.hbp_constants import DMRD
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
pytestmark = pytest.mark.integration

@ -25,6 +25,7 @@ from __future__ import annotations
import logging
import pytest
from tests.conftest import minimal_valid_config
from adn_server.application.proxy.deployment import (
is_proxy_inject_only,
@ -35,8 +36,6 @@ from adn_server.infrastructure.config_normalizer import expand_generator
from adn_server.infrastructure.config_validator import validate_config
from adn_server.infrastructure.proxy.config import apply_proxy_env_overrides
from tests.conftest import minimal_valid_config
def test_proxy_section_required_when_master_present() -> None:
with pytest.raises(ConfigError) as exc:

@ -26,8 +26,8 @@ import logging
from unittest.mock import MagicMock
from adn_server.application.proxy import ProxyUseCases
from adn_server.infrastructure.config_reload import merge_top_level_config
from adn_server.domain.proxy import ClientEndpoint, ClientSlot
from adn_server.infrastructure.config_reload import merge_top_level_config
from adn_server.infrastructure.proxy.ip_blacklist import InMemoryProxyIpBlacklist
from adn_server.infrastructure.proxy.rpto_queue import InMemoryPendingRptoQueue
from adn_server.infrastructure.proxy.runtime import ProxyServiceState, apply_proxy_config_reload

@ -24,6 +24,8 @@ from __future__ import annotations
import pytest
from bitarray import bitarray
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.support.hbp_repeat_stack import HbpRepeatStack, build_hbp_repeat_stack
from adn_server.application.proxy import ProxyUseCases
from adn_server.domain import bytes_4
@ -36,8 +38,6 @@ from adn_server.infrastructure.proxy import (
ProxyFanInProtocol,
ProxyReplyTransport,
)
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.support.hbp_repeat_stack import HbpRepeatStack, build_hbp_repeat_stack
pytestmark = pytest.mark.integration

@ -24,10 +24,14 @@ from __future__ import annotations
from unittest.mock import MagicMock
from adn_server.application.proxy.session_teardown import CLIENT_TEARDOWN_REPEAT, client_teardown_packet, master_teardown_packet
from adn_server.application.proxy.session_teardown import (
CLIENT_TEARDOWN_REPEAT,
client_teardown_packet,
master_teardown_packet,
)
from adn_server.domain.proxy import ClientEndpoint, SessionTeardown
from adn_server.infrastructure.proxy.session_executor import apply_session_teardown
from adn_server.domain.value_objects import bytes_4
from adn_server.infrastructure.proxy.session_executor import apply_session_teardown
_PEER = bytes_4(1234567)
_CLIENT = ClientEndpoint(host="10.0.0.8", port=5000)

@ -26,8 +26,8 @@ from adn_server.application.subscription.router import SubscriptionRouter
from adn_server.application.subscription.routing_table_import import subscriptions_from_routing_table
from adn_server.domain import bytes_3
from adn_server.domain.subscription import SubscriptionPhase
from adn_server.domain.voice_routing import VoiceIngress
from adn_server.domain.value_objects import TgId
from adn_server.domain.voice_routing import VoiceIngress
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore

@ -0,0 +1,194 @@
"""Parrot playback send loop and max recording timer."""
from __future__ import annotations
import logging
import re
from unittest.mock import patch
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.harness.playback_helpers import (
FakePlaybackProtocol,
install_reactor_capture,
run_scheduled,
send_playback,
)
from adn_server.application.playback_use_cases import (
_PACKET_INTERVAL_S,
_PLAYBACK_DELAY_S,
_RECORD_IDLE_S,
_SOURCE_MAX_S,
PlaybackUseCases,
)
from adn_server.domain import bytes_3, bytes_4
def test_send_next_packet_emits_all_packets_then_finishes() -> None:
proto = FakePlaybackProtocol()
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
pb._playback_busy = True
pb._playback_stream_id = bytes_4(0x77777777)
pb._playback_packets = [bytes([i]) * 55 for i in range(1, 5)]
pb._playback_index = 0
mock_reactor, scheduled = install_reactor_capture()
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
pb._send_next_packet(proto)
while scheduled:
run_scheduled(scheduled)
assert len(proto.sent) == 4
assert pb._playback_busy is False
assert pb._playback_packets == []
assert pb._playback_index == 0
def test_start_playback_sends_first_packet_and_schedules_rest() -> None:
proto = FakePlaybackProtocol()
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
base = PacketSpec(dst_id=9990, stream_id=0x88888888, slot=2)
recorded = [
DeterministicScenario.voice_head_spec(base).data(),
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1).data(),
]
mock_reactor, scheduled = install_reactor_capture()
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
pb._start_playback(
proto,
recorded,
bytes_3(base.rf_src),
bytes_4(base.peer_id),
bytes_3(base.dst_id),
2,
1.5,
)
assert len(proto.sent) == 1
assert pb._playback_index == 1
assert any(item[0] == _PACKET_INTERVAL_S for item in scheduled)
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
while scheduled:
run_scheduled(scheduled)
assert len(proto.sent) == 2
assert pb._playback_busy is False
def test_max_duration_commits_recording_when_no_vterm() -> None:
proto = FakePlaybackProtocol()
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
base = PacketSpec(dst_id=9990, stream_id=0x99999999, slot=2)
pb._recording_active = True
pb.CALL_DATA = [DeterministicScenario.voice_head_spec(base).data()]
pb._record_stream = base.data()[16:20]
pb.STATUS["RX_START"] = 100.0
pb._record_ctx = {
"slot": 2,
"rf_src": bytes_3(base.rf_src),
"peer_id": bytes_4(base.peer_id),
"dst_id": bytes_3(base.dst_id),
}
mock_reactor, scheduled = install_reactor_capture()
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
with patch("adn_server.application.playback_use_cases.time", return_value=100.0 + _SOURCE_MAX_S):
pb._on_record_max_duration(proto)
assert not pb._recording_active
assert pb._playback_busy is True
assert any(item[0] == _PLAYBACK_DELAY_S for item in scheduled)
def test_packet_interval_matches_expected() -> None:
proto = FakePlaybackProtocol()
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
pb._playback_busy = True
pb._playback_stream_id = bytes_4(0xAAAAAAAA)
pb._playback_packets = [b"\x01" * 55, b"\x02" * 55]
pb._playback_index = 0
mock_reactor, scheduled = install_reactor_capture()
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
pb._send_next_packet(proto)
assert scheduled[0][0] == _PACKET_INTERVAL_S
def test_dmrd_received_accepts_ingress_pkt_time_kwarg() -> None:
"""Regression: PEER udp_hbp passes ingress_pkt_time (parrot must not TypeError)."""
proto = FakePlaybackProtocol()
pb = PlaybackUseCases("PARROT", get_protocol=lambda: proto)
base = PacketSpec(dst_id=9990, stream_id=0x12121212, slot=2)
args = DeterministicScenario.voice_head_spec(base).decoded_hbp_args()
pb.dmrd_received(
"PARROT",
args["peer_id"],
args["rf_src"],
args["dst_id"],
args["seq"],
args["slot"],
args["call_type"],
args["frame_type"],
args["dtype_vseq"],
args["stream_id"],
DeterministicScenario.voice_head_spec(base).data(),
ingress_pkt_time=1_700_000_000.0,
)
assert pb._recording_active is True
assert len(pb.CALL_DATA) == 1
def test_start_playback_logs_duration_with_two_decimals(caplog) -> None:
"""PLAYBACK duration matches bridge-style %.2f (no float noise in logs)."""
proto = FakePlaybackProtocol()
pb = PlaybackUseCases("PARROT", get_protocol=lambda: proto)
base = PacketSpec(dst_id=9990, stream_id=0x88888888, slot=2)
recorded = [DeterministicScenario.voice_head_spec(base).data()]
with caplog.at_level(logging.INFO, logger="adn_server.application.playback_use_cases"):
pb._start_playback(
proto,
recorded,
bytes_3(base.rf_src),
bytes_4(base.peer_id),
bytes_3(base.dst_id),
2,
10.985645771026611,
)
playback_lines = [r.message for r in caplog.records if "*START PLAYBACK*" in r.message]
assert len(playback_lines) == 1
assert re.search(r"Duration: 10\.99\b", playback_lines[0])
assert "10.985645771026611" not in playback_lines[0]
def test_ingress_pkt_time_enables_record_to_playback(caplog) -> None:
"""Regression: PEER path (ingress_pkt_time) records voice and schedules playback."""
proto = FakePlaybackProtocol()
pb = PlaybackUseCases("PARROT", get_protocol=lambda: proto)
base = PacketSpec(dst_id=9990, stream_id=0x34343434, slot=2)
mock_reactor, scheduled = install_reactor_capture()
t0 = 1_700_000_000.0
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
with patch("adn_server.application.playback_use_cases.time", return_value=t0):
send_playback(pb, "PARROT", DeterministicScenario.voice_head_spec(base))
send_playback(
pb,
"PARROT",
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
ingress_pkt_time=t0 + 2.0,
)
with patch("adn_server.application.playback_use_cases.time", return_value=t0 + _RECORD_IDLE_S + 1):
with caplog.at_level(logging.INFO, logger="adn_server.application.playback_use_cases"):
pb._on_record_idle(proto)
assert any(item[0] == _PLAYBACK_DELAY_S for item in scheduled)
assert pb._playback_busy is True
assert any("*END RECORDING*" in r.message for r in caplog.records)
assert not any("unexpected keyword argument" in r.message for r in caplog.records)

@ -0,0 +1,52 @@
"""Parrot recording idle/max timers and synthetic VTERM."""
from __future__ import annotations
from unittest.mock import patch
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.harness.playback_helpers import (
FakePlaybackProtocol,
install_reactor_capture,
run_scheduled,
send_playback,
)
from adn_server.application.playback_use_cases import _RECORD_IDLE_S, PlaybackUseCases
def test_idle_timeout_appends_synthetic_vterm_and_schedules_playback() -> None:
proto = FakePlaybackProtocol()
pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto)
base = PacketSpec(dst_id=9990, stream_id=0x55555555, slot=2)
mock_reactor, scheduled = install_reactor_capture()
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
with patch("adn_server.application.playback_use_cases.time", return_value=200.0):
send_playback(pb, "ECHO", DeterministicScenario.voice_head_spec(base))
send_playback(
pb, "ECHO", DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
)
idle_calls = [item for item in scheduled if item[0] == _RECORD_IDLE_S]
assert idle_calls
with patch("adn_server.application.playback_use_cases.time", return_value=200.0 + _RECORD_IDLE_S):
run_scheduled(scheduled, delay=_RECORD_IDLE_S)
assert not pb._recording_active
assert pb._playback_busy is True
assert any(item[0] == 2.0 for item in scheduled)
def test_vterm_commit_does_not_require_synthetic_vterm() -> None:
pb = PlaybackUseCases("ECHO")
base = PacketSpec(dst_id=9990, stream_id=0x66666666, slot=2)
recorded = [
DeterministicScenario.voice_head_spec(base).data(),
DeterministicScenario.voice_term_spec(base, seq=2).data(),
]
with_vterm = pb._ensure_vterm(recorded, slot=2)
assert len(with_vterm) == len(recorded)
assert pb._packet_is_vterm(with_vterm[-1])

@ -0,0 +1,62 @@
"""Parrot re-key and playback packet prep."""
from __future__ import annotations
from unittest.mock import patch
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.harness.playback_helpers import FakePlaybackProtocol, install_reactor_capture, send_playback
from adn_server.application.playback_use_cases import PlaybackUseCases
def test_recording_rekey_does_not_store_second_vhead() -> None:
pb = PlaybackUseCases("ECHO", get_protocol=lambda: FakePlaybackProtocol())
base = PacketSpec(dst_id=9990, stream_id=0x11111111, slot=2)
rekey = PacketSpec(dst_id=9990, stream_id=0x22222222, slot=2)
mock_reactor, _scheduled = install_reactor_capture()
with patch("adn_server.application.playback_use_cases.reactor", mock_reactor):
with patch("adn_server.application.playback_use_cases.time") as mock_time:
mock_time.side_effect = [100.0, 100.1, 100.2, 100.3]
send_playback(pb, "ECHO", DeterministicScenario.voice_head_spec(base))
send_playback(
pb, "ECHO", DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
)
send_playback(pb, "ECHO", DeterministicScenario.voice_head_spec(rekey))
send_playback(
pb, "ECHO", DeterministicScenario.voice_burst_spec(rekey, seq=2, dtype_vseq=2),
)
vheads = sum(
1
for pkt in pb.CALL_DATA
if len(pkt) >= 16 and (pkt[15] & 0xF) == 1 and ((pkt[15] & 0x30) >> 4) == 2
)
assert vheads == 1
assert pb._record_stream == rekey.data()[16:20]
def test_prepare_playback_skips_mid_call_vhead_and_renumbers_seq() -> None:
pb = PlaybackUseCases("ECHO")
base = PacketSpec(dst_id=9990, stream_id=0x33333333, slot=2)
rekey = PacketSpec(dst_id=9990, stream_id=0x44444444, slot=2)
recorded = [
DeterministicScenario.voice_head_spec(base).data(),
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1).data(),
DeterministicScenario.voice_head_spec(rekey).data(),
DeterministicScenario.voice_burst_spec(rekey, seq=2, dtype_vseq=2).data(),
DeterministicScenario.voice_term_spec(rekey, seq=3).data(),
]
out = pb._prepare_playback_packets(recorded)
assert len(out) == 4
assert all(out[i][4] == (i + 1) for i in range(len(out)))
mid_vheads = sum(
1
for pkt in out[1:]
if len(pkt) >= 16 and (pkt[15] & 0xF) == 1 and ((pkt[15] & 0x30) >> 4) == 2
)
assert mid_vheads == 0
assert out[0][16:20] == out[-1][16:20]

@ -23,7 +23,6 @@
from __future__ import annotations
import pytest
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_routing_table, minimal_config
from tests.harness.session_replay import (
FIXTURES_DIR,

@ -22,10 +22,11 @@
from __future__ import annotations
from adn_server.infrastructure.bootstrap.peer_server import _seed_echo_routing_table
from tests.routing.test_echo_subscription_reset import _echo_scenario_config
from tests.harness.assertions import assert_forwarded
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.routing.test_echo_subscription_reset import _echo_scenario_config
from adn_server.infrastructure.bootstrap.peer_server import _seed_echo_routing_table
def _prod_like_config() -> dict:

@ -27,8 +27,8 @@ from tests.harness.assertions import assert_forwarded
from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config
from adn_server.domain import ID_MAX, PEER_MAX
from adn_server.infrastructure.config_loader import acl_build
from adn_server.infrastructure.bootstrap.peer_server import _seed_echo_routing_table
from adn_server.infrastructure.config_loader import acl_build
def _echo_scenario_config() -> dict:

@ -22,11 +22,6 @@
from __future__ import annotations
from adn_server.application.subscription.router import SubscriptionRouter
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain.value_objects import TgId
from adn_server.domain.voice_routing import VoiceIngress
from tests.harness.deterministic import (
DeterministicScenario,
FakeHbpProtocol,
@ -34,7 +29,11 @@ from tests.harness.deterministic import (
active_routing_table,
)
from adn_server.application.subscription.router import SubscriptionRouter
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.value_objects import TgId
from adn_server.domain.voice_routing import VoiceIngress
def _proxy_system_scenario(

@ -0,0 +1,101 @@
# ADN DMR Peer Server - bridge slot contention behaviour
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
from __future__ import annotations
import time
import pytest
from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM
pytestmark = pytest.mark.behavior
_TG_A = 7144
_TG_B = 730444
def _bridge_leg(sys: str, tg: int) -> dict:
return {
"SYSTEM": sys,
"TS": 2,
"TGID": tg,
"ACTIVE": True,
"TIMEOUT": 0,
"TO_TYPE": "OFF",
}
def _bridge_table() -> dict:
return {
str(_TG_A): [_bridge_leg("HOTSPOT", _TG_A), _bridge_leg("NETWORK", _TG_A)],
str(_TG_B): [_bridge_leg("HOTSPOT", _TG_B), _bridge_leg("NETWORK", _TG_B)],
}
def test_bridge_blocks_second_tg_on_busy_slot_with_zero_hangtime() -> None:
config = minimal_config(("HOTSPOT", "NETWORK"))
config["SYSTEMS"]["HOTSPOT"]["GROUP_HANGTIME"] = 0
config["SYSTEMS"]["NETWORK"]["GROUP_HANGTIME"] = 0
scenario = DeterministicScenario(config, _bridge_table())
scenario.protocols["HOTSPOT"].STATUS[2] = {
"RX_TYPE": HBPF_SLT_VHEAD,
"TX_TYPE": HBPF_SLT_VTERM,
"RX_TGID": bytes_3(_TG_A),
"RX_TIME": scenario.clock.time(),
"RX_STREAM_ID": bytes_4(0xAAAAAAAA),
"RX_RFS": bytes_3(7300444),
"RX_PEER": bytes_4(730001),
}
spec = PacketSpec(
rf_src=3120001,
dst_id=_TG_B,
slot=2,
stream_id=0xBBBBBBBB,
payload=b"\x00" * 33,
)
scenario.inject_hbp("NETWORK", DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1))
assert scenario.capture.for_system("HOTSPOT") == []
def test_send_peer_blocks_downlink_on_busy_slot() -> None:
stack = build_hbp_repeat_stack(talker_alias=False, system_name="MASTER-A")
stack.config["PROXY"] = {"TARGET_SYSTEM": "MASTER-A"}
stack.hbp._CONFIG = stack.config
peer = bytes_4(730002)
addr = ("10.0.0.31", 62031)
stack.register_peer(peer, addr, options=f"TS2={_TG_A},{_TG_B};")
stack.hbp.STATUS[2] = {
"RX_TYPE": HBPF_SLT_VHEAD,
"TX_TYPE": HBPF_SLT_VTERM,
"RX_TGID": bytes_3(_TG_A),
"RX_TIME": time.time(),
"RX_STREAM_ID": bytes_4(0xCCCCCCCC),
"RX_PEER": peer,
}
spec = PacketSpec(dst_id=_TG_B, slot=2, stream_id=0xDDDDDDDD, payload=b"\x00" * 33)
pkt = DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1).data()
stack.hbp.send_peer(peer, pkt)
assert stack.transport.for_addr(addr) == []
def test_group_hangtime_blocks_after_vterm() -> None:
config = minimal_config(("HOTSPOT", "NETWORK"))
config["SYSTEMS"]["HOTSPOT"]["GROUP_HANGTIME"] = 30
scenario = DeterministicScenario(config, _bridge_table())
now = scenario.clock.time()
scenario.protocols["HOTSPOT"].STATUS[2] = {
"RX_TYPE": HBPF_SLT_VTERM,
"TX_TYPE": HBPF_SLT_VTERM,
"RX_TGID": bytes_3(_TG_A),
"RX_TIME": now,
"TX_TGID": bytes_3(_TG_A),
"TX_TIME": now,
}
spec = PacketSpec(rf_src=3120001, dst_id=_TG_B, slot=2, stream_id=0xEEEEEEEE, payload=b"\x00" * 33)
scenario.inject_hbp("NETWORK", DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1))
assert scenario.capture.for_system("HOTSPOT") == []

@ -23,8 +23,6 @@
from __future__ import annotations
import pytest
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from tests.harness.assertions import assert_forwarded
from tests.harness.deterministic import (
DeterministicScenario,
@ -34,6 +32,8 @@ from tests.harness.deterministic import (
minimal_config,
)
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
@pytest.mark.behavior
def test_subscription_router_startup_bridge_voice_parity() -> None:

@ -23,7 +23,6 @@
from __future__ import annotations
import pytest
from tests.routing.unit_data_helpers import idle_hbp_slot
from tests.harness.assertions import assert_forwarded, assert_not_forwarded
from tests.harness.deterministic import (
DeterministicScenario,
@ -33,11 +32,11 @@ from tests.harness.deterministic import (
parse_dmr_fields,
patch_routing_wall_time,
)
from tests.routing.unit_data_helpers import idle_hbp_slot
from adn_server.domain import bytes_3
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM
DAPRS_GATEWAY_ID = 900999
HOTSPOT_SUB_ID = 7300392

@ -0,0 +1,193 @@
"""Tests for scripts/freedmr_cfg_to_yaml.py."""
from __future__ import annotations
import sys
import textwrap
from pathlib import Path
import yaml
_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_ROOT / "scripts"))
from freedmr_cfg_to_yaml import dump_yaml, parse_freedmr_cfg # noqa: E402
def _write_cfg(tmp_path: Path, body: str) -> Path:
path = tmp_path / "test.cfg"
path.write_text(textwrap.dedent(body).strip() + "\n", encoding="utf-8")
return path
def test_converts_global_echo_and_obp(tmp_path: Path) -> None:
cfg = _write_cfg(
tmp_path,
"""
[GLOBAL]
SERVER_ID: 73010
USE_ACL: True
TGID_TS2_ACL: PERMIT:ALL
[REPORTS]
REPORT: True
REPORT_CLIENTS: 127.0.0.1
[LOGGER]
LOG_FILE: /var/log/FreeDMR/FreeDMR.log
LOG_NAME: FreeDMR
[ALIASES]
TRY_DOWNLOAD: True
STALE_DAYS: 1
[ECHO]
MODE: MASTER
ENABLED: True
PORT: 54917
TS2_STATIC: 9990
TGID_TS2_ACL: PERMIT:9990
GENERATOR: 0
[OBP-CR]
MODE: OPENBRIDGE
ENABLED: False
PORT: 62052
NETWORK_ID: 71210
PASSPHRASE: passw0rd
TARGET_IP: freedmrcr.net
TARGET_PORT: 62026
TGID_ACL: DENY :0-82,9990-9999
TGID_TS1_ACL: DENY :0-89
PROTO_VER: 5
""",
)
out = parse_freedmr_cfg(cfg)
assert out["GLOBAL"]["SERVER_ID"] == 73010
assert out["GLOBAL"]["TALKER_ALIAS"] is False
assert out["REPORTS"]["REPORT_CLIENTS"] == "127.0.0.1"
assert out["LOGGER"]["LOG_FILE"] == "/var/log/adn-server/adn-server.log"
assert out["LOGGER"]["LOG_NAME"] == "adn-server"
assert out["ALIASES"]["KEYS_FILE"] == "keys.json"
echo = out["SYSTEMS"]["ECHO"]
assert echo["MODE"] == "MASTER"
assert echo["TS2_STATIC"] == "9990"
assert echo["TGID_TS2_ACL"] == "PERMIT:9990"
obp = out["SYSTEMS"]["OBP-CR"]
assert obp["ENABLED"] is False
assert obp["TGID_ACL"] == "DENY:0-82,9990-9999"
assert obp["TGID_TS1_ACL"] == "DENY:0-89"
assert obp["PROTO_VER"] == 5
def test_includes_disabled_systems(tmp_path: Path) -> None:
cfg = _write_cfg(
tmp_path,
"""
[OBP-OFF]
MODE: OPENBRIDGE
ENABLED: False
PORT: 62099
NETWORK_ID: 1
PASSPHRASE: x
TARGET_IP: 1.2.3.4
TARGET_PORT: 62099
""",
)
out = parse_freedmr_cfg(cfg)
assert "OBP-OFF" in out["SYSTEMS"]
assert out["SYSTEMS"]["OBP-OFF"]["ENABLED"] is False
def test_preserves_section_and_key_order(tmp_path: Path) -> None:
cfg = _write_cfg(
tmp_path,
"""
[GLOBAL]
PATH: ./
PING_TIME: 10
SERVER_ID: 73010
[REPORTS]
REPORT: True
[ALLSTAR]
ENABLED: False
[ECHO]
MODE: MASTER
ENABLED: True
PORT: 54917
TS2_STATIC: 9990
[OBP-CR]
MODE: OPENBRIDGE
ENABLED: False
PORT: 62052
NETWORK_ID: 71210
PASSPHRASE: passw0rd
TARGET_IP: freedmrcr.net
TARGET_PORT: 62026
PROTO_VER: 5
""",
)
out = parse_freedmr_cfg(cfg)
assert list(out.keys()) == ["GLOBAL", "REPORTS", "ALLSTAR", "SYSTEMS"]
assert list(out["GLOBAL"].keys())[:3] == ["PATH", "PING_TIME", "SERVER_ID"]
assert list(out["SYSTEMS"].keys()) == ["ECHO", "OBP-CR"]
assert list(out["SYSTEMS"]["ECHO"].keys())[:4] == ["MODE", "ENABLED", "PORT", "TS2_STATIC"]
text = dump_yaml(out)
yaml_keys = [line.rstrip(":") for line in text.splitlines() if line.endswith(":") and not line.startswith("#")]
assert yaml_keys[:4] == ["GLOBAL", "REPORTS", "ALLSTAR", "SYSTEMS"]
def test_security_fields_are_strings(tmp_path: Path) -> None:
cfg = _write_cfg(
tmp_path,
"""
[GLOBAL]
URL_SECURITY: 143.47.40.69
PORT_SECURITY: 7070
PASS_SECURITY: secret
""",
)
out = parse_freedmr_cfg(cfg)
assert out["GLOBAL"]["URL_SECURITY"] == "143.47.40.69"
assert out["GLOBAL"]["PORT_SECURITY"] == "7070"
assert out["GLOBAL"]["PASS_SECURITY"] == "secret"
assert isinstance(out["GLOBAL"]["PORT_SECURITY"], str)
def test_fixture_matches_cfg_section_order() -> None:
fixture = Path(__file__).resolve().parents[1] / "fixtures" / "sample_freedmr.cfg"
out = parse_freedmr_cfg(fixture)
expected_top = ["GLOBAL", "REPORTS", "LOGGER", "ALIASES", "ALLSTAR", "SYSTEMS"]
assert list(out.keys()) == expected_top
assert list(out["SYSTEMS"].keys()) == ["SYSTEM", "ECHO", "OBP-ES", "OBP-UY"]
assert list(out["GLOBAL"].keys())[:5] == ["PATH", "PING_TIME", "MAX_MISSED", "USE_ACL", "REG_ACL"]
assert out["GLOBAL"]["PORT_SECURITY"] == "7070"
assert isinstance(out["GLOBAL"]["PORT_SECURITY"], str)
def test_dump_yaml_is_parseable(tmp_path: Path) -> None:
cfg = _write_cfg(
tmp_path,
"""
[GLOBAL]
SERVER_ID: 1
[SYSTEM]
MODE: MASTER
ENABLED: True
PORT: 56400
PASSPHRASE: secret
GENERATOR: 1
""",
)
text = dump_yaml(parse_freedmr_cfg(cfg))
loaded = yaml.safe_load(text.split("\n", 2)[2])
assert loaded["SYSTEMS"]["SYSTEM"]["PORT"] == 56400

@ -0,0 +1,24 @@
"""Smoke tests for basic HBP bridge forwarding."""
from __future__ import annotations
import pytest
from tests.harness.assertions import assert_forwarded
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
@pytest.mark.smoke
def test_static_tg_routes_hbp_to_peer_master() -> None:
"""Regression: active bridge forwards HBP group voice to peer MASTER."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
base = PacketSpec(dst_id=91, stream_id=0x0A0B0C0D)
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
for seq in range(1, 4):
scenario.inject_hbp(
"MASTER-A",
DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=seq),
)
assert_forwarded(scenario, "MASTER-B", count=4, call_type="group", dst_id=91)

@ -0,0 +1,20 @@
"""Smoke tests for PacketSpec and DMR header parsing."""
from __future__ import annotations
from tests.harness.deterministic import PacketSpec, parse_dmr_fields
from adn_server.domain import int_id
def test_packet_spec_builds_valid_dmr_header() -> None:
spec = PacketSpec(dst_id=91, rf_src=3120001, peer_id=1001, seq=7)
packet = spec.data()
fields = parse_dmr_fields(packet)
assert fields["opcode"] == b"DMRD"
assert fields["seq"] == 7
assert int_id(fields["dst_id"]) == 91
assert int_id(fields["rf_src"]) == 3120001
assert fields["slot"] == 2
assert fields["call_type"] == "group"

@ -26,21 +26,21 @@ import copy
from dataclasses import dataclass, field
from typing import Any
from adn_server.application.routing_use_cases import RoutingUseCases
from tests.harness.deterministic import FakeReportFactory, FakeReportSender, PacketSpec
from tests.harness.scenarios import talker_alias_config
from adn_server.application.reporting_use_cases import ReportingUseCases
from adn_server.application.routing_use_cases import RoutingUseCases
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.infrastructure.acl_router import InMemoryAclRouter
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from adn_server.infrastructure.config_normalizer import (
apply_talker_alias_defaults,
ensure_system_runtime_config,
)
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder
from adn_server.infrastructure.twisted_adapters.udp_hbp import HBPProtocol
from tests.harness.deterministic import FakeReportFactory, FakeReportSender, PacketSpec
from tests.harness.scenarios import talker_alias_config
class RecordingTransport:
"""Capture MASTER downlink UDP writes (REPEAT and DMRA)."""

@ -0,0 +1,55 @@
"""Talker Alias bridge/repeat injection."""
from __future__ import annotations
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
from tests.harness.scenarios import talker_alias_config
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.talker_alias import DMRA_BLOCK_COUNT, DMRA_OPCODE
def test_talker_alias_inject_dmra_on_bridge_vhead() -> None:
"""Bridge forward sends DMRA blocks once per target stream on VHEAD."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges)
base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x90909090)
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
assert len(scenario.dmra_capture) == 1
dmra = scenario.dmra_capture[0]
assert dmra.target_system == "MASTER-B"
assert 1 <= len(dmra.packets) <= DMRA_BLOCK_COUNT
assert all(p[:4] == DMRA_OPCODE for p in dmra.packets)
payload = b"".join(p[8:15] for p in dmra.packets)
assert b"CE5RPY" in payload
def test_talker_alias_vhead_sent_once_per_target_stream() -> None:
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges)
base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x91919191)
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
scenario.inject_hbp(
"MASTER-A",
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
)
assert len(scenario.dmra_capture) == 1
def test_talker_alias_local_repeat_excludes_source_peer() -> None:
scenario = DeterministicScenario(
config=talker_alias_config(),
bridges=active_bridge(91, (("MASTER-A", 2),)),
)
stream_id = bytes_4(0x92929292)
peer = bytes_4(1001)
rf_src = bytes_3(3120001)
scenario.bridge.send_talker_alias_local_repeat("MASTER-A", peer, rf_src, stream_id)
assert len(scenario.dmra_capture) == 1
assert scenario.dmra_capture[0].exclude_peer == peer

@ -22,14 +22,13 @@
from __future__ import annotations
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.talker_alias import build_dmra_packet, decode_ta_from_blocks
from adn_server.infrastructure.hbp_constants import DMRA
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
from tests.harness.deterministic import DeterministicScenario, PacketSpec
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
from tests.talker_alias.test_mmdvm_wire import mmdvm_wire_blocks
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.talker_alias import build_dmra_packet, decode_ta_from_blocks
from adn_server.infrastructure.hbp_constants import DMRA
_PEER_TX = bytes_4(730039210)
_PEER_RX = bytes_4(730039101)

@ -24,6 +24,7 @@ from __future__ import annotations
from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config
from tests.talker_alias.test_passthrough import _complete_blocks, _passthrough_config
from adn_server.domain import bytes_3, bytes_4

@ -25,6 +25,7 @@ from __future__ import annotations
from bitarray import bitarray
from adn_server.application.talker_alias_use_cases import passthrough_complete, passthrough_packets_from_blocks
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.domain.talker_alias import (
decode_ta_from_blocks,
encode_utf8,
@ -35,7 +36,6 @@ from adn_server.domain.talker_alias import (
talker_alias_lc_bytes,
try_buffer_ta_from_voice_fragments,
)
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.infrastructure.talker_alias_emblc import encode_talker_alias_emblc
_encode_emblc_correct = encode_emblc

@ -22,15 +22,15 @@
from __future__ import annotations
from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config
from tests.talker_alias.test_mmdvm_wire import mmdvm_wire_blocks
from adn_server.application.routing_use_cases import RoutingUseCases
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.infrastructure.acl_router import InMemoryAclRouter
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder
from adn_server.domain.dmr.bptc import encode_emblc
from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config
from tests.talker_alias.test_mmdvm_wire import mmdvm_wire_blocks
def test_should_resend_passthrough_only_after_inject() -> None:

@ -0,0 +1,39 @@
"""Talker Alias embedded LC alternation on voice bursts."""
from __future__ import annotations
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
from tests.harness.scenarios import talker_alias_config
def test_talker_alias_embed_state_prepared_on_bridge_vhead() -> None:
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges)
base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0xABABABAB)
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
ts_st = scenario.protocols["MASTER-B"].STATUS.get(2, {})
assert "TX_TA_EMB" in ts_st
assert ts_st.get("TX_TA_ON") is False
assert ts_st.get("TX_TA_PHASE") == 0
def test_talker_alias_embed_cleared_on_vterm() -> None:
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges)
base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0xCDCDCDCD)
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
scenario.inject_hbp(
"MASTER-A",
DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1),
)
scenario.inject_hbp(
"MASTER-A",
DeterministicScenario.voice_term_spec(base, seq=99),
)
ts_st = scenario.protocols["MASTER-B"].STATUS.get(2, {})
assert "TX_TA_EMB" not in ts_st
assert "TX_TA_ON" not in ts_st
Loading…
Cancel
Save

Powered by TurnKey Linux.