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__ = [

@ -1,4 +1,4 @@
# ADN DMR Peer Server - application proxy init
# ADN DMR Peer Server - application proxy init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#

@ -1,4 +1,4 @@
# ADN DMR Peer Server - application report init
# ADN DMR Peer Server - application report init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
@ -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 = (

@ -1,4 +1,4 @@
# ADN DMR Peer Server - application routing init
# ADN DMR Peer Server - application routing init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#

@ -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,8 +172,20 @@ 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
return slot_in_group_hangtime(slot_st, incoming_tgid_b, pkt_time, group_hangtime)
@ -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"):

@ -1,4 +1,4 @@
# ADN DMR Peer Server - application subscription init
# ADN DMR Peer Server - application subscription init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
@ -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.
@ -77,7 +78,7 @@ INDEX_181 = (
#------------------------------------------------------------------------------
def decode_full_lc(_data):
binlc = bitarray(endian='big')
binlc = bitarray(endian='big')
binlc.extend([_data[136],_data[121],_data[106],_data[91], _data[76], _data[61], _data[46], _data[31]])
binlc.extend([_data[152],_data[137],_data[122],_data[107],_data[92], _data[77], _data[62], _data[47], _data[32], _data[17], _data[2] ])
binlc.extend([_data[123],_data[108],_data[93], _data[78], _data[63], _data[48], _data[33], _data[18], _data[3], _data[184],_data[169]])
@ -108,11 +109,11 @@ def encode_19696(_data):
# Create a bitarray from the 4 bytes of LC data (includes RS1293 ECC)
_bdata = bitarray(endian='big')
_bdata.frombytes(_data)
# Insert R0-R3 bits
for i in range(4):
_bdata.insert(0, 0)
# Get row hamming 15,11,3 and append. +1 is to account for R3 that makes an even 196bit string
for index in range(9):
spos = (index*15) + 1
@ -120,12 +121,12 @@ def encode_19696(_data):
_rowp = hamming.enc_15113(_bdata[spos:epos])
for pbit in range(4):
_bdata.insert(epos+pbit,_rowp[pbit])
# Get column hamming 13,9,3 and append. +1 is to account for R3 that makes an even 196bit string
# Pad out the bitarray to a full 196 bits. Can't insert into 'columns'
for i in range(60):
_bdata.append(0)
column = bitarray(9, endian='big') # Temporary bitarray to hold column data
for col in range(15):
spos = col + 1
@ -133,7 +134,7 @@ def encode_19696(_data):
column[index] = _bdata[spos]
spos += 15
_colp = hamming.enc_1393(column)
# Insert bits into matrix...
cpar = 136 + col # Starting location in the matrix for column bits
for pbit in range(4):
@ -147,19 +148,19 @@ def encode_header_lc(_lc):
full_lc = encode_19696(full_lc)
full_lc = interleave_19696(full_lc)
return full_lc
def encode_terminator_lc(_lc):
full_lc = _lc + rs129.lc_terminator_encode(_lc)
full_lc = encode_19696(full_lc)
full_lc = interleave_19696(full_lc)
return full_lc
#------------------------------------------------------------------------------
# BPTC Embedded LC Decoding Routines
#------------------------------------------------------------------------------
def decode_emblc(_elc):
_binlc = bitarray(endian='big')
_binlc.extend([_elc[0],_elc[8], _elc[16],_elc[24],_elc[32],_elc[40],_elc[48],_elc[56],_elc[64],_elc[72] ,_elc[80]])
_binlc.extend([_elc[1],_elc[9], _elc[17],_elc[25],_elc[33],_elc[41],_elc[49],_elc[57],_elc[65],_elc[73] ,_elc[81]])
@ -168,7 +169,7 @@ def decode_emblc(_elc):
_binlc.extend([_elc[4],_elc[12],_elc[20],_elc[28],_elc[36],_elc[44],_elc[52],_elc[60],_elc[68],_elc[76]])
_binlc.extend([_elc[5],_elc[13],_elc[21],_elc[29],_elc[37],_elc[45],_elc[53],_elc[61],_elc[69],_elc[77]])
_binlc.extend([_elc[6],_elc[14],_elc[22],_elc[30],_elc[38],_elc[46],_elc[54],_elc[62],_elc[70],_elc[78]])
return(_binlc.tobytes())
#------------------------------------------------------------------------------
@ -179,14 +180,14 @@ def decode_emblc(_elc):
# Accepts 12 byte LC header + 5-bit checksum, converts to binary and builts out the BPTC
# encoded result with hamming(16,11,4) and parity.
def encode_emblc(_lc):
# Get the 5-bit checksum for the Embedded LC
_csum = crc.csum5(_lc)
# Create a bitarray from the 4 bytes of LC data (includes 5-bit checksum).
_binlc = bitarray(endian='big')
_binlc.frombytes(_lc)
# Insert the checksum bits at the right location in the matrix (this is actually faster than with a for loop)
_binlc.insert(32,_csum[0])
_binlc.insert(43,_csum[1])
@ -198,36 +199,36 @@ def encode_emblc(_lc):
for index in range(0,112,16):
for hindex,hbit in zip(range(index+11,index+16), hamming.enc_16114(_binlc[index:index+11])):
_binlc.insert(hindex,hbit)
# Insert the column parity bits at the right location in the matrix
for index in range(0,16):
_binlc.insert(index+112, _binlc[index+0] ^ _binlc[index+16] ^ _binlc[index+32] ^ _binlc[index+48] ^ _binlc[index+64] ^ _binlc[index+80] ^ _binlc[index+96])
# Create Embedded LC segments in 48 bit blocks
emblc_b = bitarray(endian='big')
emblc_b.extend([_binlc[0], _binlc[16],_binlc[32],_binlc[48],_binlc[64],_binlc[80],_binlc[96], _binlc[112]])
emblc_b.extend([_binlc[1], _binlc[17],_binlc[33],_binlc[49],_binlc[65],_binlc[81],_binlc[97], _binlc[113]])
emblc_b.extend([_binlc[2], _binlc[18],_binlc[34],_binlc[50],_binlc[66],_binlc[82],_binlc[98], _binlc[114]])
emblc_b.extend([_binlc[3], _binlc[19],_binlc[35],_binlc[51],_binlc[67],_binlc[83],_binlc[99], _binlc[115]])
emblc_c = bitarray(endian='big')
emblc_c.extend([_binlc[4], _binlc[20],_binlc[36],_binlc[52],_binlc[68],_binlc[84],_binlc[100],_binlc[116]])
emblc_c.extend([_binlc[5], _binlc[21],_binlc[37],_binlc[53],_binlc[69],_binlc[85],_binlc[101],_binlc[117]])
emblc_c.extend([_binlc[6], _binlc[22],_binlc[38],_binlc[54],_binlc[70],_binlc[86],_binlc[102],_binlc[118]])
emblc_c.extend([_binlc[7], _binlc[23],_binlc[39],_binlc[55],_binlc[71],_binlc[87],_binlc[103],_binlc[119]])
emblc_d = bitarray(endian='big')
emblc_d.extend([_binlc[8], _binlc[24],_binlc[40],_binlc[56],_binlc[72],_binlc[88],_binlc[104],_binlc[120]])
emblc_d.extend([_binlc[9], _binlc[25],_binlc[41],_binlc[57],_binlc[73],_binlc[89],_binlc[105],_binlc[121]]) # bit 25 (upstream wrongly used 24)
emblc_d.extend([_binlc[10],_binlc[26],_binlc[42],_binlc[58],_binlc[74],_binlc[90],_binlc[106],_binlc[122]])
emblc_d.extend([_binlc[11],_binlc[27],_binlc[43],_binlc[59],_binlc[75],_binlc[91],_binlc[107],_binlc[123]])
emblc_e = bitarray(endian='big')
emblc_e.extend([_binlc[12],_binlc[28],_binlc[44],_binlc[60],_binlc[76],_binlc[92],_binlc[108],_binlc[124]])
emblc_e.extend([_binlc[13],_binlc[29],_binlc[45],_binlc[61],_binlc[77],_binlc[93],_binlc[109],_binlc[125]])
emblc_e.extend([_binlc[14],_binlc[30],_binlc[46],_binlc[62],_binlc[78],_binlc[94],_binlc[110],_binlc[126]])
emblc_e.extend([_binlc[15],_binlc[31],_binlc[47],_binlc[63],_binlc[79],_binlc[95],_binlc[111],_binlc[127]])
return({1: emblc_b, 2: emblc_c, 3: emblc_d, 4: emblc_e})
#------------------------------------------------------------------------------
@ -237,26 +238,26 @@ def encode_emblc(_lc):
if __name__ == '__main__':
from binascii import b2a_hex as ahex
from time import time
# Validation Example
voice_h = b'\x2b\x60\x04\x10\x1f\x84\x2d\xd0\x0d\xf0\x7d\x41\x04\x6d\xff\x57\xd7\x5d\xf5\xde\x30\x15\x2e\x20\x70\xb2\x0f\x80\x3f\x88\xc6\x95\xe2'
voice_hb = bitarray(endian='big')
voice_hb.frombytes(voice_h)
voice_hb = voice_hb[0:98] + voice_hb[166:264]
# Header LC -- Terminator similar
lc = b'\x00\x10\x20\x00\x0c\x30\x2f\x9b\xe5' # \xda\xd4\x5a
t0 = time()
full_lc_encode = encode_header_lc(lc)
t1 = time()
encode_time = t1-t0
t0 = time()
full_lc_dec = decode_full_lc(full_lc_encode)
t1 = time()
decode_time = t1-t0
print('VALIDATION ROUTINES:')
print('Orig Data: {}, {} bytes'.format(ahex(lc), len(lc)))
print('Orig Encoded: {}, {} bytes'.format(ahex(voice_hb.tobytes()), len(voice_hb.tobytes())))
@ -272,12 +273,12 @@ if __name__ == '__main__':
emblc = encode_emblc(lc)
t1 = time()
encode_time = t1 -t0
t0 = time()
decemblc = decode_emblc(emblc[1] + emblc[2] + emblc[3] + emblc[4])
t1 = time()
decode_time = t1 -t0
print('\nEMBEDDED LC:')
print('Encoded Data: Burst B:{} Burst C:{} Burst D:{} Burst E:{}'.format(ahex(emblc[1].tobytes()), ahex(emblc[2].tobytes()), ahex(emblc[3].tobytes()), ahex(emblc[4].tobytes())))
print('Endoding Time: {}'.format(encode_time))

@ -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__ = ''
@ -145,9 +144,9 @@ Slot Type: CC(4b), DataType(4), Slot Type Parity(12b - )
#------------------------------------------------------------------------------
if __name__ == '__main__':
from pprint import pprint
pprint(SYNC)
pprint(EMB)
pprint(SLOT_TYPE)

@ -55,7 +55,7 @@ __email__ = 'n0mjs@me.com'
def csum5(_data):
accum = 0
assert len(_data) == 9, 'csum5 expected 9 bytes of data and got something else'
for i in range(9):
accum += _data[i]
accum = bytes([accum % 31])
@ -67,8 +67,8 @@ def csum5(_data):
if __name__ == '__main__':
message = b'\x00\x10\x20\x00\x0c\x30\x2f\x9b\xe5'
result = csum5(message)
print(result, type(result))

@ -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.
@ -78,8 +79,8 @@ def voice_sync(_string):
ambe[2] = burst[192:264]
sync = burst[108:156]
return {'AMBE': ambe, 'SYNC': sync}
def voice(_string):
burst = to_bits(_string)
ambe = [0,0,0]
@ -108,12 +109,12 @@ def to_bytes(_bits):
#------------------------------------------------------------------------------
if __name__ == '__main__':
from binascii import b2a_hex as ahex
from time import time
# SAMPLE, KNOWN GOOD DMR BURSTS
data_head = b'\x2b\x60\x04\x10\x1f\x84\x2d\xd0\x0d\xf0\x7d\x41\x04\x6d\xff\x57\xd7\x5d\xf5\xde\x30\x15\x2e\x20\x70\xb2\x0f\x80\x3f\x88\xc6\x95\xe2'
data_head = b'\x2b\x60\x04\x10\x1f\x84\x2d\xd0\x0d\xf0\x7d\x41\x04\x6d\xff\x57\xd7\x5d\xf5\xde\x30\x15\x2e\x20\x70\xb2\x0f\x80\x3f\x88\xc6\x95\xe2'
voice_a = b'\xb9\xe8\x81\x52\x61\x73\x00\x2a\x6b\xb9\xe8\x81\x52\x67\x55\xfd\x7d\xf7\x5f\x71\x73\x00\x2a\x6b\xb9\xe8\x81\x52\x61\x73\x00\x2a\x6a'
voice_b = b'\xb9\xe8\x81\x52\x61\x73\x00\x2a\x6b\xb9\xe8\x81\x52\x61\x34\xe0\xf0\x60\x69\x11\x73\x00\x2a\x6b\xb9\xe8\x81\x52\x61\x73\x00\x2a\x6a'
voice_c = b'\xb9\xe8\x81\x52\x61\x73\x00\x2a\x6b\xb9\xe8\x81\x52\x61\x71\x71\x10\x04\x77\x41\x73\x00\x2a\x6b\xb9\xe8\x81\x52\x61\x73\x00\x2a\x6a'
@ -121,9 +122,9 @@ if __name__ == '__main__':
voice_e = b'\x86\x5a\xe7\x61\x75\x55\xb5\x06\x01\xb7\x58\xe6\x65\x11\x51\x75\xa0\xf4\xe0\x71\x24\x81\x50\x01\xff\xf5\xa3\x37\x70\x61\x28\xa7\xca'
voice_f = b'\xee\xe7\x81\x75\x74\x61\x4d\xf2\xff\xcc\xf4\xa0\x55\x11\x10\x00\x00\x00\x0e\x24\x30\x59\xe7\xf9\xe9\x08\xa0\x75\x62\x02\xcc\xd6\x22'
voice_term = b'\x2b\x0f\x04\xc4\x1f\x34\x2d\xa8\x0d\x80\x7d\xe1\x04\xad\xff\x57\xd7\x5d\xf5\xd9\x65\x01\x2d\x18\x77\xd2\x03\xc0\x37\x88\xdf\x95\xd1'
embed_lc = bitarray()
print('DMR PACKET DECODER VALIDATION\n')
print('Header:')
t0 = time()
@ -131,7 +132,7 @@ if __name__ == '__main__':
t1 = time()
print('LC: OPT-{} SRC-{} DST-{}, SLOT TYPE: CC-{} DTYPE-{}'.format(ahex(lc['LC'][0:3]),ahex(lc['LC'][3:6]),ahex(lc['LC'][6:9]),ahex(lc['CC']),ahex(lc['DTYPE'])))
print('Decode Time: {}\n'.format(t1-t0))
print('Voice Burst A:')
t0 = time()
pkt = voice_sync(voice_a)
@ -141,7 +142,7 @@ if __name__ == '__main__':
print('AMBE 1: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][1])))
print('AMBE 2: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][2])))
print(t1-t0, '\n')
print('Voice Burst B:')
t0 = time()
pkt = voice(voice_b)
@ -152,7 +153,7 @@ if __name__ == '__main__':
print('AMBE 1: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][1])))
print('AMBE 2: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][2])))
print(t1-t0, '\n')
print('Voice Burst C:')
t0 = time()
pkt = voice(voice_c)
@ -163,7 +164,7 @@ if __name__ == '__main__':
print('AMBE 1: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][1])))
print('AMBE 2: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][2])))
print(t1-t0, '\n')
print('Voice Burst D:')
t0 = time()
pkt = voice(voice_d)
@ -174,7 +175,7 @@ if __name__ == '__main__':
print('AMBE 1: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][1])))
print('AMBE 2: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][2])))
print(t1-t0, '\n')
print('Voice Burst E:')
t0 = time()
pkt = voice(voice_e)
@ -187,7 +188,7 @@ if __name__ == '__main__':
print('AMBE 1: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][1])))
print('AMBE 2: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][2])))
print(t1-t0, '\n')
print('Voice Burst F:')
t0 = time()
pkt = voice(voice_f)
@ -197,7 +198,7 @@ if __name__ == '__main__':
print('AMBE 1: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][1])))
print('AMBE 2: {}, {}'.format(pkt['AMBE'][1], len(pkt['AMBE'][2])))
print(t1-t0, '\n')
print('Terminator:')
t0 = time()
lc = voice_head_term(voice_term)

@ -50,63 +50,63 @@ NPAR = 3
POLY= [64, 56, 14, 1, 0, 0, 0, 0, 0, 0, 0, 0]
EXP_TABLE = (
0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1D, 0x3A, 0x74, 0xE8, 0xCD, 0x87, 0x13, 0x26,
0x4C, 0x98, 0x2D, 0x5A, 0xB4, 0x75, 0xEA, 0xC9, 0x8F, 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0,
0x9D, 0x27, 0x4E, 0x9C, 0x25, 0x4A, 0x94, 0x35, 0x6A, 0xD4, 0xB5, 0x77, 0xEE, 0xC1, 0x9F, 0x23,
0x46, 0x8C, 0x05, 0x0A, 0x14, 0x28, 0x50, 0xA0, 0x5D, 0xBA, 0x69, 0xD2, 0xB9, 0x6F, 0xDE, 0xA1,
0x5F, 0xBE, 0x61, 0xC2, 0x99, 0x2F, 0x5E, 0xBC, 0x65, 0xCA, 0x89, 0x0F, 0x1E, 0x3C, 0x78, 0xF0,
0xFD, 0xE7, 0xD3, 0xBB, 0x6B, 0xD6, 0xB1, 0x7F, 0xFE, 0xE1, 0xDF, 0xA3, 0x5B, 0xB6, 0x71, 0xE2,
0xD9, 0xAF, 0x43, 0x86, 0x11, 0x22, 0x44, 0x88, 0x0D, 0x1A, 0x34, 0x68, 0xD0, 0xBD, 0x67, 0xCE,
0x81, 0x1F, 0x3E, 0x7C, 0xF8, 0xED, 0xC7, 0x93, 0x3B, 0x76, 0xEC, 0xC5, 0x97, 0x33, 0x66, 0xCC,
0x85, 0x17, 0x2E, 0x5C, 0xB8, 0x6D, 0xDA, 0xA9, 0x4F, 0x9E, 0x21, 0x42, 0x84, 0x15, 0x2A, 0x54,
0xA8, 0x4D, 0x9A, 0x29, 0x52, 0xA4, 0x55, 0xAA, 0x49, 0x92, 0x39, 0x72, 0xE4, 0xD5, 0xB7, 0x73,
0xE6, 0xD1, 0xBF, 0x63, 0xC6, 0x91, 0x3F, 0x7E, 0xFC, 0xE5, 0xD7, 0xB3, 0x7B, 0xF6, 0xF1, 0xFF,
0xE3, 0xDB, 0xAB, 0x4B, 0x96, 0x31, 0x62, 0xC4, 0x95, 0x37, 0x6E, 0xDC, 0xA5, 0x57, 0xAE, 0x41,
0x82, 0x19, 0x32, 0x64, 0xC8, 0x8D, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xDD, 0xA7, 0x53, 0xA6,
0x51, 0xA2, 0x59, 0xB2, 0x79, 0xF2, 0xF9, 0xEF, 0xC3, 0x9B, 0x2B, 0x56, 0xAC, 0x45, 0x8A, 0x09,
0x12, 0x24, 0x48, 0x90, 0x3D, 0x7A, 0xF4, 0xF5, 0xF7, 0xF3, 0xFB, 0xEB, 0xCB, 0x8B, 0x0B, 0x16,
0x2C, 0x58, 0xB0, 0x7D, 0xFA, 0xE9, 0xCF, 0x83, 0x1B, 0x36, 0x6C, 0xD8, 0xAD, 0x47, 0x8E, 0x01,
0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1D, 0x3A, 0x74, 0xE8, 0xCD, 0x87, 0x13, 0x26, 0x4C,
0x98, 0x2D, 0x5A, 0xB4, 0x75, 0xEA, 0xC9, 0x8F, 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, 0x9D,
0x27, 0x4E, 0x9C, 0x25, 0x4A, 0x94, 0x35, 0x6A, 0xD4, 0xB5, 0x77, 0xEE, 0xC1, 0x9F, 0x23, 0x46,
0x8C, 0x05, 0x0A, 0x14, 0x28, 0x50, 0xA0, 0x5D, 0xBA, 0x69, 0xD2, 0xB9, 0x6F, 0xDE, 0xA1, 0x5F,
0xBE, 0x61, 0xC2, 0x99, 0x2F, 0x5E, 0xBC, 0x65, 0xCA, 0x89, 0x0F, 0x1E, 0x3C, 0x78, 0xF0, 0xFD,
0xE7, 0xD3, 0xBB, 0x6B, 0xD6, 0xB1, 0x7F, 0xFE, 0xE1, 0xDF, 0xA3, 0x5B, 0xB6, 0x71, 0xE2, 0xD9,
0xAF, 0x43, 0x86, 0x11, 0x22, 0x44, 0x88, 0x0D, 0x1A, 0x34, 0x68, 0xD0, 0xBD, 0x67, 0xCE, 0x81,
0x1F, 0x3E, 0x7C, 0xF8, 0xED, 0xC7, 0x93, 0x3B, 0x76, 0xEC, 0xC5, 0x97, 0x33, 0x66, 0xCC, 0x85,
0x17, 0x2E, 0x5C, 0xB8, 0x6D, 0xDA, 0xA9, 0x4F, 0x9E, 0x21, 0x42, 0x84, 0x15, 0x2A, 0x54, 0xA8,
0x4D, 0x9A, 0x29, 0x52, 0xA4, 0x55, 0xAA, 0x49, 0x92, 0x39, 0x72, 0xE4, 0xD5, 0xB7, 0x73, 0xE6,
0xD1, 0xBF, 0x63, 0xC6, 0x91, 0x3F, 0x7E, 0xFC, 0xE5, 0xD7, 0xB3, 0x7B, 0xF6, 0xF1, 0xFF, 0xE3,
0xDB, 0xAB, 0x4B, 0x96, 0x31, 0x62, 0xC4, 0x95, 0x37, 0x6E, 0xDC, 0xA5, 0x57, 0xAE, 0x41, 0x82,
0x19, 0x32, 0x64, 0xC8, 0x8D, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xDD, 0xA7, 0x53, 0xA6, 0x51,
0xA2, 0x59, 0xB2, 0x79, 0xF2, 0xF9, 0xEF, 0xC3, 0x9B, 0x2B, 0x56, 0xAC, 0x45, 0x8A, 0x09, 0x12,
0x24, 0x48, 0x90, 0x3D, 0x7A, 0xF4, 0xF5, 0xF7, 0xF3, 0xFB, 0xEB, 0xCB, 0x8B, 0x0B, 0x16, 0x2C,
0x58, 0xB0, 0x7D, 0xFA, 0xE9, 0xCF, 0x83, 0x1B, 0x36, 0x6C, 0xD8, 0xAD, 0x47, 0x8E, 0x01, 0x00
0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1D, 0x3A, 0x74, 0xE8, 0xCD, 0x87, 0x13, 0x26,
0x4C, 0x98, 0x2D, 0x5A, 0xB4, 0x75, 0xEA, 0xC9, 0x8F, 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0,
0x9D, 0x27, 0x4E, 0x9C, 0x25, 0x4A, 0x94, 0x35, 0x6A, 0xD4, 0xB5, 0x77, 0xEE, 0xC1, 0x9F, 0x23,
0x46, 0x8C, 0x05, 0x0A, 0x14, 0x28, 0x50, 0xA0, 0x5D, 0xBA, 0x69, 0xD2, 0xB9, 0x6F, 0xDE, 0xA1,
0x5F, 0xBE, 0x61, 0xC2, 0x99, 0x2F, 0x5E, 0xBC, 0x65, 0xCA, 0x89, 0x0F, 0x1E, 0x3C, 0x78, 0xF0,
0xFD, 0xE7, 0xD3, 0xBB, 0x6B, 0xD6, 0xB1, 0x7F, 0xFE, 0xE1, 0xDF, 0xA3, 0x5B, 0xB6, 0x71, 0xE2,
0xD9, 0xAF, 0x43, 0x86, 0x11, 0x22, 0x44, 0x88, 0x0D, 0x1A, 0x34, 0x68, 0xD0, 0xBD, 0x67, 0xCE,
0x81, 0x1F, 0x3E, 0x7C, 0xF8, 0xED, 0xC7, 0x93, 0x3B, 0x76, 0xEC, 0xC5, 0x97, 0x33, 0x66, 0xCC,
0x85, 0x17, 0x2E, 0x5C, 0xB8, 0x6D, 0xDA, 0xA9, 0x4F, 0x9E, 0x21, 0x42, 0x84, 0x15, 0x2A, 0x54,
0xA8, 0x4D, 0x9A, 0x29, 0x52, 0xA4, 0x55, 0xAA, 0x49, 0x92, 0x39, 0x72, 0xE4, 0xD5, 0xB7, 0x73,
0xE6, 0xD1, 0xBF, 0x63, 0xC6, 0x91, 0x3F, 0x7E, 0xFC, 0xE5, 0xD7, 0xB3, 0x7B, 0xF6, 0xF1, 0xFF,
0xE3, 0xDB, 0xAB, 0x4B, 0x96, 0x31, 0x62, 0xC4, 0x95, 0x37, 0x6E, 0xDC, 0xA5, 0x57, 0xAE, 0x41,
0x82, 0x19, 0x32, 0x64, 0xC8, 0x8D, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xDD, 0xA7, 0x53, 0xA6,
0x51, 0xA2, 0x59, 0xB2, 0x79, 0xF2, 0xF9, 0xEF, 0xC3, 0x9B, 0x2B, 0x56, 0xAC, 0x45, 0x8A, 0x09,
0x12, 0x24, 0x48, 0x90, 0x3D, 0x7A, 0xF4, 0xF5, 0xF7, 0xF3, 0xFB, 0xEB, 0xCB, 0x8B, 0x0B, 0x16,
0x2C, 0x58, 0xB0, 0x7D, 0xFA, 0xE9, 0xCF, 0x83, 0x1B, 0x36, 0x6C, 0xD8, 0xAD, 0x47, 0x8E, 0x01,
0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1D, 0x3A, 0x74, 0xE8, 0xCD, 0x87, 0x13, 0x26, 0x4C,
0x98, 0x2D, 0x5A, 0xB4, 0x75, 0xEA, 0xC9, 0x8F, 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, 0x9D,
0x27, 0x4E, 0x9C, 0x25, 0x4A, 0x94, 0x35, 0x6A, 0xD4, 0xB5, 0x77, 0xEE, 0xC1, 0x9F, 0x23, 0x46,
0x8C, 0x05, 0x0A, 0x14, 0x28, 0x50, 0xA0, 0x5D, 0xBA, 0x69, 0xD2, 0xB9, 0x6F, 0xDE, 0xA1, 0x5F,
0xBE, 0x61, 0xC2, 0x99, 0x2F, 0x5E, 0xBC, 0x65, 0xCA, 0x89, 0x0F, 0x1E, 0x3C, 0x78, 0xF0, 0xFD,
0xE7, 0xD3, 0xBB, 0x6B, 0xD6, 0xB1, 0x7F, 0xFE, 0xE1, 0xDF, 0xA3, 0x5B, 0xB6, 0x71, 0xE2, 0xD9,
0xAF, 0x43, 0x86, 0x11, 0x22, 0x44, 0x88, 0x0D, 0x1A, 0x34, 0x68, 0xD0, 0xBD, 0x67, 0xCE, 0x81,
0x1F, 0x3E, 0x7C, 0xF8, 0xED, 0xC7, 0x93, 0x3B, 0x76, 0xEC, 0xC5, 0x97, 0x33, 0x66, 0xCC, 0x85,
0x17, 0x2E, 0x5C, 0xB8, 0x6D, 0xDA, 0xA9, 0x4F, 0x9E, 0x21, 0x42, 0x84, 0x15, 0x2A, 0x54, 0xA8,
0x4D, 0x9A, 0x29, 0x52, 0xA4, 0x55, 0xAA, 0x49, 0x92, 0x39, 0x72, 0xE4, 0xD5, 0xB7, 0x73, 0xE6,
0xD1, 0xBF, 0x63, 0xC6, 0x91, 0x3F, 0x7E, 0xFC, 0xE5, 0xD7, 0xB3, 0x7B, 0xF6, 0xF1, 0xFF, 0xE3,
0xDB, 0xAB, 0x4B, 0x96, 0x31, 0x62, 0xC4, 0x95, 0x37, 0x6E, 0xDC, 0xA5, 0x57, 0xAE, 0x41, 0x82,
0x19, 0x32, 0x64, 0xC8, 0x8D, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xDD, 0xA7, 0x53, 0xA6, 0x51,
0xA2, 0x59, 0xB2, 0x79, 0xF2, 0xF9, 0xEF, 0xC3, 0x9B, 0x2B, 0x56, 0xAC, 0x45, 0x8A, 0x09, 0x12,
0x24, 0x48, 0x90, 0x3D, 0x7A, 0xF4, 0xF5, 0xF7, 0xF3, 0xFB, 0xEB, 0xCB, 0x8B, 0x0B, 0x16, 0x2C,
0x58, 0xB0, 0x7D, 0xFA, 0xE9, 0xCF, 0x83, 0x1B, 0x36, 0x6C, 0xD8, 0xAD, 0x47, 0x8E, 0x01, 0x00
)
LOG_TABLE = (
0x00, 0x00, 0x01, 0x19, 0x02, 0x32, 0x1A, 0xC6, 0x03, 0xDF, 0x33, 0xEE, 0x1B, 0x68, 0xC7, 0x4B,
0x04, 0x64, 0xE0, 0x0E, 0x34, 0x8D, 0xEF, 0x81, 0x1C, 0xC1, 0x69, 0xF8, 0xC8, 0x08, 0x4C, 0x71,
0x05, 0x8A, 0x65, 0x2F, 0xE1, 0x24, 0x0F, 0x21, 0x35, 0x93, 0x8E, 0xDA, 0xF0, 0x12, 0x82, 0x45,
0x1D, 0xB5, 0xC2, 0x7D, 0x6A, 0x27, 0xF9, 0xB9, 0xC9, 0x9A, 0x09, 0x78, 0x4D, 0xE4, 0x72, 0xA6,
0x06, 0xBF, 0x8B, 0x62, 0x66, 0xDD, 0x30, 0xFD, 0xE2, 0x98, 0x25, 0xB3, 0x10, 0x91, 0x22, 0x88,
0x36, 0xD0, 0x94, 0xCE, 0x8F, 0x96, 0xDB, 0xBD, 0xF1, 0xD2, 0x13, 0x5C, 0x83, 0x38, 0x46, 0x40,
0x1E, 0x42, 0xB6, 0xA3, 0xC3, 0x48, 0x7E, 0x6E, 0x6B, 0x3A, 0x28, 0x54, 0xFA, 0x85, 0xBA, 0x3D,
0xCA, 0x5E, 0x9B, 0x9F, 0x0A, 0x15, 0x79, 0x2B, 0x4E, 0xD4, 0xE5, 0xAC, 0x73, 0xF3, 0xA7, 0x57,
0x07, 0x70, 0xC0, 0xF7, 0x8C, 0x80, 0x63, 0x0D, 0x67, 0x4A, 0xDE, 0xED, 0x31, 0xC5, 0xFE, 0x18,
0xE3, 0xA5, 0x99, 0x77, 0x26, 0xB8, 0xB4, 0x7C, 0x11, 0x44, 0x92, 0xD9, 0x23, 0x20, 0x89, 0x2E,
0x37, 0x3F, 0xD1, 0x5B, 0x95, 0xBC, 0xCF, 0xCD, 0x90, 0x87, 0x97, 0xB2, 0xDC, 0xFC, 0xBE, 0x61,
0xF2, 0x56, 0xD3, 0xAB, 0x14, 0x2A, 0x5D, 0x9E, 0x84, 0x3C, 0x39, 0x53, 0x47, 0x6D, 0x41, 0xA2,
0x1F, 0x2D, 0x43, 0xD8, 0xB7, 0x7B, 0xA4, 0x76, 0xC4, 0x17, 0x49, 0xEC, 0x7F, 0x0C, 0x6F, 0xF6,
0x6C, 0xA1, 0x3B, 0x52, 0x29, 0x9D, 0x55, 0xAA, 0xFB, 0x60, 0x86, 0xB1, 0xBB, 0xCC, 0x3E, 0x5A,
0xCB, 0x59, 0x5F, 0xB0, 0x9C, 0xA9, 0xA0, 0x51, 0x0B, 0xF5, 0x16, 0xEB, 0x7A, 0x75, 0x2C, 0xD7,
0x4F, 0xAE, 0xD5, 0xE9, 0xE6, 0xE7, 0xAD, 0xE8, 0x74, 0xD6, 0xF4, 0xEA, 0xA8, 0x50, 0x58, 0xAF
0x00, 0x00, 0x01, 0x19, 0x02, 0x32, 0x1A, 0xC6, 0x03, 0xDF, 0x33, 0xEE, 0x1B, 0x68, 0xC7, 0x4B,
0x04, 0x64, 0xE0, 0x0E, 0x34, 0x8D, 0xEF, 0x81, 0x1C, 0xC1, 0x69, 0xF8, 0xC8, 0x08, 0x4C, 0x71,
0x05, 0x8A, 0x65, 0x2F, 0xE1, 0x24, 0x0F, 0x21, 0x35, 0x93, 0x8E, 0xDA, 0xF0, 0x12, 0x82, 0x45,
0x1D, 0xB5, 0xC2, 0x7D, 0x6A, 0x27, 0xF9, 0xB9, 0xC9, 0x9A, 0x09, 0x78, 0x4D, 0xE4, 0x72, 0xA6,
0x06, 0xBF, 0x8B, 0x62, 0x66, 0xDD, 0x30, 0xFD, 0xE2, 0x98, 0x25, 0xB3, 0x10, 0x91, 0x22, 0x88,
0x36, 0xD0, 0x94, 0xCE, 0x8F, 0x96, 0xDB, 0xBD, 0xF1, 0xD2, 0x13, 0x5C, 0x83, 0x38, 0x46, 0x40,
0x1E, 0x42, 0xB6, 0xA3, 0xC3, 0x48, 0x7E, 0x6E, 0x6B, 0x3A, 0x28, 0x54, 0xFA, 0x85, 0xBA, 0x3D,
0xCA, 0x5E, 0x9B, 0x9F, 0x0A, 0x15, 0x79, 0x2B, 0x4E, 0xD4, 0xE5, 0xAC, 0x73, 0xF3, 0xA7, 0x57,
0x07, 0x70, 0xC0, 0xF7, 0x8C, 0x80, 0x63, 0x0D, 0x67, 0x4A, 0xDE, 0xED, 0x31, 0xC5, 0xFE, 0x18,
0xE3, 0xA5, 0x99, 0x77, 0x26, 0xB8, 0xB4, 0x7C, 0x11, 0x44, 0x92, 0xD9, 0x23, 0x20, 0x89, 0x2E,
0x37, 0x3F, 0xD1, 0x5B, 0x95, 0xBC, 0xCF, 0xCD, 0x90, 0x87, 0x97, 0xB2, 0xDC, 0xFC, 0xBE, 0x61,
0xF2, 0x56, 0xD3, 0xAB, 0x14, 0x2A, 0x5D, 0x9E, 0x84, 0x3C, 0x39, 0x53, 0x47, 0x6D, 0x41, 0xA2,
0x1F, 0x2D, 0x43, 0xD8, 0xB7, 0x7B, 0xA4, 0x76, 0xC4, 0x17, 0x49, 0xEC, 0x7F, 0x0C, 0x6F, 0xF6,
0x6C, 0xA1, 0x3B, 0x52, 0x29, 0x9D, 0x55, 0xAA, 0xFB, 0x60, 0x86, 0xB1, 0xBB, 0xCC, 0x3E, 0x5A,
0xCB, 0x59, 0x5F, 0xB0, 0x9C, 0xA9, 0xA0, 0x51, 0x0B, 0xF5, 0x16, 0xEB, 0x7A, 0x75, 0x2C, 0xD7,
0x4F, 0xAE, 0xD5, 0xE9, 0xE6, 0xE7, 0xAD, 0xE8, 0x74, 0xD6, 0xF4, 0xEA, 0xA8, 0x50, 0x58, 0xAF
)
# multiplication using logarithms
def log_mult(a, b):
if a == 0 or b == 0:
return 0
return 0
x = LOG_TABLE[a]
y = LOG_TABLE[b]
z = EXP_TABLE[x + y]
@ -115,11 +115,11 @@ def log_mult(a, b):
# Reed-Solomon (12,9) encoder
def encode(_msg):
assert len(_msg) == 9, 'RS129_encode error: Message not 9 bytes: %s' % print_hex(_msg)
parity = [0x00, 0x00, 0x00]
for i in range(NUM_BYTES):
dbyte = _msg[i] ^ parity[NPAR - 1]
dbyte = _msg[i] ^ parity[NPAR - 1]
for j in range(NPAR - 1, 0, -1):
parity[j] = parity[j - 1] ^ log_mult(POLY[j], dbyte)
parity[0] = log_mult(POLY[0], dbyte)
@ -131,7 +131,7 @@ def lc_header_mask(_parity):
for i in range(len(_parity)):
xor[i] = _parity[i] ^ START_MASK[i]
return xor
# Apply DMR XOR LC Terminator MASK
def lc_terminator_mask(_parity):
xor = [0,0,0]
@ -145,29 +145,29 @@ def lc_header_encode(_message):
parity = encode(bin_message)
masked_parity = lc_header_mask(parity)
return bytes([masked_parity[0]]) + bytes([masked_parity[1]]) + bytes([masked_parity[2]])
# All Inclusive function to take an LC string and provide the RS129 string to append
def lc_terminator_encode(_message):
bin_message = bytearray(_message)
parity = encode(bin_message)
masked_parity = lc_terminator_mask(parity)
return bytes([masked_parity[0]]) + bytes([masked_parity[1]]) + bytes([masked_parity[2]])
if __name__ == '__main__':
if __name__ == '__main__':
from binascii import b2a_hex as ahex
# For testing the code
def print_hex(_list):
print('[{}]'.format(', '.join(hex(x) for x in _list)))
# Validation Example
message = b'\x00\x10\x20\x00\x0c\x30\x2f\x9b\xe5'
parity_should_be = b'\xda\x4d\x5a'
print('Original Message: {}'.format(ahex(message)))
print('Masked Parity Should be: {}'.format(ahex(parity_should_be)))
parity = lc_header_encode(message)
print('Calculated Masked Parity is: {}'.format(ahex(parity)))

@ -1,4 +1,4 @@
# ADN DMR Peer Server - infrastructure bootstrap init
# ADN DMR Peer Server - infrastructure bootstrap init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#

@ -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__)

@ -1,4 +1,4 @@
# ADN DMR Peer Server - infrastructure mesh init
# ADN DMR Peer Server - infrastructure mesh init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#

@ -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

@ -1,4 +1,4 @@
# ADN DMR Peer Server - infrastructure proxy init
# ADN DMR Peer Server - infrastructure proxy init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
@ -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

@ -1,4 +1,4 @@
# ADN DMR Peer Server - infrastructure proxy persistence init
# ADN DMR Peer Server - infrastructure proxy persistence init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#

@ -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

@ -1,4 +1,4 @@
# ADN DMR Peer Server - infrastructure twisted adapters report init
# ADN DMR Peer Server - infrastructure twisted adapters report init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#

@ -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

@ -1,4 +1,4 @@
# ADN DMR Peer Server - tests init
# ADN DMR Peer Server - tests init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#

@ -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

@ -1,4 +1,4 @@
# ADN DMR Peer Server - tests harness init
# ADN DMR Peer Server - tests harness init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#

@ -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

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.