diff --git a/.github/workflows/release-pr-check.yml b/.github/workflows/release-pr-check.yml new file mode 100644 index 0000000..0022f38 --- /dev/null +++ b/.github/workflows/release-pr-check.yml @@ -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 = ''; + 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, + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e7b92df --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/docs/en/server/development/testing.md b/docs/en/server/development/testing.md index 995b49f..c712da0 100644 --- a/docs/en/server/development/testing.md +++ b/docs/en/server/development/testing.md @@ -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). diff --git a/pyproject.toml b/pyproject.toml index 75f63c3..67254ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..0313ce4 --- /dev/null +++ b/pytest.ini @@ -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 diff --git a/src/adn_server/application/__init__.py b/src/adn_server/application/__init__.py index 5124def..7c50e46 100644 --- a/src/adn_server/application/__init__.py +++ b/src/adn_server/application/__init__.py @@ -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__ = [ diff --git a/src/adn_server/application/proxy/__init__.py b/src/adn_server/application/proxy/__init__.py index 3733165..c330bb5 100644 --- a/src/adn_server/application/proxy/__init__.py +++ b/src/adn_server/application/proxy/__init__.py @@ -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 # diff --git a/src/adn_server/application/report/__init__.py b/src/adn_server/application/report/__init__.py index 80d94ee..a9bd92b 100644 --- a/src/adn_server/application/report/__init__.py +++ b/src/adn_server/application/report/__init__.py @@ -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 # @@ -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", diff --git a/src/adn_server/application/report/dashboard_state.py b/src/adn_server/application/report/dashboard_state.py index 0576f44..237acf3 100644 --- a/src/adn_server/application/report/dashboard_state.py +++ b/src/adn_server/application/report/dashboard_state.py @@ -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 diff --git a/src/adn_server/application/report/monitor_topology.py b/src/adn_server/application/report/monitor_topology.py index da47e63..c3dc06c 100644 --- a/src/adn_server/application/report/monitor_topology.py +++ b/src/adn_server/application/report/monitor_topology.py @@ -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) diff --git a/src/adn_server/application/report/payloads.py b/src/adn_server/application/report/payloads.py index 5188430..1a126c7 100644 --- a/src/adn_server/application/report/payloads.py +++ b/src/adn_server/application/report/payloads.py @@ -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 = ( diff --git a/src/adn_server/application/routing/__init__.py b/src/adn_server/application/routing/__init__.py index 045811c..5f8c687 100644 --- a/src/adn_server/application/routing/__init__.py +++ b/src/adn_server/application/routing/__init__.py @@ -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 # diff --git a/src/adn_server/application/routing/downlink.py b/src/adn_server/application/routing/downlink.py index 159a117..0242d4e 100644 --- a/src/adn_server/application/routing/downlink.py +++ b/src/adn_server/application/routing/downlink.py @@ -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, diff --git a/src/adn_server/application/routing/helpers.py b/src/adn_server/application/routing/helpers.py index 84fbdb3..7ef30fc 100644 --- a/src/adn_server/application/routing/helpers.py +++ b/src/adn_server/application/routing/helpers.py @@ -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). diff --git a/src/adn_server/application/routing/lc_ta.py b/src/adn_server/application/routing/lc_ta.py index fb9e14e..efa18e1 100644 --- a/src/adn_server/application/routing/lc_ta.py +++ b/src/adn_server/application/routing/lc_ta.py @@ -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 diff --git a/src/adn_server/application/routing/obp_forward.py b/src/adn_server/application/routing/obp_forward.py index cdbd7b4..4c10c4a 100644 --- a/src/adn_server/application/routing/obp_forward.py +++ b/src/adn_server/application/routing/obp_forward.py @@ -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__) diff --git a/src/adn_server/application/routing/voice_subscription.py b/src/adn_server/application/routing/voice_subscription.py index 98020ed..e762e72 100644 --- a/src/adn_server/application/routing/voice_subscription.py +++ b/src/adn_server/application/routing/voice_subscription.py @@ -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__) diff --git a/src/adn_server/application/routing_use_cases.py b/src/adn_server/application/routing_use_cases.py index 89a0ca7..b0ed0bf 100644 --- a/src/adn_server/application/routing_use_cases.py +++ b/src/adn_server/application/routing_use_cases.py @@ -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"): diff --git a/src/adn_server/application/subscription/__init__.py b/src/adn_server/application/subscription/__init__.py index 26294bc..cfce8df 100644 --- a/src/adn_server/application/subscription/__init__.py +++ b/src/adn_server/application/subscription/__init__.py @@ -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 # @@ -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", diff --git a/src/adn_server/application/subscription/ingress.py b/src/adn_server/application/subscription/ingress.py index 3e0be21..14f4c21 100644 --- a/src/adn_server/application/subscription/ingress.py +++ b/src/adn_server/application/subscription/ingress.py @@ -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"}) diff --git a/src/adn_server/application/subscription/rule_timer_ops.py b/src/adn_server/application/subscription/rule_timer_ops.py index 03cc6e0..0098a41 100644 --- a/src/adn_server/application/subscription/rule_timer_ops.py +++ b/src/adn_server/application/subscription/rule_timer_ops.py @@ -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__) diff --git a/src/adn_server/domain/__init__.py b/src/adn_server/domain/__init__.py index f99dd37..63fe32e 100644 --- a/src/adn_server/domain/__init__.py +++ b/src/adn_server/domain/__init__.py @@ -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", diff --git a/src/adn_server/domain/dmr/bptc.py b/src/adn_server/domain/dmr/bptc.py index 4040aa5..ce7bc4e 100644 --- a/src/adn_server/domain/dmr/bptc.py +++ b/src/adn_server/domain/dmr/bptc.py @@ -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)) diff --git a/src/adn_server/domain/dmr/const.py b/src/adn_server/domain/dmr/const.py index 4a1efb8..f65e67e 100644 --- a/src/adn_server/domain/dmr/const.py +++ b/src/adn_server/domain/dmr/const.py @@ -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) diff --git a/src/adn_server/domain/dmr/crc.py b/src/adn_server/domain/dmr/crc.py index 7c100a0..02e85e2 100644 --- a/src/adn_server/domain/dmr/crc.py +++ b/src/adn_server/domain/dmr/crc.py @@ -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)) diff --git a/src/adn_server/domain/dmr/decode.py b/src/adn_server/domain/dmr/decode.py index 83ec4a8..c68d987 100644 --- a/src/adn_server/domain/dmr/decode.py +++ b/src/adn_server/domain/dmr/decode.py @@ -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) diff --git a/src/adn_server/domain/dmr/rs129.py b/src/adn_server/domain/dmr/rs129.py index ae630b7..13215cf 100644 --- a/src/adn_server/domain/dmr/rs129.py +++ b/src/adn_server/domain/dmr/rs129.py @@ -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))) diff --git a/src/adn_server/infrastructure/bootstrap/__init__.py b/src/adn_server/infrastructure/bootstrap/__init__.py index c6cca0f..7594a41 100644 --- a/src/adn_server/infrastructure/bootstrap/__init__.py +++ b/src/adn_server/infrastructure/bootstrap/__init__.py @@ -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 # diff --git a/src/adn_server/infrastructure/bootstrap/peer_server.py b/src/adn_server/infrastructure/bootstrap/peer_server.py index b635478..994b6fe 100644 --- a/src/adn_server/infrastructure/bootstrap/peer_server.py +++ b/src/adn_server/infrastructure/bootstrap/peer_server.py @@ -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 ( diff --git a/src/adn_server/infrastructure/config_reload.py b/src/adn_server/infrastructure/config_reload.py index e30a81f..4833522 100644 --- a/src/adn_server/infrastructure/config_reload.py +++ b/src/adn_server/infrastructure/config_reload.py @@ -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__) diff --git a/src/adn_server/infrastructure/mesh/__init__.py b/src/adn_server/infrastructure/mesh/__init__.py index a06b0cc..273b19a 100644 --- a/src/adn_server/infrastructure/mesh/__init__.py +++ b/src/adn_server/infrastructure/mesh/__init__.py @@ -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 # diff --git a/src/adn_server/infrastructure/mesh/transports.py b/src/adn_server/infrastructure/mesh/transports.py index dfe3c96..5729b9d 100644 --- a/src/adn_server/infrastructure/mesh/transports.py +++ b/src/adn_server/infrastructure/mesh/transports.py @@ -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 diff --git a/src/adn_server/infrastructure/proxy/__init__.py b/src/adn_server/infrastructure/proxy/__init__.py index e0ee68c..487f147 100644 --- a/src/adn_server/infrastructure/proxy/__init__.py +++ b/src/adn_server/infrastructure/proxy/__init__.py @@ -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 # @@ -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 diff --git a/src/adn_server/infrastructure/proxy/persistence/__init__.py b/src/adn_server/infrastructure/proxy/persistence/__init__.py index b40be44..d944fec 100644 --- a/src/adn_server/infrastructure/proxy/persistence/__init__.py +++ b/src/adn_server/infrastructure/proxy/persistence/__init__.py @@ -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 # diff --git a/src/adn_server/infrastructure/proxy/runtime.py b/src/adn_server/infrastructure/proxy/runtime.py index 7e8e33e..3effd4c 100644 --- a/src/adn_server/infrastructure/proxy/runtime.py +++ b/src/adn_server/infrastructure/proxy/runtime.py @@ -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]: diff --git a/src/adn_server/infrastructure/talker_alias_emblc.py b/src/adn_server/infrastructure/talker_alias_emblc.py index 6e27435..e988748 100644 --- a/src/adn_server/infrastructure/talker_alias_emblc.py +++ b/src/adn_server/infrastructure/talker_alias_emblc.py @@ -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 diff --git a/src/adn_server/infrastructure/twisted_adapters/report/__init__.py b/src/adn_server/infrastructure/twisted_adapters/report/__init__.py index a09596b..e36183b 100644 --- a/src/adn_server/infrastructure/twisted_adapters/report/__init__.py +++ b/src/adn_server/infrastructure/twisted_adapters/report/__init__.py @@ -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 # diff --git a/src/adn_server/infrastructure/twisted_adapters/report/mqtt_publisher.py b/src/adn_server/infrastructure/twisted_adapters/report/mqtt_publisher.py index 52b9725..6e2793c 100644 --- a/src/adn_server/infrastructure/twisted_adapters/report/mqtt_publisher.py +++ b/src/adn_server/infrastructure/twisted_adapters/report/mqtt_publisher.py @@ -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 diff --git a/src/adn_server/infrastructure/twisted_adapters/report/worker.py b/src/adn_server/infrastructure/twisted_adapters/report/worker.py index 744b401..295e3ce 100644 --- a/src/adn_server/infrastructure/twisted_adapters/report/worker.py +++ b/src/adn_server/infrastructure/twisted_adapters/report/worker.py @@ -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__) diff --git a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py index 3991463..66b476a 100644 --- a/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py +++ b/src/adn_server/infrastructure/twisted_adapters/udp_hbp.py @@ -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 ): diff --git a/src/adn_server/infrastructure/voice/pkt_gen.py b/src/adn_server/infrastructure/voice/pkt_gen.py index 3517cef..e201019 100644 --- a/src/adn_server/infrastructure/voice/pkt_gen.py +++ b/src/adn_server/infrastructure/voice/pkt_gen.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py index 7aa2912..4237b8a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,4 @@ -# ADN DMR Peer Server - tests init +# ADN DMR Peer Server - tests init # # Copyright (C) 2026 Rodrigo Pérez, CE5RPY # diff --git a/tests/application/test_downlink_stabilization.py b/tests/application/test_downlink_stabilization.py index 1d8adb5..795af57 100644 --- a/tests/application/test_downlink_stabilization.py +++ b/tests/application/test_downlink_stabilization.py @@ -22,7 +22,6 @@ from __future__ import annotations - from adn_server.application.routing.downlink import ( DownlinkContext, iter_downlink_voice_slots, diff --git a/tests/application/test_dynamic_tg_persist.py b/tests/application/test_dynamic_tg_persist.py index 989dfce..7c72320 100644 --- a/tests/application/test_dynamic_tg_persist.py +++ b/tests/application/test_dynamic_tg_persist.py @@ -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: diff --git a/tests/application/test_dynamic_tg_restore.py b/tests/application/test_dynamic_tg_restore.py index eafa663..c182170 100644 --- a/tests/application/test_dynamic_tg_restore.py +++ b/tests/application/test_dynamic_tg_restore.py @@ -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: diff --git a/tests/application/test_in_band_signalling_store_ops.py b/tests/application/test_in_band_signalling_store_ops.py index b516418..3450100 100644 --- a/tests/application/test_in_band_signalling_store_ops.py +++ b/tests/application/test_in_band_signalling_store_ops.py @@ -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: diff --git a/tests/application/test_monitor_hangtime_gate.py b/tests/application/test_monitor_hangtime_gate.py index a7bd997..68160c4 100644 --- a/tests/application/test_monitor_hangtime_gate.py +++ b/tests/application/test_monitor_hangtime_gate.py @@ -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 == [] diff --git a/tests/application/test_monitor_report_contract.py b/tests/application/test_monitor_report_contract.py index 83f1ffa..81caa36 100644 --- a/tests/application/test_monitor_report_contract.py +++ b/tests/application/test_monitor_report_contract.py @@ -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 diff --git a/tests/application/test_monitor_topology.py b/tests/application/test_monitor_topology.py index 8bc8e4c..268800e 100644 --- a/tests/application/test_monitor_topology.py +++ b/tests/application/test_monitor_topology.py @@ -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 { diff --git a/tests/application/test_obp_hbp_cross_slot_downlink.py b/tests/application/test_obp_hbp_cross_slot_downlink.py index 8ee2942..0d40159 100644 --- a/tests/application/test_obp_hbp_cross_slot_downlink.py +++ b/tests/application/test_obp_hbp_cross_slot_downlink.py @@ -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, ) diff --git a/tests/application/test_peer_rf_mode.py b/tests/application/test_peer_rf_mode.py index 4342573..012f183 100644 --- a/tests/application/test_peer_rf_mode.py +++ b/tests/application/test_peer_rf_mode.py @@ -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: diff --git a/tests/application/test_peer_single_downlink.py b/tests/application/test_peer_single_downlink.py index 6d24376..b70d0fd 100644 --- a/tests/application/test_peer_single_downlink.py +++ b/tests/application/test_peer_single_downlink.py @@ -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, + ) diff --git a/tests/application/test_report_payloads.py b/tests/application/test_report_payloads.py index 57d880f..833822f 100644 --- a/tests/application/test_report_payloads.py +++ b/tests/application/test_report_payloads.py @@ -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" diff --git a/tests/application/test_routing_table_legacy_view.py b/tests/application/test_routing_table_legacy_view.py index d53b9c9..77ab5a3 100644 --- a/tests/application/test_routing_table_legacy_view.py +++ b/tests/application/test_routing_table_legacy_view.py @@ -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: diff --git a/tests/application/test_rule_timer_store_ops.py b/tests/application/test_rule_timer_store_ops.py index c9b9f42..d45ffbf 100644 --- a/tests/application/test_rule_timer_store_ops.py +++ b/tests/application/test_rule_timer_store_ops.py @@ -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: diff --git a/tests/application/test_slot_contention.py b/tests/application/test_slot_contention.py index dbb68cf..a7d39fb 100644 --- a/tests/application/test_slot_contention.py +++ b/tests/application/test_slot_contention.py @@ -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) diff --git a/tests/application/test_stat_trimmer_store_ops.py b/tests/application/test_stat_trimmer_store_ops.py index bf52253..7cb647b 100644 --- a/tests/application/test_stat_trimmer_store_ops.py +++ b/tests/application/test_stat_trimmer_store_ops.py @@ -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 diff --git a/tests/application/test_store_authority_timers.py b/tests/application/test_store_authority_timers.py index 1174892..c122efd 100644 --- a/tests/application/test_store_authority_timers.py +++ b/tests/application/test_store_authority_timers.py @@ -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))) diff --git a/tests/application/test_subscription_router.py b/tests/application/test_subscription_router.py index 0b34710..eeed417 100644 --- a/tests/application/test_subscription_router.py +++ b/tests/application/test_subscription_router.py @@ -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 diff --git a/tests/application/test_subscription_table_store_ops.py b/tests/application/test_subscription_table_store_ops.py index d17f20a..e8d6c97 100644 --- a/tests/application/test_subscription_table_store_ops.py +++ b/tests/application/test_subscription_table_store_ops.py @@ -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: diff --git a/tests/application/test_timer_store_ops.py b/tests/application/test_timer_store_ops.py index fc26de0..60f2971 100644 --- a/tests/application/test_timer_store_ops.py +++ b/tests/application/test_timer_store_ops.py @@ -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: diff --git a/tests/application/test_voice_subscription_plan.py b/tests/application/test_voice_subscription_plan.py index 224b1ae..f6a1ca2 100644 --- a/tests/application/test_voice_subscription_plan.py +++ b/tests/application/test_voice_subscription_plan.py @@ -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: diff --git a/tests/bridge/test_config_reload.py b/tests/bridge/test_config_reload.py new file mode 100644 index 0000000..570b622 --- /dev/null +++ b/tests/bridge/test_config_reload.py @@ -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 diff --git a/tests/bridge/test_crc_dedup.py b/tests/bridge/test_crc_dedup.py new file mode 100644 index 0000000..cd3e143 --- /dev/null +++ b/tests/bridge/test_crc_dedup.py @@ -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 diff --git a/tests/bridge/test_echo_bridgereset.py b/tests/bridge/test_echo_bridgereset.py new file mode 100644 index 0000000..d39d0d2 --- /dev/null +++ b/tests/bridge/test_echo_bridgereset.py @@ -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 diff --git a/tests/bridge/test_options_config_loop.py b/tests/bridge/test_options_config_loop.py new file mode 100644 index 0000000..bde6475 --- /dev/null +++ b/tests/bridge/test_options_config_loop.py @@ -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" diff --git a/tests/bridge/test_private_voice.py b/tests/bridge/test_private_voice.py new file mode 100644 index 0000000..055ee8a --- /dev/null +++ b/tests/bridge/test_private_voice.py @@ -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 == [] diff --git a/tests/bridge/test_startup_bridges.py b/tests/bridge/test_startup_bridges.py new file mode 100644 index 0000000..f8600f2 --- /dev/null +++ b/tests/bridge/test_startup_bridges.py @@ -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) diff --git a/tests/bridge/test_static_tg_options.py b/tests/bridge/test_static_tg_options.py new file mode 100644 index 0000000..239ed66 --- /dev/null +++ b/tests/bridge/test_static_tg_options.py @@ -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 diff --git a/tests/bridge/test_unit_data.py b/tests/bridge/test_unit_data.py new file mode 100644 index 0000000..6351d09 --- /dev/null +++ b/tests/bridge/test_unit_data.py @@ -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" diff --git a/tests/echo/test_rekey_playback.py b/tests/echo/test_rekey_playback.py index 84cfec2..0e3129d 100644 --- a/tests/echo/test_rekey_playback.py +++ b/tests/echo/test_rekey_playback.py @@ -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 diff --git a/tests/harness/__init__.py b/tests/harness/__init__.py index 9ae606f..a21268c 100644 --- a/tests/harness/__init__.py +++ b/tests/harness/__init__.py @@ -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 # diff --git a/tests/harness/deterministic.py b/tests/harness/deterministic.py index b92f3c7..fd3b46b 100644 --- a/tests/harness/deterministic.py +++ b/tests/harness/deterministic.py @@ -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 diff --git a/tests/harness/session_replay.py b/tests/harness/session_replay.py index 1745918..2f7058a 100644 --- a/tests/harness/session_replay.py +++ b/tests/harness/session_replay.py @@ -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, diff --git a/tests/infrastructure/mesh/test_obp_v1.py b/tests/infrastructure/mesh/test_obp_v1.py index c53f63a..804bc25 100644 --- a/tests/infrastructure/mesh/test_obp_v1.py +++ b/tests/infrastructure/mesh/test_obp_v1.py @@ -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, diff --git a/tests/infrastructure/test_config_validator.py b/tests/infrastructure/test_config_validator.py new file mode 100644 index 0000000..27ba995 --- /dev/null +++ b/tests/infrastructure/test_config_validator.py @@ -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 diff --git a/tests/infrastructure/test_doctor.py b/tests/infrastructure/test_doctor.py index 65722c7..531ee9e 100644 --- a/tests/infrastructure/test_doctor.py +++ b/tests/infrastructure/test_doctor.py @@ -24,7 +24,6 @@ from __future__ import annotations import textwrap - from adn_server.infrastructure.doctor import collect_findings, run_doctor diff --git a/tests/infrastructure/test_echo_cli.py b/tests/infrastructure/test_echo_cli.py index 3c8c5ee..1fe771c 100644 --- a/tests/infrastructure/test_echo_cli.py +++ b/tests/infrastructure/test_echo_cli.py @@ -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] = [] diff --git a/tests/infrastructure/test_echo_config.py b/tests/infrastructure/test_echo_config.py index 4c2233b..39bc676 100644 --- a/tests/infrastructure/test_echo_config.py +++ b/tests/infrastructure/test_echo_config.py @@ -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"}, diff --git a/tests/infrastructure/test_hbp_repeat_options_filter.py b/tests/infrastructure/test_hbp_repeat_options_filter.py index 848b863..162abea 100644 --- a/tests/infrastructure/test_hbp_repeat_options_filter.py +++ b/tests/infrastructure/test_hbp_repeat_options_filter.py @@ -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 diff --git a/tests/infrastructure/test_hbp_repeat_talker_alias.py b/tests/infrastructure/test_hbp_repeat_talker_alias.py index fc890b0..eef2bc3 100644 --- a/tests/infrastructure/test_hbp_repeat_talker_alias.py +++ b/tests/infrastructure/test_hbp_repeat_talker_alias.py @@ -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 diff --git a/tests/infrastructure/test_mqtt_config.py b/tests/infrastructure/test_mqtt_config.py index f59d605..104408f 100644 --- a/tests/infrastructure/test_mqtt_config.py +++ b/tests/infrastructure/test_mqtt_config.py @@ -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}} diff --git a/tests/infrastructure/test_mysql_pool_ensure.py b/tests/infrastructure/test_mysql_pool_ensure.py index 106b26a..5b4efc4 100644 --- a/tests/infrastructure/test_mysql_pool_ensure.py +++ b/tests/infrastructure/test_mysql_pool_ensure.py @@ -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: diff --git a/tests/infrastructure/test_peer_downlink_fanout.py b/tests/infrastructure/test_peer_downlink_fanout.py index 91ec8ed..84a37e1 100644 --- a/tests/infrastructure/test_peer_downlink_fanout.py +++ b/tests/infrastructure/test_peer_downlink_fanout.py @@ -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 diff --git a/tests/infrastructure/test_proxy_config.py b/tests/infrastructure/test_proxy_config.py index b34c1c5..bcfa3d0 100644 --- a/tests/infrastructure/test_proxy_config.py +++ b/tests/infrastructure/test_proxy_config.py @@ -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: diff --git a/tests/infrastructure/test_proxy_reload.py b/tests/infrastructure/test_proxy_reload.py index 0476b43..184571b 100644 --- a/tests/infrastructure/test_proxy_reload.py +++ b/tests/infrastructure/test_proxy_reload.py @@ -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 diff --git a/tests/infrastructure/test_proxy_repeat_e2e.py b/tests/infrastructure/test_proxy_repeat_e2e.py index aef5a44..857fb56 100644 --- a/tests/infrastructure/test_proxy_repeat_e2e.py +++ b/tests/infrastructure/test_proxy_repeat_e2e.py @@ -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 diff --git a/tests/infrastructure/test_session_teardown.py b/tests/infrastructure/test_session_teardown.py index 6a2ba6c..4d39099 100644 --- a/tests/infrastructure/test_session_teardown.py +++ b/tests/infrastructure/test_session_teardown.py @@ -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) diff --git a/tests/infrastructure/test_subscription_store_index.py b/tests/infrastructure/test_subscription_store_index.py index b70133a..01c8a03 100644 --- a/tests/infrastructure/test_subscription_store_index.py +++ b/tests/infrastructure/test_subscription_store_index.py @@ -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 diff --git a/tests/parrot/test_playback_send_loop.py b/tests/parrot/test_playback_send_loop.py new file mode 100644 index 0000000..4cf5a0e --- /dev/null +++ b/tests/parrot/test_playback_send_loop.py @@ -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) + diff --git a/tests/parrot/test_recording_timers.py b/tests/parrot/test_recording_timers.py new file mode 100644 index 0000000..7bd2135 --- /dev/null +++ b/tests/parrot/test_recording_timers.py @@ -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]) diff --git a/tests/parrot/test_rekey_playback.py b/tests/parrot/test_rekey_playback.py new file mode 100644 index 0000000..da610f7 --- /dev/null +++ b/tests/parrot/test_rekey_playback.py @@ -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] diff --git a/tests/replay/test_session_replay.py b/tests/replay/test_session_replay.py index efda2ca..bca84a0 100644 --- a/tests/replay/test_session_replay.py +++ b/tests/replay/test_session_replay.py @@ -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, diff --git a/tests/routing/test_echo_bootstrap_seed.py b/tests/routing/test_echo_bootstrap_seed.py index 7b18198..076abd9 100644 --- a/tests/routing/test_echo_bootstrap_seed.py +++ b/tests/routing/test_echo_bootstrap_seed.py @@ -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: diff --git a/tests/routing/test_echo_subscription_reset.py b/tests/routing/test_echo_subscription_reset.py index 0760472..5dbde6f 100644 --- a/tests/routing/test_echo_subscription_reset.py +++ b/tests/routing/test_echo_subscription_reset.py @@ -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: diff --git a/tests/routing/test_peer_options_override.py b/tests/routing/test_peer_options_override.py index 602ce2c..1801a31 100644 --- a/tests/routing/test_peer_options_override.py +++ b/tests/routing/test_peer_options_override.py @@ -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( diff --git a/tests/routing/test_slot_contention_bridge.py b/tests/routing/test_slot_contention_bridge.py new file mode 100644 index 0000000..c5f1544 --- /dev/null +++ b/tests/routing/test_slot_contention_bridge.py @@ -0,0 +1,101 @@ +# ADN DMR Peer Server - bridge slot contention behaviour +# +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY + +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") == [] diff --git a/tests/routing/test_subscription_router_dmrd.py b/tests/routing/test_subscription_router_dmrd.py index e1b1b9e..a560753 100644 --- a/tests/routing/test_subscription_router_dmrd.py +++ b/tests/routing/test_subscription_router_dmrd.py @@ -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: diff --git a/tests/routing/test_unit_data_routing.py b/tests/routing/test_unit_data_routing.py index 71c8326..059cd47 100644 --- a/tests/routing/test_unit_data_routing.py +++ b/tests/routing/test_unit_data_routing.py @@ -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 diff --git a/tests/scripts/test_freedmr_cfg_to_yaml.py b/tests/scripts/test_freedmr_cfg_to_yaml.py new file mode 100644 index 0000000..8746a11 --- /dev/null +++ b/tests/scripts/test_freedmr_cfg_to_yaml.py @@ -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 diff --git a/tests/smoke/test_bridge_routing.py b/tests/smoke/test_bridge_routing.py new file mode 100644 index 0000000..9eed8a7 --- /dev/null +++ b/tests/smoke/test_bridge_routing.py @@ -0,0 +1,24 @@ +"""Smoke tests for basic HBP bridge forwarding.""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_forwarded +from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge + + +@pytest.mark.smoke +def test_static_tg_routes_hbp_to_peer_master() -> None: + """Regression: active bridge forwards HBP group voice to peer MASTER.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + base = PacketSpec(dst_id=91, stream_id=0x0A0B0C0D) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + for seq in range(1, 4): + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=seq), + ) + + assert_forwarded(scenario, "MASTER-B", count=4, call_type="group", dst_id=91) diff --git a/tests/smoke/test_packet_builder.py b/tests/smoke/test_packet_builder.py new file mode 100644 index 0000000..faef347 --- /dev/null +++ b/tests/smoke/test_packet_builder.py @@ -0,0 +1,20 @@ +"""Smoke tests for PacketSpec and DMR header parsing.""" + +from __future__ import annotations + +from tests.harness.deterministic import PacketSpec, parse_dmr_fields + +from adn_server.domain import int_id + + +def test_packet_spec_builds_valid_dmr_header() -> None: + spec = PacketSpec(dst_id=91, rf_src=3120001, peer_id=1001, seq=7) + packet = spec.data() + fields = parse_dmr_fields(packet) + + assert fields["opcode"] == b"DMRD" + assert fields["seq"] == 7 + assert int_id(fields["dst_id"]) == 91 + assert int_id(fields["rf_src"]) == 3120001 + assert fields["slot"] == 2 + assert fields["call_type"] == "group" diff --git a/tests/support/hbp_repeat_stack.py b/tests/support/hbp_repeat_stack.py index e46f73e..80fc6a5 100644 --- a/tests/support/hbp_repeat_stack.py +++ b/tests/support/hbp_repeat_stack.py @@ -26,21 +26,21 @@ import copy from dataclasses import dataclass, field from typing import Any -from adn_server.application.routing_use_cases import RoutingUseCases +from tests.harness.deterministic import FakeReportFactory, FakeReportSender, PacketSpec +from tests.harness.scenarios import talker_alias_config + from adn_server.application.reporting_use_cases import ReportingUseCases +from adn_server.application.routing_use_cases import RoutingUseCases from adn_server.domain.dmr.bptc import encode_emblc from adn_server.infrastructure.acl_router import InMemoryAclRouter -from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore from adn_server.infrastructure.config_normalizer import ( apply_talker_alias_defaults, ensure_system_runtime_config, ) +from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder from adn_server.infrastructure.twisted_adapters.udp_hbp import HBPProtocol -from tests.harness.deterministic import FakeReportFactory, FakeReportSender, PacketSpec -from tests.harness.scenarios import talker_alias_config - class RecordingTransport: """Capture MASTER downlink UDP writes (REPEAT and DMRA).""" diff --git a/tests/talker_alias/test_bridge_inject.py b/tests/talker_alias/test_bridge_inject.py new file mode 100644 index 0000000..dbef95c --- /dev/null +++ b/tests/talker_alias/test_bridge_inject.py @@ -0,0 +1,55 @@ +"""Talker Alias bridge/repeat injection.""" + +from __future__ import annotations + +from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge +from tests.harness.scenarios import talker_alias_config + +from adn_server.domain import bytes_3, bytes_4 +from adn_server.domain.talker_alias import DMRA_BLOCK_COUNT, DMRA_OPCODE + + +def test_talker_alias_inject_dmra_on_bridge_vhead() -> None: + """Bridge forward sends DMRA blocks once per target stream on VHEAD.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges) + base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x90909090) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + + assert len(scenario.dmra_capture) == 1 + dmra = scenario.dmra_capture[0] + assert dmra.target_system == "MASTER-B" + assert 1 <= len(dmra.packets) <= DMRA_BLOCK_COUNT + assert all(p[:4] == DMRA_OPCODE for p in dmra.packets) + payload = b"".join(p[8:15] for p in dmra.packets) + assert b"CE5RPY" in payload + + +def test_talker_alias_vhead_sent_once_per_target_stream() -> None: + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges) + base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x91919191) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + + assert len(scenario.dmra_capture) == 1 + + +def test_talker_alias_local_repeat_excludes_source_peer() -> None: + scenario = DeterministicScenario( + config=talker_alias_config(), + bridges=active_bridge(91, (("MASTER-A", 2),)), + ) + stream_id = bytes_4(0x92929292) + peer = bytes_4(1001) + rf_src = bytes_3(3120001) + + scenario.bridge.send_talker_alias_local_repeat("MASTER-A", peer, rf_src, stream_id) + + assert len(scenario.dmra_capture) == 1 + assert scenario.dmra_capture[0].exclude_peer == peer diff --git a/tests/talker_alias/test_dmra_early_buffer.py b/tests/talker_alias/test_dmra_early_buffer.py index 30d4730..2576713 100644 --- a/tests/talker_alias/test_dmra_early_buffer.py +++ b/tests/talker_alias/test_dmra_early_buffer.py @@ -22,14 +22,13 @@ from __future__ import annotations -from adn_server.domain import bytes_3, bytes_4 -from adn_server.domain.talker_alias import build_dmra_packet, decode_ta_from_blocks -from adn_server.infrastructure.hbp_constants import DMRA - -from tests.support.hbp_repeat_stack import build_hbp_repeat_stack from tests.harness.deterministic import DeterministicScenario, PacketSpec +from tests.support.hbp_repeat_stack import build_hbp_repeat_stack from tests.talker_alias.test_mmdvm_wire import mmdvm_wire_blocks +from adn_server.domain import bytes_3, bytes_4 +from adn_server.domain.talker_alias import build_dmra_packet, decode_ta_from_blocks +from adn_server.infrastructure.hbp_constants import DMRA _PEER_TX = bytes_4(730039210) _PEER_RX = bytes_4(730039101) diff --git a/tests/talker_alias/test_embed_ta.py b/tests/talker_alias/test_embed_ta.py index b9e6f1c..28e1923 100644 --- a/tests/talker_alias/test_embed_ta.py +++ b/tests/talker_alias/test_embed_ta.py @@ -24,6 +24,7 @@ from __future__ import annotations from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config from tests.talker_alias.test_passthrough import _complete_blocks, _passthrough_config + from adn_server.domain import bytes_3, bytes_4 diff --git a/tests/talker_alias/test_mmdvm_wire.py b/tests/talker_alias/test_mmdvm_wire.py index 51962a8..0685285 100644 --- a/tests/talker_alias/test_mmdvm_wire.py +++ b/tests/talker_alias/test_mmdvm_wire.py @@ -25,6 +25,7 @@ from __future__ import annotations from bitarray import bitarray from adn_server.application.talker_alias_use_cases import passthrough_complete, passthrough_packets_from_blocks +from adn_server.domain.dmr.bptc import encode_emblc from adn_server.domain.talker_alias import ( decode_ta_from_blocks, encode_utf8, @@ -35,7 +36,6 @@ from adn_server.domain.talker_alias import ( talker_alias_lc_bytes, try_buffer_ta_from_voice_fragments, ) -from adn_server.domain.dmr.bptc import encode_emblc from adn_server.infrastructure.talker_alias_emblc import encode_talker_alias_emblc _encode_emblc_correct = encode_emblc diff --git a/tests/talker_alias/test_relay_dedupe.py b/tests/talker_alias/test_relay_dedupe.py index 3df9292..fe5ae5d 100644 --- a/tests/talker_alias/test_relay_dedupe.py +++ b/tests/talker_alias/test_relay_dedupe.py @@ -22,15 +22,15 @@ from __future__ import annotations +from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config +from tests.talker_alias.test_mmdvm_wire import mmdvm_wire_blocks + from adn_server.application.routing_use_cases import RoutingUseCases from adn_server.domain import bytes_3, bytes_4 +from adn_server.domain.dmr.bptc import encode_emblc from adn_server.infrastructure.acl_router import InMemoryAclRouter from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder -from adn_server.domain.dmr.bptc import encode_emblc - -from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config -from tests.talker_alias.test_mmdvm_wire import mmdvm_wire_blocks def test_should_resend_passthrough_only_after_inject() -> None: diff --git a/tests/voice/test_embed_ta_forward.py b/tests/voice/test_embed_ta_forward.py new file mode 100644 index 0000000..15fe52a --- /dev/null +++ b/tests/voice/test_embed_ta_forward.py @@ -0,0 +1,39 @@ +"""Talker Alias embedded LC alternation on voice bursts.""" + +from __future__ import annotations + +from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge +from tests.harness.scenarios import talker_alias_config + + +def test_talker_alias_embed_state_prepared_on_bridge_vhead() -> None: + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges) + base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0xABABABAB) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + + ts_st = scenario.protocols["MASTER-B"].STATUS.get(2, {}) + assert "TX_TA_EMB" in ts_st + assert ts_st.get("TX_TA_ON") is False + assert ts_st.get("TX_TA_PHASE") == 0 + + +def test_talker_alias_embed_cleared_on_vterm() -> None: + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges) + base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0xCDCDCDCD) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_term_spec(base, seq=99), + ) + + ts_st = scenario.protocols["MASTER-B"].STATUS.get(2, {}) + assert "TX_TA_EMB" not in ts_st + assert "TX_TA_ON" not in ts_st