feat(routing): rename bridge layer, fix OBP forward, and optimize downlink CPU

Replace internal bridge terminology with routing (RoutingUseCases, AclRouter,
routing_table export). SubscriptionStore remains runtime authority with O(1)
indexes for router and downlink filters. Fix STATIC TG parity on OPTIONS/RPTO,
parrot in-band edge cases, and remove per-packet routing_table export from the
hot path that caused high CPU under multi-hotspot OBP load.
pull/1/head
Rodrigo Pérez 4 months ago
parent e7f0a2d905
commit 07b4a66831

@ -6,7 +6,7 @@ All notable changes to **adn-server** are documented here. Versioning follows [S
### Added
- SIGHUP reload reapplies YAML static TGs via `apply_startup_bridges()` when SYSTEMS change.
- SIGHUP reload reapplies YAML static TGs via `apply_startup_subscriptions()` when SYSTEMS change.
- Store-native timer loops (`rule_timer`, `stat_trimmer`, `bridge_debug`, `bridge_reset`) and in-band signalling.
- Store-native bridge table (OPTIONS, static TG, reflectors, UA) and OBP source ensure.
- `InbandTriggers` (ON/OFF/RESET) on `Subscription` with import/export round-trip.
@ -15,7 +15,7 @@ All notable changes to **adn-server** are documented here. Versioning follows [S
### Changed
- `SubscriptionStore` is required at runtime; bridge timers, OPTIONS, and table paths mutate the store only.
- `BRIDGES` is an export shim for monitor/report (`export_bridges`); no direct dict mutation in hot paths.
- `BRIDGES` is an export shim for monitor/report (`export_routing_table`); no direct dict mutation in hot paths.
- `_export_store_to_router()` publishes store mutations without router→store overwrite (timer/in-band paths).
### Fixed
@ -27,7 +27,7 @@ All notable changes to **adn-server** are documented here. Versioning follows [S
### Removed
- Periodic 26s `options_config_loop` (V2-P2-016): OPTIONS/static TGs covered by RPTO, startup/reload, and dmrd fallback.
- Runtime `replace_store_from_bridges` calls in bridge timers and OPTIONS paths (store-native slice 6).
- Runtime `replace_store_from_routing_table` calls in bridge timers and OPTIONS paths (store-native slice 6).
## [2.0.0-alpha.2] - 2026-06-11
@ -41,7 +41,7 @@ Subscription runtime (phase 2b/2c) closed for production Chile.
### Changed
- Removed YAML flags `USE_SUBSCRIPTION_ROUTER` and `USE_SUBSCRIPTION_STORE_AUTHORITY`; rollback via git tags only.
- `get_bridges()` mirrors router state into the store before export (report/monitor shim).
- `routing_table_for_report()` mirrors router state into the store before export (report/monitor shim).
### Fixed

@ -3,7 +3,7 @@
## Layers
1. **Domain** (`src/adn_server/domain/`) — entities, value objects, errors, `Result`. No I/O, no Twisted.
2. **Application** (`src/adn_server/application/`) — use cases (`BridgeUseCases`, `VoiceUseCases`, …) and **ports** (interfaces).
2. **Application** (`src/adn_server/application/`) — use cases (`RoutingUseCases`, `VoiceUseCases`, …) and **ports** (interfaces).
3. **Infrastructure** (`src/adn_server/infrastructure/`) — YAML config, Twisted UDP/TCP, voice, persistence, security adapters.
**Dependency rule:** infrastructure → application → domain (inward only).
@ -12,13 +12,23 @@
`main.py` wires configuration, **LoopingCall** timers, factories for **HBPProtocol**, report client, and injects use cases.
## Runtime routing authority
Voice routing is driven by **`SubscriptionStore`** (domain subscriptions). `RoutingUseCases` orchestrates `dmrd_received` and delegates forward resolution to **`SubscriptionRouter`**.
- **`InMemoryAclRouter`** (`AclRouter` port) — ACL range checks only (`acl_check`).
- **`routing_table_for_report()`** — export shim for monitor/report (legacy BRIDGE_SND shape); not used for runtime forwards.
Wire opcodes and YAML keys may still say “bridge” for legacy monitor compatibility (`BRIDGE_SND`, `GEN_STAT_BRIDGES`).
## Where to read code
| Topic | Location |
|-------|----------|
| Bridge routing, `dmrd_received`, OpenBridge loop | `application/bridge_use_cases.py` |
| Voice routing, `dmrd_received`, OpenBridge forward | `application/routing_use_cases.py`, `application/routing/` |
| Subscription store, router, in-band rules | `application/subscription/` |
| HBP / OpenBridge UDP | `infrastructure/twisted_adapters/udp_hbp.py` |
| Report TCP | `infrastructure/twisted_adapters/report_server.py` (factory), bridge events from use cases |
| Report TCP | `infrastructure/twisted_adapters/report_server.py` (factory), routing events from use cases |
| Voice / TTS | `application/voice_use_cases.py`, `infrastructure/voice/` |
| Hotspot proxy (fan-in) | `infrastructure/proxy/` (`udp_fanin.py`, `runtime.py`), `application/proxy/` use cases |
| Self-service (MySQL) | `infrastructure/proxy/self_service_bridge.py`, `infrastructure/proxy/persistence/` |

@ -1,6 +1,6 @@
# Testing
Regression tests live under **`tests/`**, **one topic per file**, grouped by domain (`bridge/`, `hbp/`, `obp/`, …). Full file index: `tests/README.md` (repo root, maintainer checkout).
Regression tests live under **`tests/`**, **one topic per file**, grouped by domain (`routing/`, `hbp/`, `obp/`, …). Full file index: `tests/README.md` (repo root, maintainer checkout).
They use an in-process **deterministic harness** (no Twisted reactor, no UDP sockets).
@ -23,7 +23,7 @@ python3 -m pytest tests/ -q
**By domain** (while working on one area):
```bash
python3 -m pytest tests/bridge/ -q
python3 -m pytest tests/routing/ -q
python3 -m pytest tests/hbp/ -q
python3 -m pytest tests/parrot/ -q
```
@ -31,14 +31,14 @@ python3 -m pytest tests/parrot/ -q
**By file** (recommended for point checks):
```bash
python3 -m pytest tests/bridge/test_unit_data_routing.py -q
python3 -m pytest tests/routing/test_unit_data_routing.py -q
python3 -m pytest tests/parrot/test_rekey_playback.py -q
```
**Single test**:
```bash
python3 -m pytest tests/bridge/test_startup_bridges.py::test_startup_bridge_routes_voice_after_apply -q
python3 -m pytest tests/routing/test_startup_subscriptions.py::test_startup_bridge_routes_voice_after_apply -q
```
Collect only:
@ -60,7 +60,8 @@ Registered in `pyproject.toml`:
| Component | Role |
|-----------|------|
| `DeterministicScenario` | Wires `BridgeUseCases` with fakes and packet capture |
| `DeterministicScenario` | Wires `RoutingUseCases` with fakes and packet capture |
| `scenario.routing` | Routing use-case facade (`dmrd_received`, timers) |
| `inject_hbp` / `inject_unit` / `inject_obp` | Public ingress paths into `dmrd_received` |
| `PacketCapture` | Records outbound `send_to_system` + parsed DMR fields |
| `FakeReportSender` + `ReportingUseCases` | Reporting events (via `scenario.report_factory.events`) |

@ -13,7 +13,7 @@ On the wire, extended OpenBridge uses the **`DMRE`** opcode. The **embedded prot
- **`DMRD`** version 1 — HMAC-SHA1 authenticated payload (compatibility mode); or
- **`DMRE`** — extended frame with **BLAKE2b** MAC, embedded version, timestamps, **hops**, source server/repeater IDs, etc. (**DMRE v5** = embedded version 5, recommended above).
This stack implements the **OPENBRIDGE** peer mode in **`udp_hbp.py`** and bridge routing in **`BridgeUseCases`**.
This stack implements the **OPENBRIDGE** peer mode in **`udp_hbp.py`** and voice routing in **`RoutingUseCases`** (`application/routing/`, `application/subscription/`).
## Ingress (DMRE)

@ -11,7 +11,7 @@
## OpenBridge vs MASTER
Private handling uses CSBK/data/unit branches, `SUB_MAP` lookup, and busy-slot checks where applicable (see `BridgeUseCases` in source).
Private handling uses CSBK/data/unit branches, `SUB_MAP` lookup, and busy-slot checks where applicable (see `RoutingUseCases` in source).
## TG / ID 4000 (unit)
@ -21,4 +21,4 @@ As documented in [Special numbers](special-numbers.md), a **private** call to **
Private **START/END** events may be emitted to the report TCP client when **`REPORTS.REPORT`** is enabled, analogous to group voice (shape `PRIVATE VOICE,...` where implemented).
For protocol ingress details, see [HBP](../protocols/hbp.md) and the bridge use cases in source (`BridgeUseCases._pvt_call_received`).
For protocol ingress details, see [HBP](../protocols/hbp.md) and the routing use cases in source (`RoutingUseCases._pvt_call_received`).

@ -3,7 +3,7 @@
## Capas
1. **Dominio** (`src/adn_server/domain/`) — entidades, objetos de valor, errores, `Result`. Sin E/S, sin Twisted.
2. **Aplicación** (`src/adn_server/application/`) — casos de uso (`BridgeUseCases`, `VoiceUseCases`, …) y **ports** (interfaces).
2. **Aplicación** (`src/adn_server/application/`) — casos de uso (`RoutingUseCases`, `VoiceUseCases`, …) y **ports** (interfaces).
3. **Infraestructura** (`src/adn_server/infrastructure/`) — config YAML, Twisted UDP/TCP, voz, persistencia, adaptadores de seguridad.
**Regla de dependencias:** infraestructura → aplicación → dominio (solo hacia dentro).
@ -12,13 +12,23 @@
`main.py` cablea configuración, temporizadores **LoopingCall**, fábricas para **HBPProtocol**, servidor de informes e inyecta casos de uso.
## Autoridad de enrutado en runtime
El enrutado de voz lo gobierna **`SubscriptionStore`** (suscripciones de dominio). `RoutingUseCases` orquesta `dmrd_received` y delega la resolución de reenvío a **`SubscriptionRouter`**.
- **`InMemoryAclRouter`** (port `AclRouter`) — solo comprobaciones ACL (`acl_check`).
- **`routing_table_for_report()`** — shim de exportación para monitor/informes (forma legacy BRIDGE_SND); no se usa para reenvíos en runtime.
Los opcodes wire y claves YAML pueden seguir diciendo “bridge” por compatibilidad con el monitor legacy (`BRIDGE_SND`, `GEN_STAT_BRIDGES`).
## Dónde leer código
| Tema | Ubicación |
|------|-----------|
| Enrutado de bridge, `dmrd_received`, bucle OpenBridge | `application/bridge_use_cases.py` |
| Enrutado de voz, `dmrd_received`, reenvío OpenBridge | `application/routing_use_cases.py`, `application/routing/` |
| Store de suscripciones, router, reglas in-band | `application/subscription/` |
| HBP / OpenBridge UDP | `infrastructure/twisted_adapters/udp_hbp.py` |
| Informes TCP | `infrastructure/twisted_adapters/report_server.py` (fábrica), eventos de bridge desde casos de uso |
| Informes TCP | `infrastructure/twisted_adapters/report_server.py` (fábrica), eventos de routing desde casos de uso |
| Voz / TTS | `application/voice_use_cases.py`, `infrastructure/voice/` |
| Proxy hotspot (fan-in) | `infrastructure/proxy/` (`udp_fanin.py`, `runtime.py`), casos de uso en `application/proxy/` |
| Self-service (MySQL) | `infrastructure/proxy/self_service_bridge.py`, `infrastructure/proxy/persistence/` |

@ -13,7 +13,7 @@ En cable, OpenBridge extendido usa el opcode **`DMRE`**. El **byte de versión d
- **`DMRD`** versión 1 — carga autenticada HMAC-SHA1 (modo de compatibilidad); o
- **`DMRE`** — trama extendida con MAC **BLAKE2b**, versión embebida, marcas de tiempo, **saltos**, IDs servidor/repetidor de origen, etc. (**DMRE v5** = versión embebida 5, recomendada arriba).
Esta pila implementa el modo par **OPENBRIDGE** en **`udp_hbp.py`** y el enrutado de bridges en **`BridgeUseCases`**.
Esta pila implementa el modo par **OPENBRIDGE** en **`udp_hbp.py`** y el enrutado de voz en **`RoutingUseCases`** (`application/routing/`, `application/subscription/`).
## Ingreso (DMRE)

@ -11,7 +11,7 @@ Las llamadas **unitarias (privadas)** usan un camino distinto a la voz de **grup
## OpenBridge frente a MASTER
El manejo privado usa ramas CSBK/datos/unit, búsqueda `SUB_MAP` y comprobaciones de slot ocupado donde aplique (ver `BridgeUseCases` en el código).
El manejo privado usa ramas CSBK/datos/unit, búsqueda `SUB_MAP` y comprobaciones de slot ocupado donde aplique (ver `RoutingUseCases` en el código).
## TG / ID 4000 (unitaria)
@ -21,4 +21,4 @@ Como en [Números especiales](special-numbers.md), una llamada **privada** a **4
Los eventos privados **START/END** pueden emitirse al cliente TCP de informes si **`REPORTS.REPORT`** está habilitado, análogo a voz de grupo (forma `PRIVATE VOICE,...` donde esté implementado).
Para detalles de ingreso de protocolo, ver [HBP](../protocols/hbp.md) y los casos de uso de bridge en código (`BridgeUseCases._pvt_call_received`).
Para detalles de ingreso de protocolo, ver [HBP](../protocols/hbp.md) y los casos de uso de routing en código (`RoutingUseCases._pvt_call_received`).

@ -9,7 +9,7 @@
"ts": 1717555261.0,
"routes": [
{
"bridge_key": "52090",
"relay_table_key": "52090",
"legs": [
{
"system": "MASTER-A",

@ -4,7 +4,7 @@
"ts": 1717555260.5,
"routes": [
{
"bridge_key": "52090",
"relay_table_key": "52090",
"legs": [
{
"system": "MASTER-A",
@ -24,7 +24,7 @@
]
},
{
"bridge_key": "#310",
"relay_table_key": "#310",
"legs": [
{
"system": "MASTER-A",

@ -192,9 +192,9 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["bridge_key", "legs"],
"required": ["relay_table_key", "legs"],
"properties": {
"bridge_key": {
"relay_table_key": {
"type": "string",
"description": "Talkgroup id string or reflector key (#xxx)."
},

@ -21,11 +21,11 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
from .bridge_use_cases import BridgeUseCases
from .routing_use_cases import RoutingUseCases
from .ident_use_cases import IdentUseCases
from .ports import (
AliasLoader,
BridgeRouter,
AclRouter,
ConfigLoader,
KeysStore,
ReportSender,
@ -42,10 +42,10 @@ __all__ = [
"SubMapStore",
"KeysStore",
"ReportSender",
"BridgeRouter",
"AclRouter",
"VoiceProvider",
"SecurityDownloader",
"BridgeUseCases",
"RoutingUseCases",
"IdentUseCases",
"VoiceUseCases",
"ReportingUseCases",

@ -1,2 +0,0 @@
# Bridge routing submodules.
# Public API: BridgeUseCases in adn_server.application.bridge_use_cases.

@ -1,38 +0,0 @@
# ADN DMR Peer Server - subscription store authority
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
"""Publish ``SubscriptionStore`` to the legacy ``BRIDGES`` shim for monitor/report."""
from __future__ import annotations
from typing import Any
from ..ports import SubscriptionStore
from ..subscription.bridges_export import export_bridges
from ..subscription.bridges_legacy_view import BridgesLegacyView
class StoreAuthorityMixin:
"""Store authority helpers for ``BridgeUseCases``."""
_subscription_store: SubscriptionStore
_router: Any
_config: dict[str, Any]
_bridges_legacy_view: BridgesLegacyView | None
def _bridges_for_report(self) -> dict[str, list[dict[str, Any]]]:
"""BRIDGES snapshot for monitor/report (export shim from subscription store)."""
view = getattr(self, "_bridges_legacy_view", None)
if view is None:
view = BridgesLegacyView(self._subscription_store)
self._bridges_legacy_view = view
return view.generate()
def _export_store_to_router(self) -> None:
"""Push subscription store snapshot to the legacy BRIDGES shim."""
self._router.set_bridges(export_bridges(self._subscription_store))
self._router.rebuild_source_index()
def _finalize_bridges_state(self) -> None:
"""Alias: publish store to router (no direct BRIDGES mutation)."""
self._export_store_to_router()

@ -40,9 +40,9 @@ logger = logging.getLogger(__name__)
# Legacy playback.py: sleep(2) before playback, sleep(0.06) between packets.
_PLAYBACK_DELAY_S = 2.0
_PACKET_INTERVAL_S = 0.06
# Match bridge stream_trimmer_loop RX idle (bridge_use_cases / legacy bridge_master).
# Match bridge stream_trimmer_loop RX idle (routing_use_cases / legacy bridge_master).
_RECORD_IDLE_S = 5.0
# HBP ingress source timeout (bridge_master.py / bridge_use_cases dmrd_received ~2183).
# HBP ingress source timeout (bridge_master.py / routing_use_cases dmrd_received ~2183).
_SOURCE_MAX_S = 180.0

@ -123,7 +123,7 @@ class ReportMqttPublisher(ABC):
self,
wire: ReportWireEncoder,
get_systems: Any,
get_bridges: Any,
routing_table_for_report: Any,
) -> None:
"""Connect to broker and publish bootstrap snapshots (hello + full topology + routing)."""
...
@ -153,7 +153,7 @@ class ReportSender(ABC):
...
@abstractmethod
def set_bridges(self, bridges: dict[str, Any]) -> None:
def set_routing_table(self, bridges: dict[str, Any]) -> None:
"""Update cached BRIDGES snapshot used by the wire encoder."""
...
@ -163,38 +163,18 @@ class ReportSender(ABC):
...
@abstractmethod
def send_bridge(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
def send_routing_table(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
"""Send BRIDGE_SND (pickle bridges) or routing_table / delta JSON."""
...
@abstractmethod
def send_bridge_event(self, event: str) -> None:
def send_routing_event(self, event: str) -> None:
"""Send BRDG_EVENT (opcode + event string)."""
...
class BridgeRouter(ABC):
"""Query and update BRIDGES (conference bridge state). Used by rule_timer, make_single_bridge, etc."""
@abstractmethod
def get_bridges(self) -> dict[str, list[dict[str, Any]]]:
"""Return current BRIDGES dict (key = TGID or #reflector)."""
...
@abstractmethod
def set_bridges(self, bridges: dict[str, list[dict[str, Any]]]) -> None:
"""Replace BRIDGES (e.g. after rule_timer or make_single_bridge)."""
...
@abstractmethod
def rebuild_source_index(self) -> None:
"""Rebuild index of ACTIVE source rows by (system, TS, dst_tgid)."""
...
@abstractmethod
def bridge_tables_with_active_source(self, system: str, ts: int, dst_tgid: int) -> list[str]:
"""Return bridge table names with matching ACTIVE source (legacy full-scan parity)."""
...
class AclRouter(ABC):
"""ACL range checks for registration and voice ingress (legacy acl_check)."""
@abstractmethod
def acl_check(self, id_bytes_or_int: bytes | int, acl: tuple[bool, list[tuple[int, int]]]) -> bool:

@ -11,7 +11,7 @@ from __future__ import annotations
import copy
from typing import Any
from adn_server.application.bridge.helpers import peer_should_receive_group_voice
from adn_server.application.routing.helpers import 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

@ -6,7 +6,7 @@ import re
import time
from typing import Any
from adn_server.application.bridge.helpers import export_peer_ua_sessions
from adn_server.application.routing.helpers import export_peer_ua_sessions
from adn_server.domain import int_id
REPORT_PROTOCOL = 2
@ -344,7 +344,7 @@ def build_routing_table(bridges: dict[str, Any], *, seq: int, ts: float | None =
"""Routing table snapshot from BRIDGES."""
epoch = time.time() if ts is None else ts
routes: list[dict[str, Any]] = []
for bridge_key, legs in bridges.items():
for relay_table_key, legs in bridges.items():
if not isinstance(legs, list):
continue
leg_rows: list[dict[str, Any]] = []
@ -365,7 +365,7 @@ def build_routing_table(bridges: dict[str, Any], *, seq: int, ts: float | None =
if timer is not None:
row["timer_expires_at"] = float(timer)
leg_rows.append(row)
routes.append({"bridge_key": str(bridge_key), "legs": leg_rows})
routes.append({"relay_table_key": str(relay_table_key), "legs": leg_rows})
return {"type": "routing_table", "seq": int(seq), "ts": float(epoch), "routes": routes}
@ -425,10 +425,10 @@ def routing_table_delta(
"""Build a delta message when only some routes changed; ``None`` if unchanged."""
if previous is None:
return None
prev_routes = {r["bridge_key"]: r for r in previous.get("routes", [])}
prev_routes = {r["relay_table_key"]: r for r in previous.get("routes", [])}
changed: list[dict[str, Any]] = []
for route in current.get("routes", []):
key = route["bridge_key"]
key = route["relay_table_key"]
if prev_routes.get(key) != route:
changed.append(route)
if not changed:

@ -51,7 +51,7 @@ class BoundedReportQueue:
sent = 0
budget = self.max_drain_per_tick
while self._events and budget > 0:
sender.send_bridge_event(self._events.popleft())
sender.send_routing_event(self._events.popleft())
sent += 1
budget -= 1
if self._pending_config is not None:
@ -63,8 +63,8 @@ class BoundedReportQueue:
if self._pending_bridge is not None:
bridges, incremental = self._pending_bridge
self._pending_bridge = None
sender.set_bridges(bridges)
sender.send_bridge(bridges, incremental=incremental)
sender.set_routing_table(bridges)
sender.send_routing_table(bridges, incremental=incremental)
sent += 1
if self.dropped_events and sent:
logger.debug("(REPORT) queue drained %s item(s); dropped_events=%s", sent, self.dropped_events)
@ -81,18 +81,18 @@ class QueuedReportSender(ReportSender):
def set_systems(self, systems: dict[str, Any]) -> None:
self._inner.set_systems(systems)
def set_bridges(self, bridges: dict[str, Any]) -> None:
self._inner.set_bridges(bridges)
def set_routing_table(self, bridges: dict[str, Any]) -> None:
self._inner.set_routing_table(bridges)
def send_config(self, systems: dict[str, Any], *, incremental: bool = False) -> None:
self._inner.set_systems(systems)
self._queue.enqueue_config(systems, incremental=incremental)
def send_bridge(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
self._inner.set_bridges(bridges)
def send_routing_table(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
self._inner.set_routing_table(bridges)
self._queue.enqueue_bridge(bridges, incremental=incremental)
def send_bridge_event(self, event: str) -> None:
def send_routing_event(self, event: str) -> None:
self._queue.enqueue_event(event)
@property

@ -45,13 +45,13 @@ class ReportingUseCases:
"""Send CONFIG_SND or v2 topology to all report clients."""
self._sender.send_config(systems, incremental=incremental)
def send_bridge(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
def send_routing_table(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
"""Send BRIDGE_SND or v2 routing_table to all report clients."""
self._sender.send_bridge(bridges, incremental=incremental)
self._sender.send_routing_table(bridges, incremental=incremental)
def send_bridge_event(self, event: str) -> None:
def send_routing_event(self, event: str) -> None:
"""Send BRDG_EVENT to all report clients."""
self._sender.send_bridge_event(event)
self._sender.send_routing_event(event)
def ka_reporting_loop(self) -> None:
"""Legacy kaReporting (60s): check OBP keepalive status and log warnings for stale connections."""

@ -0,0 +1,2 @@
# Bridge routing submodules.
# Public API: RoutingUseCases in adn_server.application.routing_use_cases.

@ -13,7 +13,7 @@ from ...domain import HBPF_SLT_VTERM, STREAM_TO, int_id
logger = logging.getLogger(__name__)
class BridgeHbpForwardMixin:
class HbpForwardMixin:
"""routerHBP group voice ingress controls and sendDataToHBP."""
def _hbp_group_voice_ingress_controls(
@ -169,7 +169,7 @@ class BridgeHbpForwardMixin:
logger.warning("(%s) send_data_to_hbp %s failed: %s", source_system, d_system, exc)
return
logger.debug("(%s) UNIT Data Bridged to HBP System: %s DST_ID: %s", source_system, d_system, int_id(dst_id))
self._send_bridge_event(
self._send_routing_event(
"UNIT DATA,DATA,TX,{},{},{},{},{},{}".format(
d_system, int_id(stream_id), int_id(peer_id), int_id(rf_src), 1, int_id(dst_id),
)

@ -109,12 +109,12 @@ def resolve_voice_peer_id(
report_peer_id_for_hbp_target = resolve_voice_peer_id
def is_special_tg(bridge_key: str) -> bool:
def is_special_tg(relay_table_key: str) -> bool:
"""True if bridge is special TGID 9990-9999 (excluded from infinite timer)."""
if bridge_key and bridge_key[0:1] == "#":
if relay_table_key and relay_table_key[0:1] == "#":
return False
try:
return 9990 <= int(bridge_key) <= 9999
return 9990 <= int(relay_table_key) <= 9999
except ValueError:
return False
@ -139,8 +139,16 @@ def _system_has_active_bridge_leg(
system: str,
slot: int,
tgid: int,
*,
subscription_store: Any | None = None,
) -> bool:
"""True when BRIDGES has an ACTIVE leg for ``system`` on ``(slot, tgid)``."""
"""True when the store (or legacy BRIDGES export) has an ACTIVE leg for ``(system, slot, tgid)``."""
if subscription_store is not None and system:
from adn_server.application.subscription.subscription_queries import (
system_has_active_leg_in_store,
)
return system_has_active_leg_in_store(subscription_store, system, slot, tgid)
if not bridges or not system:
return False
legs = bridges.get(str(tgid))
@ -492,6 +500,7 @@ def peer_should_receive_group_voice(
peer_id: bytes | None = None,
system: str | None = None,
bridges: dict[str, Any] | None = None,
subscription_store: Any | None = None,
connected_count: int = 1,
sys_cfg: dict[str, Any] | None = None,
now: float | None = None,
@ -520,7 +529,7 @@ def peer_should_receive_group_voice(
if peer_owns_multi_dynamic_ua(peer, slot, tgid, sys_cfg, peer_id=peer_id):
return True
if connected_count == 1 and system and _system_has_active_bridge_leg(
bridges, system, slot, tgid
bridges, system, slot, tgid, subscription_store=subscription_store
):
return True
return False

@ -19,7 +19,7 @@ from .helpers import EMB_LC_SLICE
logger = logging.getLogger(__name__)
class BridgeLcTaMixin:
class LcTaMixin:
"""Talker Alias DMRA relay and embedded LC overlay on forward legs."""
def _get_stream_dmra_blocks(self, source_system: str, stream_id: bytes) -> dict[int, bytes] | None:

@ -18,13 +18,13 @@ from ...domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, int_id
logger = logging.getLogger(__name__)
class BridgeObpForwardMixin:
class ObpForwardMixin:
"""routerOBP group voice, stream tracking, sendDataToOBP."""
def _ensure_obp_source_for_tg(
self,
system_name: str,
bridge_key: str,
relay_table_key: str,
dst_id_b: bytes,
dst_int: int,
) -> None:
@ -33,7 +33,7 @@ class BridgeObpForwardMixin:
remove_bridge_system / BRIDGERESET sets all rows for a system to ACTIVE False. Local MASTER
traffic still matches MASTER source rows; inbound OBP traffic needs these OBP rows re-enabled
or added (e.g. new OBP in config after bridge was built).
Same TG range as make_single_bridge OBP entries.
Same TG range as ensure_dynamic_relay OBP entries.
"""
systems_cfg = self._config.get("SYSTEMS", {})
if systems_cfg.get(system_name, {}).get("MODE") != "OPENBRIDGE":
@ -46,12 +46,11 @@ class BridgeObpForwardMixin:
ensure_obp_source_for_tg_store(
self._subscription_store,
system_name,
bridge_key,
relay_table_key,
dst_id_b,
dst_int,
time.time(),
)
self._export_store_to_router()
def _obp_wire_stream_dict(self, src_proto: Any, stream_id: bytes, st: dict[str, Any]) -> None:
"""Legacy routerOBP.STATUS is a single flat dict keyed by stream_id (bridge_master.py:1911).
Write only there; trimmer iterates the same dict (parity)."""
@ -160,7 +159,7 @@ class BridgeObpForwardMixin:
)
# INGRESS: debug-only (all OBP legs); monitor logs it but does not update OPENBRIDGES chips until START.
if _do_report:
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,INGRESS,RX,{},{},{},{},{},{}".format(
system_name, int_id(stream_id), int_id(peer_id), int_id(rf_src), slot, int_id(dst_id)
)
@ -252,7 +251,7 @@ class BridgeObpForwardMixin:
)
st["LOOPLOG"] = True
if _do_report:
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format(
system_name,
int_id(stream_id),
@ -348,7 +347,7 @@ class BridgeObpForwardMixin:
# Canonical START,RX for monitor CTABLE (OpenBridge / Linked / Active QSO) after loop win; INGRESS was debug-only.
if _do_report:
if not st.get("_monitor_canonical_rx"):
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,START,RX,{},{},{},{},{},{}".format(
system_name, int_id(stream_id), int_id(peer_id), int_id(rf_src), slot, int_id(dst_id)
)
@ -362,9 +361,12 @@ class BridgeObpForwardMixin:
if self._config.get("GLOBAL", {}).get("GEN_STAT_BRIDGES"):
_di = int_id(dst_id)
_bk = str(_di)
if _di >= 5 and _di != 9 and _bk not in self._router.get_bridges():
from ..subscription.subscription_queries import store_has_table
if _di >= 5 and _di != 9 and not store_has_table(self._subscription_store, _bk):
logger.debug("(%s) Bridge for STAT TG %s does not exist. Creating", system_name, _di)
self.make_stat_bridge(dst_id)
self.ensure_stat_relay(dst_id)
self.apply_static_tg_to_bridge(_di)
return True
def _send_data_to_obp(
@ -414,7 +416,7 @@ class BridgeObpForwardMixin:
logger.warning("(%s) send_data_to_obp %s failed: %s", source_system, target, exc)
return
logger.debug("(%s) UNIT Data Bridged to OBP System: %s DST_ID: %s", source_system, target, int_id(dst_id))
self._send_bridge_event(
self._send_routing_event(
"UNIT DATA,DATA,TX,{},{},{},{},{},{}".format(
target, int_id(stream_id), int_id(peer_id), int_id(rf_src), 1, int_id(dst_id),
)

@ -0,0 +1,33 @@
# ADN DMR Peer Server - subscription store authority
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
"""``SubscriptionStore`` is the sole runtime routing authority; monitor uses export shim only."""
from __future__ import annotations
from typing import Any
from ..ports import SubscriptionStore
from ..subscription.routing_table_legacy_view import RoutingTableLegacyView
class StoreAuthorityMixin:
"""Store authority helpers for ``RoutingUseCases``."""
_subscription_store: SubscriptionStore
_config: dict[str, Any]
_routing_table_legacy_view: RoutingTableLegacyView | None
def _routing_table_for_report(self) -> dict[str, list[dict[str, Any]]]:
"""BRIDGES snapshot for monitor/report only (export shim, not routing authority)."""
view = getattr(self, "_routing_table_legacy_view", None)
if view is None:
view = RoutingTableLegacyView(self._subscription_store)
self._routing_table_legacy_view = view
return view.generate()
def _sync_subscription_store(self) -> None:
"""No-op: runtime state lives only in ``SubscriptionStore`` (D-08)."""
def _finalize_routing_state(self) -> None:
"""No-op kept for harness/tests; routing reads ``SubscriptionStore`` only."""

@ -15,25 +15,25 @@ from ...domain import bytes_3, bytes_4, int_id
logger = logging.getLogger(__name__)
class BridgeTableMixin:
"""make_single_bridge, stat/static TG, OPTIONS refresh (RPTO / startup / dmrd)."""
class SubscriptionTableMixin:
"""ensure_dynamic_relay, stat/static TG, OPTIONS refresh (RPTO / startup / dmrd)."""
def make_single_bridge(
def ensure_dynamic_relay(
self,
_tgid: bytes | int,
_sourcesystem: str,
_slot: int,
_tmout: float,
) -> None:
"""Legacy make_single_bridge: create bridge for TG with entries per MASTER (source ACTIVE on its slot) and OBP."""
"""Legacy ensure_dynamic_relay: create bridge for TG with entries per MASTER (source ACTIVE on its slot) and OBP."""
tgid_int = int_id(_tgid) if not isinstance(_tgid, int) else _tgid
_tgid_s = str(tgid_int)
_tgid_b = _tgid if isinstance(_tgid, bytes) and len(_tgid) >= 3 else bytes_3(tgid_int)
if _tgid_s in ("9990", "9991", "9992", "9993", "9994", "9995", "9996", "9997", "9998", "9999"):
_tmout = 1.0 / 6.0
from ..subscription.bridge_table_ops import make_single_bridge_store
from ..subscription.subscription_table_ops import ensure_dynamic_relay_store
make_single_bridge_store(
ensure_dynamic_relay_store(
self._subscription_store,
tgid_int,
_sourcesystem,
@ -42,7 +42,6 @@ class BridgeTableMixin:
self._config.get("SYSTEMS", {}),
time.time(),
)
self._export_store_to_router()
def make_single_reflector(self, _tgid: bytes | int, _tmout: float, _sourcesystem: str) -> None:
"""Legacy make_single_reflector: create reflector bridge #tgid with MASTERs and OBP."""
@ -51,7 +50,7 @@ class BridgeTableMixin:
_tgid_b = _tgid if isinstance(_tgid, bytes) and len(_tgid) >= 3 else bytes_3(int(_tgid_s))
if _tgid_s in ("9990", "9991", "9992", "9993", "9994", "9995", "9996", "9997", "9998", "9999"):
_tmout = 1.0 / 6.0
from ..subscription.bridge_table_ops import make_single_reflector_store
from ..subscription.subscription_table_ops import make_single_reflector_store
make_single_reflector_store(
self._subscription_store,
@ -64,7 +63,7 @@ class BridgeTableMixin:
def make_default_reflector(self, reflector: int, _tmout: float, system: str) -> None:
"""Legacy make_default_reflector: ensure #reflector bridge exists and set system TS2 to ACTIVE/OFF."""
from ..subscription.bridge_table_ops import make_default_reflector_store
from ..subscription.subscription_table_ops import make_default_reflector_store
make_default_reflector_store(
self._subscription_store,
@ -77,7 +76,7 @@ class BridgeTableMixin:
def make_static_tg(self, tg: int, ts: int, _tmout: float, system: str) -> None:
"""Legacy make_static_tg: ensure bridge for tg exists and set system/ts to ACTIVE/OFF."""
from ..subscription.bridge_table_ops import make_static_tg_store
from ..subscription.subscription_table_ops import make_static_tg_store
single_mode = bool(
self._config.get("SYSTEMS", {}).get(system, {}).get("SINGLE_MODE", False)
@ -92,11 +91,9 @@ class BridgeTableMixin:
time.time(),
single_mode=single_mode,
)
self._export_store_to_router()
def reset_static_tg(self, tg: int, ts: int, _tmout: float, system: str) -> None:
"""Legacy reset_static_tg: set system/ts entry to ACTIVE False, TO_TYPE ON."""
from ..subscription.bridge_table_ops import reset_static_tg_store
from ..subscription.subscription_table_ops import reset_static_tg_store
reset_static_tg_store(
self._subscription_store,
@ -109,7 +106,7 @@ class BridgeTableMixin:
def reset_all_reflector_system(self, _tmout: float, system: str) -> None:
"""Legacy reset_all_reflector_system: set system's TS2 entry to inactive in every # bridge."""
from ..subscription.bridge_table_ops import reset_all_reflector_system_store
from ..subscription.subscription_table_ops import reset_all_reflector_system_store
reset_all_reflector_system_store(
self._subscription_store,
@ -120,34 +117,28 @@ class BridgeTableMixin:
def remove_bridge_system(self, system: str) -> None:
"""Deactivate all legs for one system (legacy remove_bridge_system)."""
from ..subscription.bridge_reset_ops import deactivate_system_legs_store
from ..subscription.subscription_reset_ops import deactivate_system_legs_store
deactivate_system_legs_store(self._subscription_store, system, time.time())
self._export_store_to_router()
def make_stat_bridge(self, _tgid: bytes) -> None:
"""Legacy make_stat_bridge: on-the-fly relay bridges for OBP traffic when GEN_STAT_BRIDGES is True."""
def ensure_stat_relay(self, _tgid: bytes) -> None:
"""Legacy ensure_stat_relay: on-the-fly relay bridges for OBP traffic when GEN_STAT_BRIDGES is True."""
_tgid_s = str(int_id(_tgid))
from ..subscription.bridge_table_ops import make_stat_bridge_store
from ..subscription.subscription_table_ops import ensure_stat_relay_store
make_stat_bridge_store(
ensure_stat_relay_store(
self._subscription_store,
_tgid,
self._config.get("SYSTEMS", {}),
time.time(),
)
self._export_store_to_router()
def deactivate_all_dynamic_bridges(self, system_name: str) -> None:
"""Legacy deactivate_all_dynamic_bridges: deactivate all non-STAT, non-reflector bridges for a system (TG 4000)."""
from ..subscription.bridge_table_ops import deactivate_all_dynamic_bridges_store
deactivate_all_dynamic_bridges_store(self._subscription_store, system_name)
self._export_store_to_router()
def deactivate_all_dynamic_relays(self, system_name: str) -> None:
"""Legacy deactivate_all_dynamic_relays: deactivate all non-STAT, non-reflector bridges for a system (TG 4000)."""
from ..subscription.subscription_table_ops import deactivate_all_dynamic_relays_store
deactivate_all_dynamic_relays_store(self._subscription_store, system_name)
def _readd_system_after_ua_timer_change(self, system: str, _tmout: float) -> None:
"""After remove_bridge_system, re-add system to bridges that no longer have ts1/ts2 (legacy 1624-1639)."""
from ..subscription.bridge_table_ops import readd_system_after_ua_timer_change_store
from ..subscription.subscription_table_ops import readd_system_after_ua_timer_change_store
readd_system_after_ua_timer_change_store(
self._subscription_store,
@ -156,7 +147,7 @@ class BridgeTableMixin:
time.time(),
)
def apply_startup_bridges(self) -> None:
def apply_startup_subscriptions(self) -> None:
"""Legacy startup: set default reflectors and static TGs for each MASTER system."""
prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999)
logger.debug("(ROUTER) Setting default reflectors")
@ -489,14 +480,27 @@ class BridgeTableMixin:
merged = self._merged_static_tg_lists_for_master(system_name)
_fp = self._options_static_apply_fingerprint(system_name)
if sys_cfg.get("_options_static_apply_fp") == _fp:
if peer_options is not None:
if merged is not None:
ts1_nums, ts2_nums = merged
yaml_tmout = self._yaml_default_ua_timer(sys_cfg)
ts1_timers, ts2_timers = self._static_tg_timer_maps_for_master(
system_name, peer_options=peer_options
system_name,
peer_options=peer_options if peer_options is not None else None,
)
for tg, tmout in ts1_timers.items():
self.make_static_tg(tg, 1, tmout, system_name)
for tg, tmout in ts2_timers.items():
self.make_static_tg(tg, 2, tmout, system_name)
if peer_options is not None:
refresh_ts1 = set(ts1_timers)
refresh_ts2 = set(ts2_timers)
else:
refresh_ts1 = set(ts1_nums)
refresh_ts2 = set(ts2_nums)
for tg in refresh_ts1:
if tg in prohibited_tgs:
continue
self.make_static_tg(tg, 1, ts1_timers.get(tg, yaml_tmout), system_name)
for tg in refresh_ts2:
if tg in prohibited_tgs:
continue
self.make_static_tg(tg, 2, ts2_timers.get(tg, yaml_tmout), system_name)
self._restore_prohibited_static_bridge_legs(system_name)
self._sync_subscription_store()
return

@ -14,7 +14,7 @@ from ...domain import HBPF_SLT_VTERM, int_id
logger = logging.getLogger(__name__)
class BridgeTimerMixin:
class RoutingTimerMixin:
"""rule_timer, stream_trimmer, bridge_reset, stat_trimmer, bridge_debug loops."""
def rule_timer_loop(self) -> None:
@ -25,20 +25,18 @@ class BridgeTimerMixin:
self._subscription_store,
self._config.get("SYSTEMS", {}),
time.time(),
on_bridge_deactivated=self._on_bridge_deactivated,
on_relay_deactivated=self._on_relay_deactivated,
)
self._export_store_to_router()
def bridge_debug_loop(self) -> None:
def subscription_debug_loop(self) -> None:
"""Legacy bridgeDebug (bridge_master.py 487-543): remove invalid bridges, fix >1 active dial per MASTER."""
logger.debug("(BRIDGEDEBUG) Running bridge debug")
from ..subscription.bridge_debug_ops import apply_bridge_debug_store
from ..subscription.subscription_debug_ops import apply_subscription_debug_store
apply_bridge_debug_store(
apply_subscription_debug_store(
self._subscription_store,
self._config.get("SYSTEMS", {}),
time.time(),
)
self._export_store_to_router()
def apply_in_band_signalling(
self, system_name: str, slot: int, dst_id: bytes, pkt_time: float
) -> None:
@ -57,8 +55,7 @@ class BridgeTimerMixin:
pkt_time,
self._config.get("SYSTEMS", {}),
)
self._export_store_to_router()
self._send_bridge_snapshot(incremental=True)
self._send_routing_table_snapshot(incremental=True)
def _obp_emit_end_tx_forward_leg(
self,
tgt_name: str,
@ -68,7 +65,7 @@ class BridgeTimerMixin:
) -> bool:
"""Emit GROUP VOICE,END,TX for one to_target OBP forward leg (H_LC in STATUS).
Same CSV shape as legacy bridge_master.py send_bridgeEvent on VTERM (~2039, ~2121).
Same CSV shape as legacy bridge_master.py send_routing_tableEvent on VTERM (~2039, ~2121).
Safe for legacy and v2 monitors (OPENBRIDGE STREAMS chip clear on END,TX).
"""
if not isinstance(tst, dict) or "H_LC" not in tst:
@ -83,7 +80,7 @@ class BridgeTimerMixin:
tgid_b = tst.get("TGID", b"\x00\x00\x00")
start = tst.get("START", now)
duration = max(0.0, now - start)
if not self._send_bridge_event(
if not self._send_routing_event(
"GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format(
tgt_name,
int_id(stream_id),
@ -163,7 +160,7 @@ class BridgeTimerMixin:
continue
trx = "TX" if "H_LC" in st else "RX"
start = st.get("START", now)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,{},{},{},{},{},{},{},{:.2f}".format(
trx, system_name, int_id(stream_id),
int_id(st.get("RX_PEER", b"\x00\x00\x00\x00")),
@ -187,7 +184,7 @@ class BridgeTimerMixin:
sid = slot_st.get(sid_key, b"\x00")
if sid in (b"\x00", b"") or slot_st.get(type_key) == HBPF_SLT_VTERM:
continue
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,{},{},{},{},{},{},{},{:.2f}".format(
trx, system_name, int_id(sid),
int_id(slot_st.get(peer_key, b"\x00\x00\x00\x00")),
@ -238,7 +235,7 @@ class BridgeTimerMixin:
tgid = st.get("TGID", b"\x00\x00\x00")
start = st.get("START", now)
duration = max(0.0, last - start)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}".format(
system_name, int_id(stream_id), int_id(peer), int_id(rfs), 1, int_id(tgid), duration
)
@ -269,7 +266,7 @@ class BridgeTimerMixin:
system_name, int_id(_slot.get("RX_STREAM_ID", b"")), int_id(_slot.get("RX_RFS", b"")),
int_id(_slot.get("RX_TGID", b"")), slot, _slot.get("RX_TIME", 0) - _slot.get("RX_START", 0),
)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}".format(
system_name, int_id(_slot.get("RX_STREAM_ID", b"")), int_id(_slot.get("RX_PEER", b"")),
int_id(_slot.get("RX_RFS", b"")), slot, int_id(_slot.get("RX_TGID", b"")),
@ -285,7 +282,7 @@ class BridgeTimerMixin:
system_name, int_id(_slot.get("TX_STREAM_ID", b"")), int_id(_slot.get("TX_RFS", b"")),
int_id(_slot.get("TX_TGID", b"")), slot, _slot.get("TX_TIME", 0) - _slot.get("TX_START", 0),
)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format(
system_name, int_id(_slot.get("TX_STREAM_ID", b"")), int_id(_slot.get("TX_PEER", b"")),
int_id(_slot.get("TX_RFS", b"")), slot, int_id(_slot.get("TX_TGID", b"")),
@ -309,10 +306,10 @@ class BridgeTimerMixin:
if hasattr(protocol, "trim_dmra_streams"):
protocol.trim_dmra_streams()
def bridge_reset_loop(self) -> None:
def subscription_reset_loop(self) -> None:
"""Bridge reset iteration (legacy bridge_reset, 6s). Clear _reset and remove_bridge_system."""
systems_cfg = self._config.get("SYSTEMS", {})
from ..subscription.bridge_reset_ops import (
from ..subscription.subscription_reset_ops import (
deactivate_system_legs_store,
restore_prohibited_static_legs_store,
)
@ -335,13 +332,12 @@ class BridgeTimerMixin:
)
sys_cfg["_reset"] = False
sys_cfg["_resetlog"] = False
self._export_store_to_router()
def _restore_prohibited_static_bridge_legs(self, system_name: str) -> None:
"""After BRIDGERESET / peer RPTO: restore static TGs in prohibited_tgs (parity with _make_echo_bridges)."""
"""After BRIDGERESET / peer RPTO: restore static TGs in prohibited_tgs (parity with _seed_echo_routing_table)."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
if sys_cfg.get("MODE") != "MASTER" or not sys_cfg.get("ENABLED", True):
return
from ..subscription.bridge_reset_ops import restore_prohibited_static_legs_store
from ..subscription.subscription_reset_ops import restore_prohibited_static_legs_store
restore_prohibited_static_legs_store(
self._subscription_store,
system_name,
@ -355,4 +351,3 @@ class BridgeTimerMixin:
from ..subscription.stat_trimmer_ops import apply_stat_trimmer_store
apply_stat_trimmer_store(self._subscription_store)
self._export_store_to_router()

@ -49,7 +49,7 @@ class VoiceSubscriptionMixin:
stream_id=stream_id,
)
def _voice_bridge_tables_with_active_source(
def _voice_relay_tables_with_active_source(
self,
system_name: str,
bridge_match_slot: int,
@ -87,7 +87,7 @@ class VoiceSubscriptionMixin:
) -> tuple[tuple[str, ...], tuple[ForwardLeg, ...]]:
"""Return bridge tables and resolved forward legs from the subscription store."""
router = self._subscription_router_instance()
tables = router.bridge_tables_with_active_source(system_name, bridge_match_slot, dst_int)
tables = router.relay_tables_with_active_source(system_name, bridge_match_slot, dst_int)
if not ingress_required:
return tables, ()
ingress = self._build_dmrd_voice_ingress(

@ -1,4 +1,4 @@
# ADN DMR Peer Server - bridge use cases
# ADN DMR Peer Server - routing use cases
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
@ -22,9 +22,9 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Conference bridge facade: dmrd_received and unit/private paths.
"""Routing facade: dmrd_received and unit/private paths.
Routing helpers live in application/bridge/ (timers, OBP/HBP forward, LC/TA, BRIDGES table).
Helpers live in application/routing/ (timers, OBP/HBP forward, LC/TA, subscription table).
"""
from __future__ import annotations
@ -40,40 +40,40 @@ 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 .ports import BridgeRouter, DmrEmbeddedLcEncoder, SubscriptionStore, TalkerAliasEmblcEncoder
from .ports import AclRouter, DmrEmbeddedLcEncoder, SubscriptionStore, TalkerAliasEmblcEncoder
from .talker_alias_use_cases import TalkerAliasUseCases
from .bridge.helpers import obp_target_bcsq_quenches_stream, resolve_voice_peer_id
from .bridge.timers import BridgeTimerMixin
from .bridge.obp_forward import BridgeObpForwardMixin
from .bridge.hbp_forward import BridgeHbpForwardMixin
from .bridge.lc_ta import BridgeLcTaMixin
from .bridge.bridge_table import BridgeTableMixin
from .bridge.store_authority_mixin import StoreAuthorityMixin
from .bridge.voice_subscription import VoiceSubscriptionMixin
from .routing.helpers import obp_target_bcsq_quenches_stream, resolve_voice_peer_id
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.store_authority_mixin import StoreAuthorityMixin
from .routing.voice_subscription import VoiceSubscriptionMixin
logger = logging.getLogger(__name__)
class BridgeUseCases(
BridgeTimerMixin,
BridgeObpForwardMixin,
BridgeHbpForwardMixin,
BridgeLcTaMixin,
BridgeTableMixin,
class RoutingUseCases(
RoutingTimerMixin,
ObpForwardMixin,
HbpForwardMixin,
LcTaMixin,
SubscriptionTableMixin,
StoreAuthorityMixin,
VoiceSubscriptionMixin,
):
"""Use cases for conference bridge state (BRIDGES)."""
"""Use cases for subscription-based voice routing."""
def __init__(
self,
bridge_router: BridgeRouter,
acl_router: AclRouter,
config: dict[str, Any],
subscription_store: SubscriptionStore,
send_to_system: Any = None,
get_protocols: Any = None,
reporting: ReportingUseCases | None = None,
on_bridge_deactivated: Any = None,
on_relay_deactivated: Any = None,
send_bcsq: Any = None,
send_dmra_to_system: Any = None,
get_dmra_blocks: Any = None,
@ -81,15 +81,15 @@ class BridgeUseCases(
encode_emblc: DmrEmbeddedLcEncoder | None = None,
ta_emblc_encoder: TalkerAliasEmblcEncoder | None = None,
) -> None:
self._router = bridge_router
self._acl_router = acl_router
self._config = config
self._subscription_store = subscription_store
self._subscription_router = None
self._bridges_legacy_view = None
self._routing_table_legacy_view = None
self._send_to_system = send_to_system # (system_name, packet, **kwargs) -> None
self._get_protocols = get_protocols # () -> dict[str, protocol]
self._reporting = reporting
self._on_bridge_deactivated = on_bridge_deactivated # (system_name: str) -> None; legacy disconnectedVoice
self._on_relay_deactivated = on_relay_deactivated # (system_name: str) -> None; legacy disconnectedVoice
self._send_bcsq = send_bcsq # (system_name, tgid, stream_id) -> None; legacy OBP send_bcsq from router
self._send_dmra_to_system = send_dmra_to_system
self._get_dmra_blocks = get_dmra_blocks
@ -103,7 +103,7 @@ class BridgeUseCases(
# Passthrough DMRA/embed relay already applied for this source stream.
self._passthrough_relayed: set[tuple[str, bytes]] = set()
def _send_bridge_event(self, event: str | bytes) -> bool:
def _send_routing_event(self, event: str | bytes) -> bool:
"""Send BRDG_EVENT via ReportingUseCases when REPORT is enabled."""
if not self._config.get("REPORTS", {}).get("REPORT", True):
return False
@ -112,33 +112,29 @@ class BridgeUseCases(
if isinstance(event, bytes):
event = event.decode("utf-8", "ignore")
try:
self._reporting.send_bridge_event(event)
self._reporting.send_routing_event(event)
return True
except Exception:
return False
def _send_bridge_snapshot(self, *, incremental: bool = True) -> None:
def _send_routing_table_snapshot(self, *, incremental: bool = True) -> None:
"""Push BRIDGES / routing_table to report clients (monitor SINGLE_TS chips)."""
if not self._config.get("REPORTS", {}).get("REPORT", True):
return
if not self._reporting:
return
try:
self._reporting.send_bridge(self._bridges_for_report(), incremental=incremental)
self._reporting.send_routing_table(self._routing_table_for_report(), incremental=incremental)
except Exception:
pass
def _sync_subscription_store(self) -> None:
"""Export subscription store to BRIDGES shim after OPTIONS/static batch mutations."""
self._export_store_to_router()
def get_bridges(self) -> dict[str, list[dict[str, Any]]]:
"""Return current BRIDGES (export shim when store authority is enabled)."""
return self._bridges_for_report()
def routing_table_for_report(self) -> dict[str, list[dict[str, Any]]]:
"""Return BRIDGES export for monitor/report only (routing uses ``SubscriptionStore``)."""
return self._routing_table_for_report()
def acl_check(self, id_val: bytes | int, acl: tuple[bool, list[tuple[int, int]]]) -> bool:
"""Check ID against ACL. Legacy acl_check."""
return self._router.acl_check(id_val, acl)
return self._acl_router.acl_check(id_val, acl)
def dmrd_received(
@ -206,14 +202,15 @@ class BridgeUseCases(
# Arm ON in-band rules on VHEAD (parrot 9990 and UA bridges); VTERM handled in udp_hbp too.
if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD:
self.apply_in_band_signalling(system_name, slot, dst_id, pkt_time)
bridge_key = str(int_id(dst_id))
bridges = self._router.get_bridges()
from .subscription.subscription_queries import store_has_table
relay_table_key = str(int_id(dst_id))
dst_int = int_id(dst_id)
# Legacy bridge_master to_target: OpenBridge clears TS bit — "all OpenBridge streams are
# effectively on TS1". DMRD v1 rejects slot != 1; DMRE v5 can still set slot 2 from bits.
# BRIDGES entries for OBP use TS:1 (make_single_bridge / make_stat_bridge). Match that.
# BRIDGES entries for OBP use TS:1 (ensure_dynamic_relay / ensure_stat_relay). Match that.
bridge_match_slot = 1 if source_is_obp else slot
if bridge_key not in bridges:
if not store_has_table(self._subscription_store, relay_table_key):
if dst_int < 5 or dst_int == 9 or dst_int == 4000 or dst_int == 5000:
logger.debug(
"(ROUTER) No bridge for TG %s (excluded TGID), not creating",
@ -221,26 +218,23 @@ class BridgeUseCases(
)
elif source_is_obp and self._config.get("GLOBAL", {}).get("GEN_STAT_BRIDGES"):
logger.debug("(%s) Bridge for STAT TG %s does not exist. Creating", system_name, dst_int)
self.make_stat_bridge(dst_id)
self.ensure_stat_relay(dst_id)
self.apply_static_tg_to_bridge(dst_int)
bridges = self._router.get_bridges()
else:
tmout = self._ua_timer_minutes_for_peer(system_name, peer_id)
logger.info(
"(%s) Bridge for TG %s does not exist. Creating as User Activated. Timeout %s",
system_name, dst_int, tmout,
)
self.make_single_bridge(dst_id, system_name, slot, float(tmout))
self.ensure_dynamic_relay(dst_id, system_name, slot, float(tmout))
self.apply_static_tg_to_bridge(dst_int)
self._send_bridge_snapshot(incremental=True)
bridges = self._router.get_bridges()
self._send_routing_table_snapshot(incremental=True)
# Legacy bridge_master routerOBP ~2413-2418: scan every BRIDGES[_bridge] table; forward only within
# a table that contains a matching ACTIVE source row (not a flat merge of TG + #TG only).
dst_id_b = dst_id if isinstance(dst_id, bytes) and len(dst_id) >= 3 else bytes_3(dst_int)
if source_is_obp:
self._ensure_obp_source_for_tg(system_name, bridge_key, dst_id_b, dst_int)
bridges = self._router.get_bridges()
self._ensure_obp_source_for_tg(system_name, relay_table_key, dst_id_b, dst_int)
if source_is_obp and call_type in ("group", "vcsbk"):
if not self._obp_group_voice_router_obp(
system_name,
@ -257,24 +251,27 @@ class BridgeUseCases(
obp_hops if obp_use_parsed else b"",
):
return
# SINGLE_MODE in-band VTERM on another TG (e.g. 9990 parrot) deactivates static OFF
# legs; re-arm OPTIONS static destinations before forward (inject-only merged lists).
if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD:
self.apply_static_tg_to_bridge(dst_int)
has_source = bool(
self._voice_bridge_tables_with_active_source(system_name, bridge_match_slot, dst_int)
self._voice_relay_tables_with_active_source(system_name, bridge_match_slot, dst_int)
)
if not has_source and systems_cfg.get(system_name, {}).get("MODE") == "MASTER":
self.options_config_for_system(system_name)
bridges = self._router.get_bridges()
has_source = bool(
self._voice_bridge_tables_with_active_source(system_name, bridge_match_slot, dst_int)
self._voice_relay_tables_with_active_source(system_name, bridge_match_slot, dst_int)
)
# Do not call make_single_bridge here for 9990–9999 when BRIDGES["9990"] already exists:
# make_single_bridge replaces the whole table and only sets the source MASTER ACTIVE; every
# Do not call ensure_dynamic_relay here for 9990–9999 when BRIDGES["9990"] already exists:
# ensure_dynamic_relay replaces the whole table and only sets the source MASTER ACTIVE; every
# other system (including ECHO with TO_TYPE NONE) becomes ACTIVE False — to_target then has
# no active target for the parrot path (legacy _make_echo_bridges / make_bridges keeps ECHO
# no active target for the parrot path (legacy _seed_echo_routing_table / make_bridges keeps ECHO
# ACTIVE True; activation of the source row is via in-band ON on VTERM, bridge_master ~3465).
if not has_source:
logger.debug(
"(ROUTER) No matching source rule for TG %s from %s slot %s (ACTIVE), not forwarding",
bridge_key, system_name, bridge_match_slot,
relay_table_key, system_name, bridge_match_slot,
)
return True
@ -290,7 +287,7 @@ class BridgeUseCases(
system_name,
systems_cfg,
)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,START,RX,{},{},{},{},{},{}".format(
system_name,
int_id(stream_id),
@ -321,7 +318,7 @@ class BridgeUseCases(
system_name,
systems_cfg,
)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}".format(
system_name,
int_id(stream_id),
@ -421,7 +418,7 @@ class BridgeUseCases(
for leg in forward_legs
]
for _bridge_table_name, entry in _leg_iter:
for _relay_table_key, entry in _leg_iter:
if not systems_cfg.get(entry["SYSTEM"], {}).get("ENABLED", True):
continue
_target_system = systems_cfg.get(entry["SYSTEM"], {})
@ -482,9 +479,9 @@ class BridgeUseCases(
)
logger.debug(
"(%s) Conference Bridge: %s, Call Bridged to OBP System: %s TS: %s, TGID: %s",
system_name, _bridge_table_name, entry["SYSTEM"], entry.get("TS", 1), int_id(target_tgid),
system_name, _relay_table_key, entry["SYSTEM"], entry.get("TS", 1), int_id(target_tgid),
)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,START,TX,{},{},{},{},{},{}".format(
entry["SYSTEM"], int_id(stream_id), int_id(peer_id), int_id(rf_src), entry.get("TS", 1), int_id(target_tgid)
)
@ -521,7 +518,7 @@ class BridgeUseCases(
dmrbits = _target_status[stream_id]["T_LC"][0:98] + dmrbits[98:166] + _target_status[stream_id]["T_LC"][98:197]
self._clear_talker_alias_embed(_target_status[stream_id])
call_duration = pkt_time - _target_status[stream_id].get("START", pkt_time)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format(
entry["SYSTEM"], int_id(stream_id), int_id(peer_id), int_id(rf_src), entry.get("TS", 1), int_id(target_tgid), call_duration
)
@ -608,9 +605,9 @@ class BridgeUseCases(
)
logger.info(
"(%s) Conference Bridge: %s, Call Bridged to HBP System: %s TS: %s, TGID: %s",
system_name, _bridge_table_name, entry["SYSTEM"], entry_ts, int_id(entry_tgid_b),
system_name, _relay_table_key, entry["SYSTEM"], entry_ts, int_id(entry_tgid_b),
)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,START,TX,{},{},{},{},{},{}".format(
entry["SYSTEM"],
int_id(stream_id),
@ -638,7 +635,7 @@ class BridgeUseCases(
dmrbits = _ts_st["TX_T_LC"][0:98] + dmrbits[98:166] + _ts_st["TX_T_LC"][98:197]
call_duration = pkt_time - _ts_st.get("TX_START", pkt_time)
_end_peer = _ts_st.get("TX_PEER", peer_id)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format(
entry["SYSTEM"],
int_id(stream_id),
@ -701,7 +698,7 @@ class BridgeUseCases(
packet_rate,
loss_pct,
)
self._send_bridge_event(
self._send_routing_event(
"GROUP VOICE,END,RX,{},{},{},{},{},{},{:.2f}".format(
system_name,
int_id(stream_id),
@ -719,7 +716,7 @@ class BridgeUseCases(
if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD:
logger.info(
"(ROUTER) Bridged TG %s from %s -> %s",
bridge_key, system_name, ", ".join(forwarded),
relay_table_key, system_name, ", ".join(forwarded),
)
return True
@ -860,7 +857,7 @@ class BridgeUseCases(
_dtype_labels = {3: "UNIT CSBK", 6: "UNIT DATA HEADER", 7: "UNIT VCSBK 1/2 DATA BLOCK", 8: "UNIT VCSBK 3/4 DATA BLOCK"}
_label = _dtype_labels.get(dtype_vseq, "UNIT DATA")
self._send_bridge_event(
self._send_routing_event(
"{},DATA,RX,{},{},{},{},{},{}".format(
_label, system_name, int_id(stream_id), int_id(peer_id), int_id(rf_src), slot, _int_dst_id,
)
@ -1005,7 +1002,7 @@ class BridgeUseCases(
"(%s) *PRIVATE CALL START* STREAM ID: %s SUB: %s PEER: %s DST: %s, TS: %s, FORWARD: %s",
system_name, int_id(stream_id), int_id(rf_src), int_id(peer_id), int_id(dst_id), slot, self._pvt_targets,
)
self._send_bridge_event(
self._send_routing_event(
"PRIVATE VOICE,START,RX,{},{},{},{},{},{}".format(
system_name, int_id(stream_id), int_id(peer_id), int_id(rf_src), slot, int_id(dst_id)
)
@ -1032,7 +1029,7 @@ class BridgeUseCases(
"(%s) PRIVATE call bridged to OBP System: %s TS: %s, UNIT: %s",
system_name, _target, slot if _target_system.get("BOTH_SLOTS") else 1, int_id(dst_id),
)
self._send_bridge_event(
self._send_routing_event(
"PRIVATE VOICE,START,TX,{},{},{},{},{},{}".format(
_target, int_id(stream_id), int_id(peer_id), int_id(rf_src), slot, int_id(dst_id),
).encode("utf-8", "ignore")
@ -1069,7 +1066,7 @@ class BridgeUseCases(
ts_st["TX_RFS"] = rf_src
ts_st["TX_PEER"] = peer_id
logger.info("(%s) PRIVATE call bridged to HBP System: %s TS: %s, DST: %s", system_name, _target, slot, int_id(dst_id))
self._send_bridge_event(
self._send_routing_event(
"PRIVATE VOICE,START,TX,{},{},{},{},{},{}".format(
_target, int_id(stream_id), int_id(peer_id), int_id(rf_src), slot, int_id(dst_id),
).encode("utf-8", "ignore")
@ -1088,7 +1085,7 @@ class BridgeUseCases(
"(%s) *PRIVATE CALL END* STREAM ID: %s SUB: %s PEER: %s DST: %s, TS %s, Duration: %.2f",
system_name, int_id(stream_id), int_id(rf_src), int_id(peer_id), int_id(dst_id), slot, call_duration,
)
self._send_bridge_event(
self._send_routing_event(
"PRIVATE VOICE,END,RX,{},{},{},{},{},{},{:.2f}".format(
system_name,
int_id(stream_id),

@ -1,18 +1,26 @@
"""Subscription application helpers."""
from .bridges_export import export_bridges, subscription_to_legacy_row
from .bridges_import import subscriptions_from_bridges
from .store_sync import replace_store_from_bridges
from .bridges_legacy_view import BridgesLegacyView
from .routing_table_export import export_routing_table, subscription_to_legacy_row
from .routing_table_import import subscriptions_from_routing_table
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__ = [
"BridgesLegacyView",
"RoutingTableLegacyView",
"SubscriptionRouter",
"active_system_slots_for_tg_in_store",
"build_voice_ingress",
"export_bridges",
"replace_store_from_bridges",
"export_routing_table",
"replace_store_from_routing_table",
"store_has_table",
"subscription_to_legacy_row",
"subscriptions_from_bridges",
"subscriptions_from_routing_table",
"system_has_active_leg_in_store",
]

@ -6,10 +6,11 @@ import logging
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.bridges_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.application.subscription.trigger_bytes import dst_in_triggers
from adn_server.domain import bytes_3, int_id
from adn_server.domain.subscription import Subscription, SubscriptionPhase
from adn_server.domain.subscription import ActivationPolicy, Subscription, SubscriptionPhase
logger = logging.getLogger(__name__)
@ -27,7 +28,7 @@ def apply_in_band_signalling_store(
pkt_time: float,
systems_cfg: dict[str, Any],
) -> None:
"""Mirror ``BridgeTimerMixin.apply_in_band_signalling`` on the subscription store."""
"""Mirror ``RoutingTimerMixin.apply_in_band_signalling`` on the subscription store."""
dst_group = int_id(dst_id)
dst_id_b = dst_id if isinstance(dst_id, bytes) and len(dst_id) >= 3 else bytes_3(dst_group)
@ -35,8 +36,8 @@ def apply_in_band_signalling_store(
if sub.system.value != system_name:
continue
bridge_key = sub.table_key()
if bridge_key[:1] == "#" and dst_group != 9:
relay_table_key = sub.table_key()
if relay_table_key[:1] == "#" and dst_group != 9:
continue
entry_ts = int(sub.channel.slot)
@ -55,7 +56,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [1] Transmission match for Bridge: %s. Reset timeout to %s",
system_name,
bridge_key,
relay_table_key,
sub.state.timer_expires_at,
)
@ -73,7 +74,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [2] Bridge: %s, connection changed to state: %s",
system_name,
bridge_key,
relay_table_key,
True,
)
if to_type == "OFF":
@ -81,7 +82,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [3] Bridge: %s set to \"OFF\" with an on timer rule: timeout timer cancelled",
system_name,
bridge_key,
relay_table_key,
)
if sub.is_active() and to_type == "ON" and timeout_sec:
sub.state.timer_expires_at = pkt_time + timeout_sec
@ -89,7 +90,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [4] Bridge: %s, timeout timer reset to: %s",
system_name,
bridge_key,
relay_table_key,
sub.state.timer_expires_at - pkt_time,
)
@ -109,13 +110,20 @@ def apply_in_band_signalling_store(
or dst_id_b != tgid_b
or dst_id_b == bytes_3(4000)
):
if sub.is_active():
# OPTIONS static (OFF) legs stay armed when parrot/special TG ends (9990–9999).
if (
sub.policy == ActivationPolicy.STATIC
and dst_id_b != tgid_b
and is_special_tg(str(dst_group))
):
pass
elif sub.is_active():
sub.state.phase = SubscriptionPhase.IDLE
changed = True
logger.info(
"(%s) [5] Bridge: %s, connection changed to state: %s",
system_name,
bridge_key,
relay_table_key,
False,
)
if to_type == "ON":
@ -123,7 +131,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [6] Bridge: %s set to \"OFF\" with an on timer rule: timeout timer cancelled",
system_name,
bridge_key,
relay_table_key,
)
if not sub.is_active() and to_type == "OFF" and timeout_sec:
sub.state.timer_expires_at = pkt_time + timeout_sec
@ -131,7 +139,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [7] Bridge: %s, timeout timer reset to: %s",
system_name,
bridge_key,
relay_table_key,
sub.state.timer_expires_at - pkt_time,
)
if sub.is_active() and to_type == "ON" and dst_in_triggers(dst_id_b, dst_group, off_list):
@ -140,7 +148,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [8] Bridge: %s set to ON with and \"OFF\" timer rule: timeout timer cancelled",
system_name,
bridge_key,
relay_table_key,
)
elif dst_id_b == bytes_3(4000) and slot == entry_ts:
is_static_tg = False
@ -155,7 +163,7 @@ def apply_in_band_signalling_store(
if dst_group in static_tgs:
is_static_tg = True
is_reflector = bridge_key[:1] == "#"
is_reflector = relay_table_key[:1] == "#"
if (
dst_in_triggers(dst_id_b, dst_group, off_list)
or dst_id_b == bytes_3(4000)
@ -167,7 +175,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [5b] Bridge: %s, connection changed to state: %s (TG 4000 forced deactivation)",
system_name,
bridge_key,
relay_table_key,
False,
)
if to_type == "ON":
@ -175,7 +183,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [6b] Bridge: %s set to \"OFF\" with an on timer rule: timeout timer cancelled",
system_name,
bridge_key,
relay_table_key,
)
if not sub.is_active() and to_type == "OFF" and timeout_sec:
sub.state.timer_expires_at = pkt_time + timeout_sec
@ -183,7 +191,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [7b] Bridge: %s, timeout timer reset to: %s",
system_name,
bridge_key,
relay_table_key,
sub.state.timer_expires_at - pkt_time,
)
if sub.is_active() and to_type == "ON" and dst_in_triggers(dst_id_b, dst_group, off_list):
@ -192,7 +200,7 @@ def apply_in_band_signalling_store(
logger.info(
"(%s) [8b] Bridge: %s set to ON with and \"OFF\" timer rule: timeout timer cancelled",
system_name,
bridge_key,
relay_table_key,
)
if changed:

@ -20,7 +20,7 @@ def build_voice_ingress(
call_type: str,
stream_id: bytes = b"",
) -> VoiceIngress | None:
"""Map ``BridgeUseCases.dmrd_received`` args to a routable ingress, or ``None`` if not bridged."""
"""Map ``RoutingUseCases.dmrd_received`` args to a routable ingress, or ``None`` if not bridged."""
if call_type not in _BRIDGE_CALL_TYPES:
return None
match_slot = 1 if int(slot) == 1 else 2

@ -31,13 +31,13 @@ def _tgid_match(entry_tgid: Any, dst_id_b: bytes, dst_int: int) -> bool:
def ensure_obp_source_for_tg_store(
store: SubscriptionStore,
system_name: str,
bridge_key: str,
relay_table_key: str,
dst_id_b: bytes,
dst_int: int,
now: float,
) -> None:
"""Ensure OBP has ACTIVE TS1 source row in main and #reflector tables."""
for key in (bridge_key, "#" + bridge_key):
for key in (relay_table_key, "#" + relay_table_key):
if not any(sub.table_key() == key for sub in store.snapshot()):
continue
channel_tgid = dst_int
@ -65,7 +65,7 @@ def ensure_obp_source_for_tg_store(
role=SubscriptionRole.ECHO,
policy=ActivationPolicy.INBAND,
state=SubscriptionState(phase=SubscriptionPhase.ACTIVE, timer_expires_at=now),
bridge_key=key if key.startswith("#") else None,
relay_table_key=key if key.startswith("#") else None,
timeout_seconds=None,
triggers=InbandTriggers(),
)

@ -11,12 +11,15 @@ class SubscriptionRouter:
def __init__(self, store: SubscriptionStore) -> None:
self._store = store
self._indexed = hasattr(store, "relay_tables_with_active_source") and hasattr(
store, "legs_in_table"
)
def resolve(self, ingress: VoiceIngress) -> tuple[ForwardLeg, ...]:
"""Return active forward legs when the source has an ACTIVE row on the dst TG (legacy to_target)."""
match_slot = ingress.bridge_match_slot
dst_tgid = ingress.dst_tgid.value
tables = self.bridge_tables_with_active_source(
tables = self.relay_tables_with_active_source(
ingress.source_system,
match_slot,
dst_tgid,
@ -27,9 +30,16 @@ class SubscriptionRouter:
legs: list[ForwardLeg] = []
seen_obp: set[tuple[str, int]] = set()
for table_key in tables:
for sub in self._store.snapshot():
if sub.table_key() != table_key:
continue
subs = (
self._store.legs_in_table(table_key)
if self._indexed
else tuple(
sub
for sub in self._store.snapshot()
if sub.table_key() == table_key
)
)
for sub in subs:
if sub.system.value == ingress.source_system:
continue
if not sub.is_active():
@ -48,8 +58,10 @@ class SubscriptionRouter:
)
return tuple(legs)
def bridge_tables_with_active_source(self, system: str, slot: int, dst_tgid: int) -> tuple[str, ...]:
"""Mirror ``BridgeRouter.bridge_tables_with_active_source`` on subscription rows."""
def relay_tables_with_active_source(self, system: str, slot: int, dst_tgid: int) -> tuple[str, ...]:
"""Mirror legacy ``relay_tables_with_active_source`` on subscription rows."""
if self._indexed:
return self._store.relay_tables_with_active_source(system, slot, dst_tgid)
tables: list[str] = []
seen: set[str] = set()
for sub in self._store.snapshot():

@ -44,7 +44,7 @@ def subscription_to_legacy_row(sub: Subscription, *, now: float | None = None) -
}
def export_bridges(
def export_routing_table(
store: SubscriptionStore,
*,
now: float | None = None,

@ -20,7 +20,7 @@ from adn_server.domain.subscription import (
from .trigger_bytes import trigger_bytes_tuple
def subscriptions_from_bridges(bridges: dict[str, list[dict[str, Any]]]) -> list[Subscription]:
def subscriptions_from_routing_table(bridges: dict[str, list[dict[str, Any]]]) -> list[Subscription]:
"""Build subscriptions from a legacy ``BRIDGES`` snapshot (OPTIONS/static TG / ECHO)."""
subs: list[Subscription] = []
for table_key, rows in bridges.items():
@ -35,13 +35,13 @@ def _subscription_from_row(table_key: str, row: dict[str, Any]) -> Subscription:
ts = int(row.get("TS") or 1)
if table_key.startswith("#"):
channel_tgid = int_id(row.get("TGID") or b"\x00\x00\x00")
bridge_key = table_key
relay_table_key = table_key
else:
try:
channel_tgid = int(table_key)
except ValueError:
channel_tgid = int_id(row.get("TGID") or b"\x00\x00\x00")
bridge_key = None
relay_table_key = None
to_type = str(row.get("TO_TYPE", "ON"))
timer = row.get("TIMER")
timer_at = float(timer) if isinstance(timer, (int, float)) else None
@ -57,7 +57,7 @@ def _subscription_from_row(table_key: str, row: dict[str, Any]) -> Subscription:
phase=SubscriptionPhase.ACTIVE if row.get("ACTIVE") else SubscriptionPhase.IDLE,
timer_expires_at=timer_at,
),
bridge_key=bridge_key,
relay_table_key=relay_table_key,
timeout_seconds=timeout_sec,
triggers=InbandTriggers(
on=trigger_bytes_tuple(row.get("ON")),

@ -5,15 +5,15 @@ from __future__ import annotations
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.bridges_export import export_bridges
from adn_server.application.subscription.routing_table_export import export_routing_table
class BridgesLegacyView:
class RoutingTableLegacyView:
"""One-way export: subscriptions → legacy ``BRIDGES`` dict (pickle / ``BRIDGE_SND``)."""
def __init__(self, store: SubscriptionStore) -> None:
self._store = store
def generate(self, *, now: float | None = None) -> dict[str, list[dict[str, Any]]]:
"""Build a pickle-compatible ``BRIDGES`` snapshot (legacy ``bridge_master.send_bridge``)."""
return export_bridges(self._store, now=now)
"""Build a pickle-compatible ``BRIDGES`` snapshot (legacy ``bridge_master.send_routing_table``)."""
return export_routing_table(self._store, now=now)

@ -8,8 +8,8 @@ from collections.abc import Callable
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.bridges_export import _legacy_to_type
from adn_server.application.bridge.helpers import is_special_tg
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.domain.subscription import Subscription, SubscriptionPhase
logger = logging.getLogger(__name__)
@ -20,7 +20,7 @@ def apply_rule_timer_store(
systems_cfg: dict[str, Any],
now: float,
*,
on_bridge_deactivated: Callable[[str], None] | None = None,
on_relay_deactivated: Callable[[str], None] | None = None,
) -> None:
"""One rule_timer tick: mutate subscriptions in place; prune unused bridge tables."""
by_table: dict[str, list[Subscription]] = defaultdict(list)
@ -30,9 +30,9 @@ def apply_rule_timer_store(
remove_tables: list[str] = []
debug_msgs: list[str] = []
for bridge_key, entries in list(by_table.items()):
for relay_table_key, entries in list(by_table.items()):
bridge_used = False
special_tg = is_special_tg(bridge_key)
special_tg = is_special_tg(relay_table_key)
for sub in entries:
system_name = sub.system.value
@ -41,7 +41,7 @@ def apply_rule_timer_store(
to_type = _legacy_to_type(sub)
active = sub.is_active()
timer = float(sub.state.timer_expires_at or 0.0)
is_dynamic = bridge_key[0:1] != "#" and to_type != "STAT"
is_dynamic = relay_table_key[0:1] != "#" and to_type != "STAT"
is_obp = sys_config.get("MODE") == "OPENBRIDGE"
if not is_single_mode and is_dynamic and not is_obp and not special_tg:
@ -50,12 +50,12 @@ def apply_rule_timer_store(
bridge_used = True
debug_msgs.append(
"(ROUTER) Conference Bridge ACTIVE (INFINITE TIMER): System: %s Bridge: %s, TS: %s, TGID: %s"
% (system_name, bridge_key, sub.channel.slot, int(sub.target_tgid))
% (system_name, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
else:
debug_msgs.append(
"(ROUTER) Conference Bridge INACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s"
% (system_name, bridge_key, sub.channel.slot, int(sub.target_tgid))
% (system_name, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
elif to_type == "OFF":
if not active:
@ -65,7 +65,7 @@ def apply_rule_timer_store(
logger.info(
"(ROUTER) Conference Bridge ACTIVATED (NO TIMEOUT): System: %s, Bridge: %s, TS: %s, TGID: %s",
system_name,
bridge_key,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
)
@ -73,7 +73,7 @@ def apply_rule_timer_store(
bridge_used = True
debug_msgs.append(
"(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s"
% (system_name, bridge_key, sub.channel.slot, int(sub.target_tgid))
% (system_name, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
else:
if to_type == "ON":
@ -82,12 +82,12 @@ def apply_rule_timer_store(
if timer < now:
sub.state.phase = SubscriptionPhase.IDLE
store.upsert(sub)
if on_bridge_deactivated and bridge_key[:1] == "#":
on_bridge_deactivated(system_name)
if on_relay_deactivated and relay_table_key[:1] == "#":
on_relay_deactivated(system_name)
logger.info(
"(ROUTER) Conference Bridge TIMEOUT: DEACTIVATE System: %s, Bridge: %s, TS: %s, TGID: %s",
system_name,
bridge_key,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
)
@ -95,7 +95,7 @@ def apply_rule_timer_store(
logger.info(
"(ROUTER) Conference Bridge ACTIVE (ON timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,",
system_name,
bridge_key,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
timer - now,
@ -103,7 +103,7 @@ def apply_rule_timer_store(
else:
debug_msgs.append(
"(ROUTER) Conference Bridge INACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s"
% (system_name, bridge_key, sub.channel.slot, int(sub.target_tgid))
% (system_name, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
elif to_type == "OFF":
if not active:
@ -114,7 +114,7 @@ def apply_rule_timer_store(
logger.info(
"(ROUTER) Conference Bridge TIMEOUT: ACTIVATE System: %s, Bridge: %s, TS: %s, TGID: %s",
system_name,
bridge_key,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
)
@ -123,7 +123,7 @@ def apply_rule_timer_store(
logger.info(
"(ROUTER) Conference Bridge INACTIVE (OFF timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,",
system_name,
bridge_key,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
timer - now,
@ -132,18 +132,18 @@ def apply_rule_timer_store(
bridge_used = True
debug_msgs.append(
"(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s"
% (system_name, bridge_key, sub.channel.slot, int(sub.target_tgid))
% (system_name, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
else:
if not is_obp or (is_obp and (to_type == "STAT" or active)):
bridge_used = True
debug_msgs.append(
"(ROUTER) Conference Bridge NO ACTION: System: %s, Bridge: %s, TS: %s, TGID: %s"
% (system_name, bridge_key, sub.channel.slot, int(sub.target_tgid))
% (system_name, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
if not bridge_used:
remove_tables.append(bridge_key)
remove_tables.append(relay_table_key)
if debug_msgs:
logger.debug("\n".join(debug_msgs))

@ -6,7 +6,7 @@ import logging
from collections import defaultdict
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.bridges_export import _legacy_to_type
from adn_server.application.subscription.routing_table_export import _legacy_to_type
from adn_server.domain.subscription import Subscription
logger = logging.getLogger(__name__)
@ -18,13 +18,20 @@ def apply_stat_trimmer_store(store: SubscriptionStore) -> None:
for sub in store.snapshot():
by_table[sub.table_key()].append(sub)
for bridge_key, entries in list(by_table.items()):
for relay_table_key, entries in list(by_table.items()):
has_stat = any(_legacy_to_type(sub) == "STAT" for sub in entries)
has_active_stat_source = any(
_legacy_to_type(sub) == "STAT" and sub.is_active() for sub in entries
)
in_use = any(
(_legacy_to_type(sub) == "ON" and sub.is_active()) or _legacy_to_type(sub) == "OFF"
for sub in entries
)
# Keep OBP STAT tables while the source leg is active; SYSTEM targets may be OFF/idle
# until OPTIONS/static TG apply (legacy statTrimmer did not drop mid-QSO OBP bridges).
if has_stat and has_active_stat_source:
continue
if has_stat and not in_use:
for sub in entries:
store.remove(sub.subscription_id)
logger.debug("(ROUTER) STAT bridge %s removed", bridge_key)
logger.debug("(ROUTER) STAT bridge %s removed", relay_table_key)

@ -1,8 +1,8 @@
"""Import legacy ``BRIDGES`` snapshots into ``SubscriptionStore``.
Runtime hot paths mutate the store directly and publish via ``export_bridges``; they must not
call ``replace_store_from_bridges`` (would overwrite store authority with the shim).
Bootstrap and tests may import once — e.g. ``_make_echo_bridges`` in ``peer_server.py``.
Runtime hot paths mutate the store directly and publish via ``export_routing_table``; they must not
call ``replace_store_from_routing_table`` (would overwrite store authority with the shim).
Bootstrap and tests may import once — e.g. ``_seed_echo_routing_table`` in ``peer_server.py``.
"""
from __future__ import annotations
@ -10,12 +10,12 @@ from __future__ import annotations
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.bridges_import import subscriptions_from_bridges
from adn_server.application.subscription.routing_table_import import subscriptions_from_routing_table
def replace_store_from_bridges(
def replace_store_from_routing_table(
store: SubscriptionStore,
bridges: dict[str, list[dict[str, Any]]],
) -> None:
"""Replace store contents from a full ``BRIDGES`` snapshot."""
store.replace_all(subscriptions_from_bridges(bridges))
store.replace_all(subscriptions_from_routing_table(bridges))

@ -1,4 +1,4 @@
"""Store-native bridge_debug_loop."""
"""Store-native subscription_debug_loop."""
from __future__ import annotations
@ -6,7 +6,7 @@ import logging
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.bridges_export import _legacy_to_type
from adn_server.application.subscription.routing_table_export import _legacy_to_type
from adn_server.domain import bytes_3
from adn_server.domain.subscription import (
ActivationPolicy,
@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
_PROHIBITED_TABLE_KEYS = tuple(str(b) for b in range(10)) + tuple(f"#{b}" for b in range(10))
def apply_bridge_debug_store(
def apply_subscription_debug_store(
store: SubscriptionStore,
systems_cfg: dict[str, Any],
now: float,
@ -79,27 +79,27 @@ def _fix_duplicate_dial_bridges(
for sub in store.snapshot():
if sub.system.value != system or not sub.is_active():
continue
bridge_key = sub.table_key()
if not bridge_key.startswith("#"):
relay_table_key = sub.table_key()
if not relay_table_key.startswith("#"):
continue
timer = sub.state.timer_expires_at
if isinstance(timer, (int, float)):
times[float(timer)] = bridge_key
times[float(timer)] = relay_table_key
_tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10))
timeout_sec = _tmout * 60.0
system_id = SystemId(system)
for bridge_key in set(times.values()):
logger.warning("(BRIDGEDEBUG) deactivating system: %s for bridge: %s", system, bridge_key)
for relay_table_key in set(times.values()):
logger.warning("(BRIDGEDEBUG) deactivating system: %s for bridge: %s", system, relay_table_key)
try:
setbridge = int(bridge_key[1:]) if bridge_key.startswith("#") else int(bridge_key)
setbridge = int(relay_table_key[1:]) if relay_table_key.startswith("#") else int(relay_table_key)
except ValueError:
setbridge = 9
on_trigger = bytes_3(setbridge)
for sub in list(store.snapshot()):
if sub.system != system_id or sub.table_key() != bridge_key:
if sub.system != system_id or sub.table_key() != relay_table_key:
continue
if int(sub.channel.slot) != 2:
continue
@ -115,7 +115,7 @@ def _fix_duplicate_dial_bridges(
phase=SubscriptionPhase.IDLE,
timer_expires_at=now + timeout_sec,
),
bridge_key=bridge_key,
relay_table_key=relay_table_key,
timeout_seconds=timeout_sec,
triggers=InbandTriggers(on=(on_trigger,), off=(), reset=()),
)

@ -0,0 +1,62 @@
"""Read-only queries on ``SubscriptionStore`` (runtime routing authority)."""
from __future__ import annotations
from adn_server.application.ports import SubscriptionStore
from adn_server.domain.subscription import Subscription
def store_has_table(store: SubscriptionStore, table_key: str) -> bool:
"""True when the store has at least one leg in ``table_key``."""
return any(sub.table_key() == table_key for sub in store.snapshot())
def system_has_active_leg_in_store(
store: SubscriptionStore,
system: str,
slot: int,
tgid: int,
) -> bool:
"""True when an ACTIVE subscription leg exists for ``(system, slot, tgid)``."""
indexed = getattr(store, "has_active_target_leg", None)
if callable(indexed):
return bool(indexed(system, slot, tgid))
for sub in store.snapshot():
if sub.system.value != system:
continue
if int(sub.channel.slot) != int(slot):
continue
if int(sub.target_tgid) != int(tgid):
continue
if sub.is_active():
return True
return False
def active_system_slots_for_tg_in_store(
store: SubscriptionStore,
system: str,
tgid: int,
) -> tuple[int, ...]:
"""Return sorted unique slots with an ACTIVE leg for ``system`` on bridge table ``tgid``."""
slots: list[int] = []
table_key = str(tgid)
for sub in store.snapshot():
if sub.table_key() != table_key:
continue
if sub.system.value != system:
continue
if not sub.is_active():
continue
slot = int(sub.channel.slot)
if slot not in slots:
slots.append(slot)
return tuple(sorted(slots))
def store_legs_for_table(
store: SubscriptionStore,
table_key: str,
) -> tuple[Subscription, ...]:
"""All subscription legs in a bridge table."""
return tuple(sub for sub in store.snapshot() if sub.table_key() == table_key)

@ -1,4 +1,4 @@
"""Store-native bridge_reset_loop helpers."""
"""Store-native subscription_reset_loop helpers."""
from __future__ import annotations
@ -7,7 +7,7 @@ from collections.abc import Callable
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.bridges_export import _legacy_to_type
from adn_server.application.subscription.routing_table_export import _legacy_to_type
from adn_server.domain import bytes_3
from adn_server.domain.subscription import (
ActivationPolicy,

@ -6,7 +6,7 @@ import logging
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.bridges_export import _legacy_to_type
from adn_server.application.subscription.routing_table_export import _legacy_to_type
from adn_server.domain import bytes_3, int_id
from adn_server.domain.subscription import (
ActivationPolicy,
@ -69,7 +69,7 @@ def _upsert_inband_sink(
now: float,
timer_at: float | None = None,
on_trigger: bytes | None = None,
bridge_key: str | None = None,
relay_table_key: str | None = None,
) -> None:
trigger = on_trigger if on_trigger is not None else bytes_3(target_tgid)
timer = timer_at
@ -86,7 +86,7 @@ def _upsert_inband_sink(
phase=SubscriptionPhase.ACTIVE if active else SubscriptionPhase.IDLE,
timer_expires_at=timer,
),
bridge_key=bridge_key,
relay_table_key=relay_table_key,
timeout_seconds=timeout_sec,
triggers=InbandTriggers(on=(trigger,), off=(), reset=()),
)
@ -129,7 +129,7 @@ def _upsert_obp_none(
channel_tgid: int,
target_tgid: int,
now: float,
bridge_key: str | None = None,
relay_table_key: str | None = None,
) -> None:
store.upsert(
Subscription(
@ -139,7 +139,7 @@ def _upsert_obp_none(
role=SubscriptionRole.ECHO,
policy=ActivationPolicy.INBAND,
state=SubscriptionState(phase=SubscriptionPhase.ACTIVE, timer_expires_at=now),
bridge_key=bridge_key,
relay_table_key=relay_table_key,
timeout_seconds=None,
triggers=InbandTriggers(),
)
@ -168,7 +168,7 @@ def _upsert_stat_obp(
)
def make_single_bridge_store(
def ensure_dynamic_relay_store(
store: SubscriptionStore,
tgid_int: int,
source_system: str,
@ -177,7 +177,7 @@ def make_single_bridge_store(
systems_cfg: dict[str, Any],
now: float,
) -> None:
"""Create bridge table for TG with per-system legs (legacy make_single_bridge)."""
"""Create bridge table for TG with per-system legs (legacy ensure_dynamic_relay)."""
tmout_eff = _effective_tmout_minutes(tgid_int, tmout)
timeout_sec = tmout_eff * 60.0
table_key = str(tgid_int)
@ -298,7 +298,7 @@ def make_single_reflector_store(
now=now,
timer_at=now + timeout_sec,
on_trigger=tgid_b,
bridge_key=table_key,
relay_table_key=table_key,
)
else:
_upsert_inband_sink(
@ -312,7 +312,7 @@ def make_single_reflector_store(
timeout_sec=def_ua,
now=now,
on_trigger=tgid_b,
bridge_key=table_key,
relay_table_key=table_key,
)
elif mode == "OPENBRIDGE" and (79 <= tgid_int < 9990 or tgid_int > 9999):
_upsert_obp_none(
@ -322,7 +322,7 @@ def make_single_reflector_store(
channel_tgid=tgid_int,
target_tgid=tgid_int,
now=now,
bridge_key=table_key,
relay_table_key=table_key,
)
@ -354,7 +354,7 @@ def make_default_reflector_store(
phase=SubscriptionPhase.ACTIVE,
timer_expires_at=now + timeout_sec,
),
bridge_key=table_key,
relay_table_key=table_key,
timeout_seconds=timeout_sec,
triggers=InbandTriggers(on=(reflector_b,), off=(), reset=()),
)
@ -412,21 +412,19 @@ def make_static_tg_store(
"""Ensure TG bridge exists and mark system/ts STATIC active (legacy make_static_tg)."""
table_key = str(tg)
if not _table_has_legs(store, table_key):
make_single_bridge_store(store, tg, system, ts, tmout, systems_cfg, now)
ensure_dynamic_relay_store(store, tg, system, ts, tmout, systems_cfg, now)
ensure_master_legs_in_tg_bridge_store(store, tg, system, tmout, systems_cfg, now)
timeout_sec = tmout * 60.0
timer_at = now + timeout_sec
active = True
existing = _find_leg(store, system, table_key, ts)
if existing is not None and single_mode and not existing.is_active():
active = False
timer_at = float(existing.state.timer_expires_at or timer_at)
# Legacy make_static_tg always sets ACTIVE True (TO_TYPE OFF); SINGLE_MODE contention
# is handled in-band during QSO, not by withholding static arm on OPTIONS/RPTO.
del single_mode # reserved for callers; parity with bridge_master.make_static_tg
_upsert_static_off(
store,
system=system,
tg=tg,
slot=ts,
active=active,
active=True,
timeout_sec=timeout_sec,
timer_at=timer_at,
)
@ -491,17 +489,17 @@ def reset_all_reflector_system_store(
now=now,
timer_at=now + timeout_sec,
on_trigger=bytes_3(on_tgid),
bridge_key=table_key,
relay_table_key=table_key,
)
def make_stat_bridge_store(
def ensure_stat_relay_store(
store: SubscriptionStore,
tgid_b: bytes,
systems_cfg: dict[str, Any],
now: float,
) -> None:
"""On-the-fly STAT relay bridge for OBP (legacy make_stat_bridge)."""
"""On-the-fly STAT relay bridge for OBP (legacy ensure_stat_relay)."""
tgid_int = int_id(tgid_b)
table_key = str(tgid_int)
_remove_table(store, table_key)
@ -527,7 +525,7 @@ def make_stat_bridge_store(
_upsert_stat_obp(store, system=system, tgid_b=tgid_b, now=now)
def deactivate_all_dynamic_bridges_store(
def deactivate_all_dynamic_relays_store(
store: SubscriptionStore,
system_name: str,
) -> None:
@ -535,8 +533,8 @@ def deactivate_all_dynamic_bridges_store(
for sub in list(store.snapshot()):
if sub.system.value != system_name:
continue
bridge_key = sub.table_key()
if bridge_key.startswith("#"):
relay_table_key = sub.table_key()
if relay_table_key.startswith("#"):
continue
if _legacy_to_type(sub) == "STAT":
continue
@ -547,7 +545,7 @@ def deactivate_all_dynamic_bridges_store(
logger.info(
"(ROUTER) Deactivated dynamic bridge due to TG/ID 4000: System: %s, Bridge: %s, TS: %s, TGID: %s",
system_name,
bridge_key,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
)
@ -578,7 +576,7 @@ def readd_system_after_ua_timer_change_store(
now=now,
timer_at=now + timeout_sec,
on_trigger=bytes_3(4000),
bridge_key=table_key,
relay_table_key=table_key,
)
except ValueError:
pass

@ -52,7 +52,7 @@ class VoiceUseCases:
get_protocols: Callable[[], dict[str, Any]] | None = None,
call_from_reactor: Callable[..., None] | None = None,
audio_path: str | None = None,
get_bridges: Callable[[], dict[str, list[dict[str, Any]]]] | None = None,
routing_table_for_report: Callable[[], dict[str, list[dict[str, Any]]]] | None = None,
call_later: Callable[..., Any] | None = None,
start_looping_call: Callable[[Callable[[], None], float, bool], Any] | None = None,
defer_to_thread: Callable[..., Any] | None = None,
@ -62,7 +62,7 @@ class VoiceUseCases:
self._get_protocols = get_protocols
self._call_from_reactor = call_from_reactor
self._audio_path = audio_path or ""
self._get_bridges = get_bridges
self._routing_table_for_report = routing_table_for_report
self._call_later = call_later
self._start_looping_call = start_looping_call
self._defer_to_thread = defer_to_thread
@ -96,7 +96,7 @@ class VoiceUseCases:
busy_count = 0
protocols = self._get_protocols() if self._get_protocols else {}
systems_cfg = self._config.get("SYSTEMS", {})
bridges = self._get_bridges() if self._get_bridges else {}
bridges = self._routing_table_for_report() if self._routing_table_for_report else {}
bridge_entries = bridges.get(tg_str, [])
for sys_name in list(protocols.keys()):
if sys_name in _ANNOUNCEMENT_EXCLUDED or any(
@ -289,7 +289,7 @@ class VoiceUseCases:
)
self._broadcast_finished(tg)
return
bridges = self._get_bridges() if self._get_bridges else {}
bridges = self._routing_table_for_report() if self._routing_table_for_report else {}
now = time.time()
for t in targets:
try:
@ -590,7 +590,7 @@ class VoiceUseCases:
)
self._broadcast_finished(tg)
return
bridges = self._get_bridges() if self._get_bridges else {}
bridges = self._routing_table_for_report() if self._routing_table_for_report else {}
now = time.time()
for t in targets:
try:

@ -87,7 +87,7 @@ class Subscription:
role: SubscriptionRole
policy: ActivationPolicy
state: SubscriptionState
bridge_key: str | None = None
relay_table_key: str | None = None
timeout_seconds: float | None = None
triggers: InbandTriggers = field(default_factory=InbandTriggers)
@ -99,6 +99,6 @@ class Subscription:
return self.state.phase == SubscriptionPhase.ACTIVE
def table_key(self) -> str:
if self.bridge_key is not None:
return self.bridge_key
if self.relay_table_key is not None:
return self.relay_table_key
return str(self.channel.tgid.value)

@ -0,0 +1,51 @@
# ADN DMR Peer Server - ACL router implementation
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2020 Simon Adlem, G7RZU <g7rzu@gb7fr.org.uk>
# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS <n0mjs@me.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""In-memory ACL check (legacy acl_check parity)."""
from __future__ import annotations
from ..application.ports import AclRouter
def _int_id(val: bytes | int) -> int:
if isinstance(val, int):
return val
if len(val) >= 4:
return int.from_bytes(val[:4], "big")
if len(val) == 3:
return int.from_bytes(val, "big")
return 0
class InMemoryAclRouter(AclRouter):
"""ACL-only adapter; runtime routing lives in ``SubscriptionStore``."""
def acl_check(self, id_bytes_or_int: bytes | int, acl: tuple[bool, list[tuple[int, int]]]) -> bool:
"""Legacy acl_check: (action, ranges). If id in any range return action else not action."""
action, ranges = acl
i = _int_id(id_bytes_or_int)
for lo, hi in ranges:
if lo <= i <= hi:
return action
return not action

@ -14,7 +14,7 @@ from typing import Any
from twisted.internet import reactor, task, threads
from adn_server.application import (
BridgeUseCases,
RoutingUseCases,
IdentUseCases,
ReportingUseCases,
ReportSender,
@ -35,8 +35,8 @@ from adn_server.application.runtime_context import (
)
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_bridges
from adn_server.infrastructure.bridge_router_impl import InMemoryBridgeRouter
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,
@ -83,19 +83,19 @@ class ReportSenderAdapter(ReportSender):
def set_systems(self, systems) -> None:
self._factory.set_systems(systems)
def set_bridges(self, bridges) -> None:
self._factory.set_bridges(bridges)
def set_routing_table(self, bridges) -> None:
self._factory.set_routing_table(bridges)
def send_config(self, systems, *, incremental: bool = False) -> None:
self._factory.set_systems(systems)
self._factory.send_config(incremental=incremental)
def send_bridge(self, bridges, *, incremental: bool = False) -> None:
self._factory.set_bridges(bridges)
self._factory.send_bridge(incremental=incremental)
def send_routing_table(self, bridges, *, incremental: bool = False) -> None:
self._factory.set_routing_table(bridges)
self._factory.send_routing_table(incremental=incremental)
def send_bridge_event(self, event: str) -> None:
self._factory.send_bridge_event(event)
def send_routing_event(self, event: str) -> None:
self._factory.send_routing_event(event)
def set_peer_slot_map(self, provider) -> None:
self._factory.set_peer_slot_map(provider)
@ -120,7 +120,7 @@ def _wire_proxy_report_slots(
report_factory.set_peer_slot_map(_slot_map)
def _make_echo_bridges(config: dict) -> dict:
def _seed_echo_routing_table(config: dict) -> dict:
"""Initial BRIDGES for ECHO system (legacy make_bridges 9990 + MASTER expansion)."""
now = time.time()
timeout_sec = 2 * 60
@ -212,13 +212,20 @@ def run_peer_server(
runtime_holder = RuntimeContextHolder(RuntimeContext(config=config, config_path=config_path))
config = ConfigProxy(runtime_holder)
# BRIDGES
bridge_router = InMemoryBridgeRouter()
# Subscription store is runtime routing authority; acl_router is ACL-only (D-08).
acl_router = InMemoryAclRouter()
systems_cfg = config.get("SYSTEMS", {})
subscription_store = InMemorySubscriptionStore()
if "ECHO" in systems_cfg and systems_cfg["ECHO"].get("MODE") in ("PEER", "MASTER"):
bridge_router.set_bridges(_make_echo_bridges(config))
else:
bridge_router.set_bridges({})
replace_store_from_routing_table(subscription_store, _seed_echo_routing_table(config))
_ctx = runtime_holder.get()
runtime_holder.swap(
RuntimeContext(
config=_ctx.config,
config_path=_ctx.config_path,
subscription_store=subscription_store,
)
)
# Protocol registry for send_to_system (legacy: systems[name].send_system(packet))
protocols: dict[str, Any] = {}
@ -257,7 +264,7 @@ def run_peer_server(
report_queue = BoundedReportQueue()
report_sender = QueuedReportSender(report_queue, report_inner)
report_factory.set_systems(systems_cfg)
report_factory.set_bridges(bridge_router.get_bridges())
report_factory.set_routing_table({})
reporting_use_cases = ReportingUseCases(report_sender, config)
if config.get("GLOBAL", {}).get("URL_SECURITY", "").strip():
security = DefaultSecurityDownloader(project_root)
@ -291,7 +298,7 @@ def run_peer_server(
get_protocols=lambda: protocols,
call_from_reactor=reactor.callFromThread,
audio_path=audio_path,
get_bridges=bridge_router.get_bridges,
routing_table_for_report=lambda: {},
call_later=reactor.callLater,
start_looping_call=_start_voice_loop,
defer_to_thread=threads.deferToThread,
@ -305,27 +312,14 @@ def run_peer_server(
call_from_reactor=reactor.callFromThread,
)
subscription_store = InMemorySubscriptionStore()
initial_bridges = bridge_router.get_bridges()
if initial_bridges:
# Bootstrap-only: seed echo/parrot tables (_make_echo_bridges) into the store authority.
replace_store_from_bridges(subscription_store, initial_bridges)
_ctx = runtime_holder.get()
runtime_holder.swap(
RuntimeContext(
config=_ctx.config,
config_path=_ctx.config_path,
subscription_store=subscription_store,
)
)
bridge_use_cases = BridgeUseCases(
bridge_router,
routing_use_cases = RoutingUseCases(
acl_router,
config,
subscription_store,
send_to_system=send_to_system,
get_protocols=lambda: protocols,
reporting=reporting_use_cases,
on_bridge_deactivated=lambda sys: reactor.callInThread(voice_use_cases.disconnected_voice, sys),
on_relay_deactivated=lambda sys: reactor.callInThread(voice_use_cases.disconnected_voice, sys),
send_bcsq=send_bcsq,
send_dmra_to_system=send_dmra_to_system,
get_dmra_blocks=get_dmra_blocks,
@ -333,9 +327,10 @@ def run_peer_server(
encode_emblc=encode_emblc,
ta_emblc_encoder=default_ta_emblc_encoder,
)
bridge_use_cases.apply_startup_bridges()
get_bridges = bridge_use_cases.get_bridges
report_factory.set_bridges(get_bridges())
routing_use_cases.apply_startup_subscriptions()
routing_table_for_report = routing_use_cases.routing_table_for_report
voice_use_cases._routing_table_for_report = routing_table_for_report
report_factory.set_routing_table(routing_table_for_report())
report_factory.set_systems(config.get("SYSTEMS", {}))
# Report server (same order as legacy: log then listen)
@ -353,11 +348,11 @@ def run_peer_server(
report_factory.start_mqtt()
reactor.addSystemEventTrigger("during", "shutdown", report_mqtt.stop)
# Reporting loop (REPORT_INTERVAL) — same logs as legacy after send_config/send_bridge
# Reporting loop (REPORT_INTERVAL) — same logs as legacy after send_config/send_routing_table
def reporting_loop():
logger.debug("(REPORT) Periodic reporting loop started")
reporting_use_cases.send_config(config.get("SYSTEMS", {}))
reporting_use_cases.send_bridge(get_bridges())
reporting_use_cases.send_routing_table(routing_table_for_report())
# Legacy: peer count and SUB_MAP count
systems_with_peers = sum(1 for s in config.get("SYSTEMS", {}) if config.get("SYSTEMS", {}).get(s, {}).get("PEERS"))
logger.info("(REPORT) %s systems have at least one peer", systems_with_peers)
@ -366,20 +361,20 @@ def run_peer_server(
report_interval = config.get("REPORTS", {}).get("REPORT_INTERVAL", 60)
task.LoopingCall(reporting_loop).start(report_interval).addErrback(_looping_errback, logger)
# LoopingCalls (legacy intervals). rule_timer / stat_trimmer mutate BRIDGES on reactor
# LoopingCalls (legacy intervals). rule_timer / stat_trimmer mutate SubscriptionStore on reactor
# Timer loops run on the reactor thread (no deferToThread — avoids races with dmrd_received).
def _rule_timer_on_reactor() -> None:
bridge_use_cases.rule_timer_loop()
reporting_use_cases.send_bridge(get_bridges(), incremental=True)
routing_use_cases.rule_timer_loop()
reporting_use_cases.send_routing_table(routing_table_for_report(), incremental=True)
task.LoopingCall(_rule_timer_on_reactor).start(52).addErrback(_looping_errback, logger)
task.LoopingCall(bridge_use_cases.stream_trimmer_loop).start(5).addErrback(_looping_errback, logger)
task.LoopingCall(bridge_use_cases.bridge_reset_loop).start(6).addErrback(_looping_errback, logger)
task.LoopingCall(routing_use_cases.stream_trimmer_loop).start(5).addErrback(_looping_errback, logger)
task.LoopingCall(routing_use_cases.subscription_reset_loop).start(6).addErrback(_looping_errback, logger)
if config.get("GLOBAL", {}).get("GEN_STAT_BRIDGES", False):
def _stat_trimmer_on_reactor() -> None:
bridge_use_cases.stat_trimmer_loop()
reporting_use_cases.send_bridge(get_bridges(), incremental=True)
routing_use_cases.stat_trimmer_loop()
reporting_use_cases.send_routing_table(routing_table_for_report(), incremental=True)
task.LoopingCall(_stat_trimmer_on_reactor).start(303).addErrback(_looping_errback, logger)
@ -390,8 +385,8 @@ def run_peer_server(
if config.get("GLOBAL", {}).get("DEBUG_BRIDGES"):
task.LoopingCall(
lambda: (
bridge_use_cases.bridge_debug_loop(),
reporting_use_cases.send_bridge(get_bridges(), incremental=True),
routing_use_cases.subscription_debug_loop(),
reporting_use_cases.send_routing_table(routing_table_for_report(), incremental=True),
)
).start(66).addErrback(_looping_errback, logger)
@ -492,20 +487,21 @@ def run_peer_server(
system_name,
config,
report_sender,
router=bridge_router,
dmrd_received=bridge_use_cases.dmrd_received,
router=acl_router,
dmrd_received=routing_use_cases.dmrd_received,
get_user_password_callback=user_passwords_loader.get_user_password,
on_play_file_request=voice_use_cases.play_file_on_request,
on_handle_recording=recording_handler.handle_recording,
on_in_band_signalling=bridge_use_cases.apply_in_band_signalling,
on_options_received=bridge_use_cases.options_config_for_system,
on_deactivate_dynamic_bridges=bridge_use_cases.deactivate_all_dynamic_bridges,
on_obp_bcsq_received=bridge_use_cases.on_obp_bcsq_received,
on_talker_alias_repeat_prepare=bridge_use_cases.prepare_talker_alias_local_repeat,
on_talker_alias_repeat_burst=bridge_use_cases.rewrite_repeat_voice_burst,
on_talker_alias_stream_end=bridge_use_cases.clear_talker_alias_stream,
on_dmra_fragment_stored=bridge_use_cases.on_dmra_fragment_stored,
get_bridges=bridge_router.get_bridges,
on_in_band_signalling=routing_use_cases.apply_in_band_signalling,
on_options_received=routing_use_cases.options_config_for_system,
on_deactivate_dynamic_relays=routing_use_cases.deactivate_all_dynamic_relays,
on_obp_bcsq_received=routing_use_cases.on_obp_bcsq_received,
on_talker_alias_repeat_prepare=routing_use_cases.prepare_talker_alias_local_repeat,
on_talker_alias_repeat_burst=routing_use_cases.rewrite_repeat_voice_burst,
on_talker_alias_stream_end=routing_use_cases.clear_talker_alias_stream,
on_dmra_fragment_stored=routing_use_cases.on_dmra_fragment_stored,
routing_table_for_report=routing_table_for_report,
get_subscription_store=lambda: subscription_store,
)
def _listen_system(_name: str, bind: BindSpec, protocol: Any) -> Any:
@ -526,9 +522,9 @@ def run_peer_server(
return not is_proxy_inject_only(config, system_name)
def _on_config_systems_changed() -> None:
bridge_use_cases.apply_startup_bridges()
routing_use_cases.apply_startup_subscriptions()
reporting_use_cases.send_config(config.get("SYSTEMS", {}))
reporting_use_cases.send_bridge(get_bridges())
reporting_use_cases.send_routing_table(routing_table_for_report())
user_passwords_loader.load(config)
def _apply_reload_success(result: Any, *, new_config: dict[str, Any], mqtt_before: Any) -> None:
@ -576,7 +572,7 @@ def run_peer_server(
listen_udp=lambda n, b, p: _listen_system(n, b, p),
stop_listener=_stop_udp_port,
on_systems_changed=None,
on_system_removed=bridge_use_cases.flush_monitor_events_for_system,
on_system_removed=routing_use_cases.flush_monitor_events_for_system,
should_bind_udp=_should_bind_udp,
log=logger,
).addCallbacks(_on_reload_done, _on_reload_failed)
@ -617,7 +613,7 @@ def run_peer_server(
reactor.callInThread(ident_use_cases.run_ident)
task.LoopingCall(ident_loop).start(3600).addErrback(_looping_errback, logger)
task.LoopingCall(bridge_use_cases.log_connected_systems_and_tgs).start(60).addErrback(_looping_errback, logger)
task.LoopingCall(routing_use_cases.log_connected_systems_and_tgs).start(60).addErrback(_looping_errback, logger)
task.LoopingCall(lambda: logger.debug("(ROUTER) KeepAlive reporting loop started")).start(60).addErrback(_looping_errback, logger)
# UDP listeners per system (same order as legacy: SYSTEM STARTING then instance created per system)

@ -1,90 +0,0 @@
# ADN DMR Peer Server - bridge router implementation
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2020 Simon Adlem, G7RZU <g7rzu@gb7fr.org.uk>
# Copyright (C) 2016-2019 Cortney T. Buffington, N0MJS <n0mjs@me.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""In-memory BRIDGES and ACL check (legacy acl_check)."""
from __future__ import annotations
from typing import Any
from ..application.ports import BridgeRouter
from ..domain import int_id
def _int_id(val: bytes | int) -> int:
if isinstance(val, int):
return val
if len(val) >= 4:
return int.from_bytes(val[:4], "big")
if len(val) == 3:
return int.from_bytes(val, "big")
return 0
class InMemoryBridgeRouter(BridgeRouter):
"""Holds BRIDGES dict; implements acl_check like legacy."""
def __init__(self) -> None:
self._bridges: dict[str, list[dict[str, Any]]] = {}
self._source_index: dict[tuple[str, int, int], list[str]] = {}
def get_bridges(self) -> dict[str, list[dict[str, Any]]]:
return self._bridges
def set_bridges(self, bridges: dict[str, list[dict[str, Any]]]) -> None:
self._bridges = bridges
self._source_index = {}
def rebuild_source_index(self) -> None:
"""Rebuild O(1) lookup: (system, TS, dst_tgid) -> bridge table names with ACTIVE source row."""
index: dict[tuple[str, int, int], list[str]] = {}
for bridge_name, rows in self._bridges.items():
for row in rows:
if not row.get("ACTIVE"):
continue
system = row.get("SYSTEM")
if not system:
continue
ts = row.get("TS", 1)
if ts is None:
ts = 1
tgid = int_id(row.get("TGID") or b"\x00\x00\x00")
key = (system, int(ts), tgid)
names = index.setdefault(key, [])
if bridge_name not in names:
names.append(bridge_name)
self._source_index = index
def bridge_tables_with_active_source(self, system: str, ts: int, dst_tgid: int) -> list[str]:
"""Bridge tables containing an ACTIVE source on (system, ts) for dst_tgid (legacy scan parity)."""
self.rebuild_source_index()
return list(self._source_index.get((system, int(ts), int(dst_tgid)), []))
def acl_check(self, id_bytes_or_int: bytes | int, acl: tuple[bool, list[tuple[int, int]]]) -> bool:
"""Legacy acl_check: (action, ranges). If id in any range return action else not action."""
action, ranges = acl
i = _int_id(id_bytes_or_int)
for lo, hi in ranges:
if lo <= i <= hi:
return action
return not action

@ -11,7 +11,7 @@ from twisted.internet import reactor, task
from adn_server.application.playback_use_cases import PlaybackUseCases
from adn_server.infrastructure import reopen_file_handlers
from adn_server.infrastructure.bridge_router_impl import InMemoryBridgeRouter
from adn_server.infrastructure.acl_router import InMemoryAclRouter
from adn_server.infrastructure.config_normalizer import (
ensure_system_runtime_config,
normalize_obp_config,
@ -36,7 +36,7 @@ def run_parrot(config: dict[str, Any], *, logger: logging.Logger) -> None:
g["SERVER_ID"] = (int(sid) & 0xFFFFFFFF).to_bytes(4, "big") if not isinstance(sid, bytes) else sid
protocols: dict[str, Any] = {}
router = InMemoryBridgeRouter()
router = InMemoryAclRouter()
report_factory = ReportServerFactory(config)
systems_cfg = config.get("SYSTEMS", {})
report_factory.set_systems(systems_cfg)

@ -1,7 +1,8 @@
"""In-memory subscription store (Phase 2 authority; not wired to BRIDGES yet)."""
"""In-memory subscription store (Phase 2 runtime routing authority)."""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Sequence
from adn_server.application.ports import SubscriptionStore
@ -13,27 +14,46 @@ from adn_server.domain.subscription import (
SystemId,
)
_IndexKey = tuple[str, int, int]
class InMemorySubscriptionStore(SubscriptionStore):
"""Hold subscriptions keyed by ``SubscriptionId``; no Twisted or YAML."""
"""Hold subscriptions keyed by ``SubscriptionId``; maintains hot-path indexes."""
def __init__(self) -> None:
self._items: dict[SubscriptionId, Subscription] = {}
self._by_table: dict[str, list[Subscription]] = defaultdict(list)
self._source_tables: dict[_IndexKey, set[str]] = {}
self._active_target_counts: dict[_IndexKey, int] = {}
def get(self, sub_id: SubscriptionId) -> Subscription | None:
return self._items.get(sub_id)
def upsert(self, subscription: Subscription) -> None:
old = self._items.get(subscription.subscription_id)
if old is not None:
self._unindex(old)
self._items[subscription.subscription_id] = subscription
self._index(subscription)
def remove(self, sub_id: SubscriptionId) -> bool:
return self._items.pop(sub_id, None) is not None
old = self._items.pop(sub_id, None)
if old is None:
return False
self._unindex(old)
return True
def clear(self) -> None:
self._items.clear()
self._by_table.clear()
self._source_tables.clear()
self._active_target_counts.clear()
def replace_all(self, subscriptions: Sequence[Subscription]) -> None:
self._items = {sub.subscription_id: sub for sub in subscriptions}
self.clear()
for sub in subscriptions:
self._items[sub.subscription_id] = sub
self._index(sub)
def snapshot(self) -> tuple[Subscription, ...]:
return tuple(self._items.values())
@ -49,3 +69,57 @@ class InMemorySubscriptionStore(SubscriptionStore):
def list_by_phase(self, phase: SubscriptionPhase) -> tuple[Subscription, ...]:
return tuple(sub for sub in self._items.values() if sub.state.phase == phase)
def relay_tables_with_active_source(
self,
system: str,
slot: int,
dst_tgid: int,
) -> tuple[str, ...]:
"""O(1) lookup: table keys with an ACTIVE source on (system, slot, dst_tgid)."""
keys = self._source_tables.get((system, int(slot), int(dst_tgid)))
if not keys:
return ()
return tuple(sorted(keys))
def legs_in_table(self, table_key: str) -> tuple[Subscription, ...]:
"""All legs for a relay table key (indexed)."""
return tuple(self._by_table.get(table_key, ()))
def has_active_target_leg(self, system: str, slot: int, tgid: int) -> bool:
"""True when any ACTIVE leg exists for ``(system, slot, target_tgid)``."""
return self._active_target_counts.get((system, int(slot), int(tgid)), 0) > 0
def _index_key(self, sub: Subscription) -> _IndexKey:
return (sub.system.value, int(sub.channel.slot), int(sub.target_tgid))
def _index(self, sub: Subscription) -> None:
table_key = sub.table_key()
self._by_table[table_key].append(sub)
if sub.is_active():
key = self._index_key(sub)
self._source_tables.setdefault(key, set()).add(table_key)
self._active_target_counts[key] = self._active_target_counts.get(key, 0) + 1
def _unindex(self, sub: Subscription) -> None:
table_key = sub.table_key()
legs = self._by_table.get(table_key)
if legs:
try:
legs.remove(sub)
except ValueError:
pass
if not legs:
del self._by_table[table_key]
if sub.is_active():
key = self._index_key(sub)
keys = self._source_tables.get(key)
if keys is not None:
keys.discard(table_key)
if not keys:
del self._source_tables[key]
count = self._active_target_counts.get(key, 0) - 1
if count <= 0:
self._active_target_counts.pop(key, None)
else:
self._active_target_counts[key] = count

@ -27,8 +27,8 @@ except ImportError: # pragma: no cover - exercised via create_report_mqtt_publi
class NullReportMqttPublisher(ReportMqttPublisher):
"""No-op sink when MQTT is disabled."""
def start(self, wire: ReportWireEncoder, get_systems: Any, get_bridges: Any) -> None:
del wire, get_systems, get_bridges
def start(self, wire: ReportWireEncoder, get_systems: Any, routing_table_for_report: Any) -> None:
del wire, get_systems, routing_table_for_report
def publish_frames(self, frames: tuple[bytes, ...]) -> None:
del frames
@ -54,9 +54,9 @@ class PahoReportMqttPublisher(ReportMqttPublisher):
self,
wire: ReportWireEncoder,
get_systems: Callable[[], dict[str, Any]],
get_bridges: Callable[[], dict[str, Any]],
routing_table_for_report: Callable[[], dict[str, Any]],
) -> None:
del wire, get_bridges
del wire, routing_table_for_report
if mqtt is None:
logger.error("(REPORT) MQTT enabled but paho-mqtt is not installed; publisher disabled")
return

@ -107,7 +107,7 @@ class ReportServerFactory(Factory):
def set_systems(self, systems: dict[str, Any]) -> None:
self._systems = systems
def set_bridges(self, bridges: dict[str, Any]) -> None:
def set_routing_table(self, bridges: dict[str, Any]) -> None:
self._bridges = bridges
def set_peer_slot_map(self, provider: Callable[[], dict[bytes, int]] | None) -> None:
@ -152,13 +152,13 @@ class ReportServerFactory(Factory):
if self._mqtt is not None:
self._mqtt.publish_dashboard(systems)
def send_bridge(self, *, incremental: bool = False) -> None:
def send_routing_table(self, *, incremental: bool = False) -> None:
frames = self._wire.bridge_frames(self._bridges, full_snapshot=not incremental)
self._broadcast_frames(frames)
if self._mqtt is not None:
self._mqtt.publish_dashboard(self._systems_for_report())
def send_bridge_event(self, event: str) -> None:
def send_routing_event(self, event: str) -> None:
peer_slots = self._peer_slot_map() if self._peer_slot_map is not None else None
events = remap_inject_proxy_voice_events(
event, self._config, self._systems, peer_slots, self._bridges

@ -40,7 +40,7 @@ from ...domain.dmr.const import LC_OPT
from twisted.internet import reactor, task
from twisted.internet.protocol import DatagramProtocol
from ...application.bridge.helpers import (
from ...application.routing.helpers import (
clear_peer_rx_status_slots,
clear_peer_ua_sessions,
is_special_tg,
@ -173,28 +173,30 @@ class HBPProtocol(DatagramProtocol):
on_handle_recording: Callable[..., None] | None = None,
on_in_band_signalling: Callable[[str, int, bytes, float], None] | None = None,
on_options_received: Callable[..., None] | None = None,
on_deactivate_dynamic_bridges: Callable[[str], None] | None = None,
on_deactivate_dynamic_relays: Callable[[str], None] | None = None,
on_obp_bcsq_received: Callable[[str, bytes, bytes], None] | None = None,
on_talker_alias_local_repeat: Callable[[str, bytes, bytes, bytes], None] | None = None,
on_talker_alias_repeat_prepare: Callable[[str, bytes, bytes, bytes, int, bytes], None] | None = None,
on_talker_alias_repeat_burst: Callable[[str, int, bytes, int, bytes], bytes] | None = None,
on_talker_alias_stream_end: Callable[[str, bytes], None] | None = None,
on_dmra_fragment_stored: Callable[[str, bytes, bytes, bytes], None] | None = None,
get_bridges: Callable[[], dict[str, Any]] | None = None,
routing_table_for_report: Callable[[], dict[str, Any]] | None = None,
get_subscription_store: Callable[[], Any] | None = None,
mesh_registry: MeshCodecRegistry | None = None,
) -> None:
self._CONFIG = config
self._system = system_name
self._report = report_factory
self._router = router
self._get_bridges = get_bridges
self._routing_table_for_report = routing_table_for_report
self._get_subscription_store = get_subscription_store
self._dmrd_received = dmrd_received
self._get_user_password = get_user_password_callback if get_user_password_callback is not None else get_user_password
self._on_play_file_request = on_play_file_request
self._on_handle_recording = on_handle_recording
self._on_in_band_signalling = on_in_band_signalling
self._on_options_received = on_options_received
self._on_deactivate_dynamic_bridges = on_deactivate_dynamic_bridges
self._on_deactivate_dynamic_relays = on_deactivate_dynamic_relays
self._on_obp_bcsq_received = on_obp_bcsq_received
self._on_talker_alias_local_repeat = on_talker_alias_local_repeat
self._on_talker_alias_repeat_prepare = on_talker_alias_repeat_prepare
@ -353,12 +355,12 @@ class HBPProtocol(DatagramProtocol):
self._system, _kind, slot, int_id(peer_id),
)
return
if self._on_deactivate_dynamic_bridges:
if self._on_deactivate_dynamic_relays:
logger.info(
"(%s) %s 4000 received on TS %s — deactivating all dynamic bridges",
self._system, _kind, slot,
)
self._on_deactivate_dynamic_bridges(self._system)
self._on_deactivate_dynamic_relays(self._system)
def _handle_tg4000_packet(
self,
@ -406,14 +408,15 @@ class HBPProtocol(DatagramProtocol):
connected = sum(
1 for peer in self._peers.values() if peer.get("CONNECTION") == "YES"
)
bridges = self._get_bridges() if self._get_bridges else None
store = self._get_subscription_store() if self._get_subscription_store else None
return peer_should_receive_group_voice(
self._peers[peer_id],
slot,
tgid,
peer_id=peer_id,
system=self._system,
bridges=bridges,
bridges=None,
subscription_store=store,
connected_count=connected,
sys_cfg=self._config,
)
@ -910,7 +913,7 @@ class HBPProtocol(DatagramProtocol):
# Legacy parity (bridge_master.py:3270-3310, routerHBP.dmrd_received):
# the HBP source path only writes per-slot state. The full LC is stored
# in STATUS[_slot]['RX_LC'] (decoded from the voice-header LC), and
# bridge_use_cases.py:2080 reads exactly that. Never write STATUS[stream_id]
# routing_use_cases.py:2080 reads exactly that. Never write STATUS[stream_id]
# for HBP — that pattern is only legal for routerOBP (flat dict).
dmrpkt = _data[20:53] if len(_data) >= 53 else b""
if _slot in self.STATUS:
@ -1292,7 +1295,7 @@ class HBPProtocol(DatagramProtocol):
)
# Legacy parity (bridge_master.py:3270-3310, routerHBP.dmrd_received):
# PEER source path only writes per-slot state. Full LC stored in
# STATUS[_slot]['RX_LC'] (decoded voice-header LC). bridge_use_cases
# STATUS[_slot]['RX_LC'] (decoded voice-header LC). routing_use_cases
# reads STATUS[_slot]['RX_LC'] for HBP sources. Never write
# STATUS[stream_id] for HBP — that pattern is only legal for routerOBP.
dmrpkt = _data[20:53] if len(_data) >= 53 else b""
@ -1600,7 +1603,7 @@ class HBPProtocol(DatagramProtocol):
self.store_ta_from_voice_burst(
_peer_id, _rf_src, _stream_id, _dtype_vseq, _data[20:53],
)
# Group/vcsbk stream state, LC, duplicates: bridge_use_cases._obp_group_voice_router_obp (legacy routerOBP.dmrd_received)
# Group/vcsbk stream state, LC, duplicates: routing_use_cases._obp_group_voice_router_obp (legacy routerOBP.dmrd_received)
if self._dmrd_received:
# Legacy hblink DMRD v1: SERVER_ID + default rptr/hops/ber/rssi (`hblink.py` ~338–345, ~416)
_global = self._CONFIG.get("GLOBAL", {})
@ -1895,14 +1898,15 @@ def HBPProtocolFactory(
on_handle_recording: Callable[..., None] | None = None,
on_in_band_signalling: Callable[[str, int, bytes, float], None] | None = None,
on_options_received: Callable[..., None] | None = None,
on_deactivate_dynamic_bridges: Callable[[str], None] | None = None,
on_deactivate_dynamic_relays: Callable[[str], None] | None = None,
on_obp_bcsq_received: Callable[[str, bytes, bytes], None] | None = None,
on_talker_alias_local_repeat: Callable[[str, bytes, bytes, bytes], None] | None = None,
on_talker_alias_repeat_prepare: Callable[[str, bytes, bytes, bytes, int, bytes], None] | None = None,
on_talker_alias_repeat_burst: Callable[[str, int, bytes, int, bytes], bytes] | None = None,
on_talker_alias_stream_end: Callable[[str, bytes], None] | None = None,
on_dmra_fragment_stored: Callable[[str, bytes, bytes, bytes], None] | None = None,
get_bridges: Callable[[], dict[str, Any]] | None = None,
routing_table_for_report: Callable[[], dict[str, Any]] | None = None,
get_subscription_store: Callable[[], Any] | None = None,
mesh_registry: MeshCodecRegistry | None = None,
) -> HBPProtocol:
"""Create HBP protocol instance (legacy: one HBSYSTEM per system)."""
@ -1917,13 +1921,14 @@ def HBPProtocolFactory(
on_handle_recording=on_handle_recording,
on_in_band_signalling=on_in_band_signalling,
on_options_received=on_options_received,
on_deactivate_dynamic_bridges=on_deactivate_dynamic_bridges,
on_deactivate_dynamic_relays=on_deactivate_dynamic_relays,
on_obp_bcsq_received=on_obp_bcsq_received,
on_talker_alias_local_repeat=on_talker_alias_local_repeat,
on_talker_alias_repeat_prepare=on_talker_alias_repeat_prepare,
on_talker_alias_repeat_burst=on_talker_alias_repeat_burst,
on_talker_alias_stream_end=on_talker_alias_stream_end,
on_dmra_fragment_stored=on_dmra_fragment_stored,
get_bridges=get_bridges,
routing_table_for_report=routing_table_for_report,
get_subscription_store=get_subscription_store,
mesh_registry=mesh_registry,
)

@ -7,7 +7,7 @@ One **topic per file** — run only what you need while developing or validating
python3 -m pip install --no-user -e ".[dev]"
python3 -m pytest tests/<path>/test_<name>.py -q # single file
python3 -m pytest tests/<path>/test_<name>.py::test_foo -q # single test
python3 -m pytest tests/bridge/ -q # whole domain
python3 -m pytest tests/routing/ -q # whole domain
python3 -m pytest tests/ -q # full suite
python3 -m pytest tests/ -q -m "not mqtt" # skip MQTT-heavy tests
```
@ -18,23 +18,23 @@ Use the project interpreter, e.g. `/opt/.pyenv/versions/3.11.8/bin/python3`.
| Directory | What it covers |
|-----------|----------------|
| `bridge/` | Static TG, startup bridges, unit data, CRC dedup, echo, private voice, config reload |
| `routing/` | Static TG, startup subscriptions, unit data, CRC dedup, echo reset, private voice, config reload |
| `hbp/` | HBP ingress, loop control, rate limit, timeout/collision, master maintenance |
| `obp/` | OpenBridge loop, rate limit, unit-data loop |
| `voice/` | Announcements, TTS schedule, broadcast queue, disconnected voice, in-band signalling |
| `talker_alias/` | Encode/decode, passthrough, MMDVM wire, bridge inject (DeterministicScenario) |
| `talker_alias/` | Encode/decode, passthrough, MMDVM wire, routing inject (DeterministicScenario) |
| `parrot/` | Recording timers, playback loop, seq preservation, ingress path |
| `replay/` | JSONL session replay |
| `schemas/` | Report v2 JSON Schema validation (`jsonschema` dev dep) |
| `application/` | Report payloads, monitor topology, proxy use cases, runtime context |
| `infrastructure/` | Logging reload, bridge router, **HBP REPEAT + proxy fan-in integration**, MQTT |
| `application/` | Report payloads, monitor topology, proxy use cases, subscription store/router |
| `infrastructure/` | Logging reload, ACL router, **HBP REPEAT + proxy fan-in integration**, MQTT |
| `smoke/` | Quick routing smoke |
| `support/` | Shared stacks (`hbp_repeat_stack`, monitor sim) — not run as tests |
| `harness/` | Shared fakes (`DeterministicScenario`, assertions) — not run as tests |
## Integration vs harness
Most bridge/voice tests inject packets via **`DeterministicScenario`** (`bridge.dmrd_received` on fakes). That is fast but **skips** `udp_hbp` REPEAT rewrite and proxy UDP fan-in.
Most routing/voice tests inject packets via **`DeterministicScenario`** (`routing.dmrd_received` on fakes). That is fast but **skips** `udp_hbp` REPEAT rewrite and proxy UDP fan-in.
For regressions on those paths, use:
@ -48,17 +48,19 @@ Mark new stack tests with `@pytest.mark.integration`.
## Files by domain
### bridge/
### routing/
| File | Tests | Topic |
|------|-------|-------|
| `test_config_reload.py` | 1 | Merge system config on reload |
| `test_crc_dedup.py` | 3 | HBP/OBP CRC dedup, seq=0 |
| `test_echo_bridgereset.py` | 5 | Echo leg after BRIDGERESET / OPTIONS |
| `test_echo_subscription_reset.py` | 5 | Echo leg after subscription reset / OPTIONS |
| `test_options_config_loop.py` | 3 | OPTIONS paths (RPTO/startup; no 26s loop) |
| `test_peer_options_override.py` | — | RPTO SINGLE/TIMER override, inject proxy |
| `test_private_voice.py` | 3 | Private call routing |
| `test_startup_bridges.py` | 4 | Startup BRIDGES + voice E2E |
| `test_startup_subscriptions.py` | 4 | Startup subscriptions + voice E2E |
| `test_static_tg_options.py` | 4 | Static TG from peer OPTIONS |
| `test_subscription_router_dmrd.py` | — | `dmrd_received` via `SubscriptionRouter` |
| `test_unit_data_ingress.py` | 4 | Unit headers, CSBK, reports |
| `test_unit_data_routing.py` | 5 | SUB_MAP, hotspot, gateway, OBP fanout |
@ -97,7 +99,7 @@ Mark new stack tests with `@pytest.mark.integration`.
| File | Tests | Topic |
|------|-------|-------|
| `test_bridge_inject.py` | 3 | TA inject on bridge VHEAD (harness) |
| `test_routing_inject.py` | 3 | TA inject on routing VHEAD (harness) |
| `test_embed_ta.py` | 4 | Embedded LC modes |
| `test_encode_decode.py` | 8 | Domain encode/decode |
| `test_format.py` | 2 | Format from subscriber profile |
@ -124,21 +126,35 @@ Mark new stack tests with `@pytest.mark.integration`.
| `test_udp_fanin.py` | UDP fan-in routing |
| `test_report_server_wire.py` | Report server wire opcodes |
| `test_logging_reload.py` | Log level reload |
| `test_bridge_router_index.py` | BRIDGES O(1) index vs legacy scan |
| `test_acl_router.py` | ACL range checks (`acl_check` parity) |
### smoke/ · application/
| File | Topic |
|------|-------|
| `smoke/test_bridge_routing.py` | Static TG forward smoke |
| `smoke/test_routing.py` | Static TG forward smoke |
| `application/test_monitor_topology.py` | Inject-only proxy monitor remap |
| `application/test_runtime_context.py` | RuntimeContext holder, SIGHUP swap prep |
| `application/test_subscription_router.py` | `SubscriptionRouter` vs legacy scan |
| `application/test_routing_table_export.py` | Monitor export shim from store |
## Harness API (post-rename)
| Symbol | Role |
|--------|------|
| `DeterministicScenario` | Wires `RoutingUseCases` + `InMemorySubscriptionStore` + `InMemoryAclRouter` |
| `scenario.routing` | Use-case facade (`dmrd_received`, timers, OPTIONS) |
| `scenario.seed_routing_table()` | Seed store from legacy monitor-shaped dict |
| `active_routing_table()` | Build minimal ACTIVE routing table for harness |
| `patch_routing_wall_time()` | Patches wall clock on OBP/unit paths |
Session replay JSONL meta accepts `routing_table` (preferred) or legacy `bridges`; `apply_startup_subscriptions` or legacy `apply_startup_bridges`.
## Examples (copy-paste)
```bash
# After changing bridge unit-data routing
python3 -m pytest tests/bridge/test_unit_data_routing.py -q
# After changing unit-data routing
python3 -m pytest tests/routing/test_unit_data_routing.py -q
# After parrot seq fix
python3 -m pytest tests/parrot/test_rekey_playback.py -q
@ -150,7 +166,7 @@ python3 -m pytest tests/infrastructure/test_hbp_repeat_talker_alias.py -q
python3 -m pytest tests/hbp/test_hbp_loop_control.py tests/hbp/test_hbp_rate_limit.py -q
# One test by name
python3 -m pytest tests/bridge/test_startup_bridges.py::test_startup_bridge_routes_voice_after_apply -q
python3 -m pytest tests/routing/test_startup_subscriptions.py::test_startup_bridge_routes_voice_after_apply -q
```
## Policy

@ -4,23 +4,24 @@ from __future__ import annotations
import copy
from adn_server.application.subscription.subscription_table_ops import make_static_tg_store
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_bridges
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_bridge
from tests.harness.voice_helpers import reflector_bridge_entry
from tests.harness.deterministic import active_routing_table
from tests.harness.voice_helpers import reflector_routing_entry
def _store_from_bridges(bridges: dict) -> InMemorySubscriptionStore:
store = InMemorySubscriptionStore()
replace_store_from_bridges(store, bridges)
replace_store_from_routing_table(store, bridges)
return store
def test_store_single_mode_deactivates_on_non_source_tg() -> None:
store = _store_from_bridges(active_bridge(52090, (("MASTER-A", 2),)))
store = _store_from_bridges(active_routing_table(52090, (("MASTER-A", 2),)))
systems_cfg = {"MASTER-A": {"MODE": "MASTER", "SINGLE_MODE": True}}
apply_in_band_signalling_store(store, "MASTER-A", 2, bytes_3(91), 3000.0, systems_cfg)
@ -30,7 +31,7 @@ def test_store_single_mode_deactivates_on_non_source_tg() -> None:
def test_store_non_single_keeps_bridge_on_arbitrary_vterm_tg() -> None:
store = _store_from_bridges(active_bridge(52090, (("MASTER-A", 2),)))
store = _store_from_bridges(active_routing_table(52090, (("MASTER-A", 2),)))
systems_cfg = {"MASTER-A": {"MODE": "MASTER", "SINGLE_MODE": False}}
apply_in_band_signalling_store(store, "MASTER-A", 2, bytes_3(91), 3000.0, systems_cfg)
@ -40,7 +41,7 @@ def test_store_non_single_keeps_bridge_on_arbitrary_vterm_tg() -> None:
def test_store_non_single_deactivates_on_tg4000() -> None:
bridges = copy.deepcopy(active_bridge(52090, (("MASTER-A", 2),)))
bridges = copy.deepcopy(active_routing_table(52090, (("MASTER-A", 2),)))
store = _store_from_bridges(bridges)
systems_cfg = {"MASTER-A": {"MODE": "MASTER", "SINGLE_MODE": False}}
@ -50,11 +51,26 @@ def test_store_non_single_deactivates_on_tg4000() -> None:
assert sub.state.phase == SubscriptionPhase.IDLE
def test_store_single_mode_keeps_static_off_on_unrelated_vterm() -> None:
"""OPTIONS static OFF legs must survive parrot/other-TG VTERM for OBP downlink."""
store = InMemorySubscriptionStore()
systems_cfg = {"MASTER-A": {"MODE": "MASTER", "SINGLE_MODE": True, "DEFAULT_UA_TIMER": 10}}
make_static_tg_store(store, 52090, 2, 10.0, "MASTER-A", systems_cfg, 1000.0, single_mode=True)
apply_in_band_signalling_store(store, "MASTER-A", 2, bytes_3(9990), 3000.0, systems_cfg)
sub = next(
s for s in store.snapshot()
if s.system.value == "MASTER-A" and int(s.target_tgid) == 52090
)
assert sub.is_active()
def test_store_reflector_ignored_when_vterm_not_on_tg9() -> None:
bridges = active_bridge(52090, (("MASTER-A", 2),))
bridges.update(reflector_bridge_entry())
bridges = active_routing_table(52090, (("MASTER-A", 2),))
bridges.update(reflector_routing_entry())
store = _store_from_bridges(bridges)
reflector = next(s for s in store.snapshot() if s.bridge_key == "#310")
reflector = next(s for s in store.snapshot() if s.relay_table_key == "#310")
timer_before = reflector.state.timer_expires_at
apply_in_band_signalling_store(store, "MASTER-A", 2, bytes_3(52090), 2000.0, {})

@ -357,7 +357,7 @@ def test_report_wire_bridge_frames_emits_routing_table() -> None:
assert frames[0][:1] == REPORT_OPCODES["ROUTING_TABLE_SND"]
doc = json.loads(frames[0][1:].decode())
assert doc["type"] == "routing_table"
assert doc["routes"][0]["bridge_key"] == "52090"
assert doc["routes"][0]["relay_table_key"] == "52090"
def test_report_wire_emits_voice_event_only() -> None:

@ -5,7 +5,7 @@ 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.bridge.helpers import (
from adn_server.application.routing.helpers import (
clear_peer_ua_sessions,
tg4000_reset_on_vhead,
clear_peer_rx_status_slots,
@ -210,7 +210,7 @@ def test_cross_peer_static_tx_does_not_reach_locked_peer() -> None:
)
def test_active_bridge_does_not_fan_out_without_static_tg() -> None:
def test_active_routing_table_does_not_fan_out_without_static_tg() -> None:
"""Regression: ACTIVE BRIDGES leg must not deliver to peers missing TG in OPTIONS."""
peer_without = {"OPTIONS": b"TS2=91;"}
peer_with = {"OPTIONS": b"TS2=7305;"}

@ -15,7 +15,7 @@ from adn_server.application.report import (
parse_bridge_event_csv,
routing_table_delta,
)
from adn_server.application.bridge.helpers import peer_should_receive_group_voice
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,

@ -10,24 +10,24 @@ from adn_server.application.report.queue import BoundedReportQueue, QueuedReport
class RecordingSender:
def __init__(self) -> None:
self.systems: dict[str, Any] | None = None
self.bridges: dict[str, Any] | None = None
self.routing_table: dict[str, Any] | None = None
self.config_calls: list[tuple[dict, bool]] = []
self.bridge_calls: list[tuple[dict, bool]] = []
self.routing_table_calls: list[tuple[dict, bool]] = []
self.events: list[str] = []
def set_systems(self, systems: dict[str, Any]) -> None:
self.systems = systems
def set_bridges(self, bridges: dict[str, Any]) -> None:
self.bridges = bridges
def set_routing_table(self, bridges: dict[str, Any]) -> None:
self.routing_table = bridges
def send_config(self, systems: dict[str, Any], *, incremental: bool = False) -> None:
self.config_calls.append((systems, incremental))
def send_bridge(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
self.bridge_calls.append((bridges, incremental))
def send_routing_table(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
self.routing_table_calls.append((bridges, incremental))
def send_bridge_event(self, event: str) -> None:
def send_routing_event(self, event: str) -> None:
self.events.append(event)
@ -35,7 +35,7 @@ def test_enqueue_event_is_non_blocking():
inner = RecordingSender()
queue = BoundedReportQueue(max_events=4, max_drain_per_tick=10)
sender = QueuedReportSender(queue, inner)
sender.send_bridge_event("GROUP VOICE,START,RX,SYS,1,2,3,1,4")
sender.send_routing_event("GROUP VOICE,START,RX,SYS,1,2,3,1,4")
assert inner.events == []
assert queue.pending_count() == 1
@ -44,14 +44,14 @@ def test_drain_delivers_events_before_snapshots():
inner = RecordingSender()
queue = BoundedReportQueue(max_drain_per_tick=10)
sender = QueuedReportSender(queue, inner)
sender.send_bridge_event("e1")
sender.send_routing_event("e1")
sender.send_config({"SYS": {}}, incremental=True)
sender.send_bridge({"99": []}, incremental=False)
sender.send_routing_table({"99": []}, incremental=False)
queue.drain(inner)
assert inner.events == ["e1"]
assert len(inner.config_calls) == 1
assert inner.config_calls[0][1] is True
assert len(inner.bridge_calls) == 1
assert len(inner.routing_table_calls) == 1
def test_config_and_bridge_coalesce_to_latest():
@ -60,20 +60,20 @@ def test_config_and_bridge_coalesce_to_latest():
sender = QueuedReportSender(queue, inner)
sender.send_config({"A": 1})
sender.send_config({"B": 2}, incremental=True)
sender.send_bridge({"old": []})
sender.send_bridge({"new": []}, incremental=True)
sender.send_routing_table({"old": []})
sender.send_routing_table({"new": []}, incremental=True)
queue.drain(inner)
assert inner.config_calls == [({"B": 2}, True)]
assert inner.bridge_calls == [({"new": []}, True)]
assert inner.routing_table_calls == [({"new": []}, True)]
def test_bounded_queue_drops_oldest_events():
inner = RecordingSender()
queue = BoundedReportQueue(max_events=2, max_drain_per_tick=10)
sender = QueuedReportSender(queue, inner)
sender.send_bridge_event("e1")
sender.send_bridge_event("e2")
sender.send_bridge_event("e3")
sender.send_routing_event("e1")
sender.send_routing_event("e2")
sender.send_routing_event("e3")
assert queue.dropped_events == 1
queue.drain(inner)
assert inner.events == ["e2", "e3"]
@ -84,7 +84,7 @@ def test_drain_respects_per_tick_budget():
queue = BoundedReportQueue(max_drain_per_tick=2)
sender = QueuedReportSender(queue, inner)
for i in range(5):
sender.send_bridge_event(f"e{i}")
sender.send_routing_event(f"e{i}")
queue.drain(inner)
assert inner.events == ["e0", "e1"]
queue.drain(inner)

@ -2,7 +2,7 @@
from __future__ import annotations
from adn_server.application.subscription.bridges_export import export_bridges, subscription_to_legacy_row
from adn_server.application.subscription.routing_table_export import export_routing_table, subscription_to_legacy_row
from adn_server.domain import bytes_3
from adn_server.domain.subscription import (
ActivationPolicy,
@ -53,7 +53,7 @@ def test_subscription_to_legacy_row_stat_obp():
assert row["ON"] == []
def test_export_bridges_groups_by_table_key():
def test_export_routing_table_groups_by_table_key():
store = InMemorySubscriptionStore()
channel = AudioChannel(tgid=TgId(730444), slot=1)
store.upsert(
@ -84,10 +84,10 @@ def test_export_bridges_groups_by_table_key():
role=SubscriptionRole.PASSIVE_STAT,
policy=ActivationPolicy.OPENBRIDGE_STAT,
state=SubscriptionState(phase=SubscriptionPhase.ACTIVE),
bridge_key="730444",
relay_table_key="730444",
)
)
bridges = export_bridges(store)
bridges = export_routing_table(store)
assert set(bridges.keys()) == {"730444"}
assert len(bridges["730444"]) == 3

@ -2,7 +2,7 @@
from __future__ import annotations
from adn_server.application.subscription.bridges_import import subscriptions_from_bridges
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 (
ActivationPolicy,
@ -33,7 +33,7 @@ def test_import_echo_and_inband_rows():
},
]
}
subs = subscriptions_from_bridges(bridges)
subs = subscriptions_from_routing_table(bridges)
assert len(subs) == 2
echo, master = subs
assert echo.role == SubscriptionRole.ECHO
@ -65,7 +65,7 @@ def test_import_static_and_stat_rows():
},
]
}
subs = subscriptions_from_bridges(bridges)
subs = subscriptions_from_routing_table(bridges)
static, stat = subs
assert static.policy == ActivationPolicy.STATIC
assert static.role == SubscriptionRole.SINK
@ -73,7 +73,7 @@ def test_import_static_and_stat_rows():
assert stat.role == SubscriptionRole.PASSIVE_STAT
def test_import_hash_table_key_sets_bridge_key():
def test_import_hash_table_key_sets_relay_table_key():
bridges = {
"#730444": [
{
@ -86,8 +86,8 @@ def test_import_hash_table_key_sets_bridge_key():
}
]
}
(sub,) = subscriptions_from_bridges(bridges)
assert sub.bridge_key == "#730444"
(sub,) = subscriptions_from_routing_table(bridges)
assert sub.relay_table_key == "#730444"
def test_import_preserves_on_off_reset_triggers():
@ -108,7 +108,7 @@ def test_import_preserves_on_off_reset_triggers():
}
]
}
(sub,) = subscriptions_from_bridges(bridges)
(sub,) = subscriptions_from_routing_table(bridges)
assert sub.triggers.on == (tg,)
assert sub.triggers.off == (off_tg,)
assert sub.triggers.reset == (bytes_3(4000),)

@ -1,11 +1,11 @@
"""BridgesLegacyView: subscription store → pickle BRIDGE_SND shim."""
"""RoutingTableLegacyView: subscription store → pickle BRIDGE_SND shim."""
from __future__ import annotations
import pickle
from adn_server.application.subscription.bridges_export import export_bridges
from adn_server.application.subscription.bridges_legacy_view import BridgesLegacyView
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 (
@ -37,16 +37,16 @@ def _sample_store() -> InMemorySubscriptionStore:
return store
def test_generate_matches_export_bridges():
def test_generate_matches_export_routing_table():
store = _sample_store()
view = BridgesLegacyView(store)
view = RoutingTableLegacyView(store)
now = 1_700_000_000.0
assert view.generate(now=now) == export_bridges(store, now=now)
assert view.generate(now=now) == export_routing_table(store, now=now)
def test_bridge_snd_frame_is_pickle_protocol_2():
store = _sample_store()
frame = encode_bridge_snd_frame(BridgesLegacyView(store).generate())
frame = encode_bridge_snd_frame(RoutingTableLegacyView(store).generate())
assert frame[:1] == REPORT_OPCODES["BRIDGE_SND"]
bridges = pickle.loads(frame[1:], encoding="bytes")
assert "730444" in bridges

@ -3,21 +3,21 @@
from __future__ import annotations
from adn_server.application.subscription.rule_timer_ops import apply_rule_timer_store
from adn_server.application.subscription.store_sync import replace_store_from_bridges
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_bridge
from tests.harness.deterministic import active_routing_table
def test_apply_rule_timer_store_deactivates_expired_on() -> None:
bridges = active_bridge(52090, (("MASTER-A", 1), ("MASTER-B", 2)))
bridges = active_routing_table(52090, (("MASTER-A", 1), ("MASTER-B", 2)))
bridges["52090"][0]["TO_TYPE"] = "ON"
bridges["52090"][0]["ACTIVE"] = True
bridges["52090"][0]["TIMER"] = 100.0
bridges["52090"][1]["TIMER"] = 300.0
store = InMemorySubscriptionStore()
replace_store_from_bridges(store, bridges)
replace_store_from_routing_table(store, bridges)
systems_cfg = {
"MASTER-A": {"SINGLE_MODE": True},
"MASTER-B": {"SINGLE_MODE": True},

@ -0,0 +1,23 @@
"""stat_trimmer must not drop OBP STAT bridges while the source leg is active."""
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.domain import bytes_3
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
def test_stat_trimmer_keeps_obp_stat_bridge_with_inactive_system_legs() -> None:
store = InMemorySubscriptionStore()
systems_cfg = {
"OBP-CL": {"MODE": "OPENBRIDGE", "DEFAULT_UA_TIMER": 10},
"SYSTEM": {"MODE": "MASTER", "DEFAULT_UA_TIMER": 10},
}
ensure_stat_relay_store(store, bytes_3(52090), systems_cfg, now=1000.0)
assert store_has_table(store, "52090")
apply_stat_trimmer_store(store)
assert store_has_table(store, "52090")

@ -3,36 +3,36 @@
from __future__ import annotations
from adn_server.domain.subscription import SubscriptionPhase
from tests.harness.deterministic import DeterministicScenario, active_bridge, patch_bridge_wall_time
from tests.harness.deterministic import DeterministicScenario, active_routing_table, patch_routing_wall_time
def test_rule_timer_syncs_subscription_store() -> None:
bridges = active_bridge(52090, (("MASTER-A", 1), ("MASTER-B", 2)))
bridges = active_routing_table(52090, (("MASTER-A", 1), ("MASTER-B", 2)))
bridges["52090"][0]["TO_TYPE"] = "ON"
bridges["52090"][0]["ACTIVE"] = True
bridges["52090"][0]["TIMER"] = 100.0
scenario = DeterministicScenario(bridges=bridges)
scenario = DeterministicScenario(routing_table=bridges)
scenario.clock.now = 200.0
with patch_bridge_wall_time(scenario.clock):
scenario.bridge.rule_timer_loop()
with patch_routing_wall_time(scenario.clock):
scenario.routing.rule_timer_loop()
assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is False
assert scenario.routing.routing_table_for_report()["52090"][0]["ACTIVE"] is False
subs = scenario.subscription_store.snapshot()
master_a = next(s for s in subs if s.system.value == "MASTER-A")
assert master_a.state.phase == SubscriptionPhase.IDLE
def test_finalize_exports_from_subscription_store() -> None:
bridges = active_bridge(730, (("MASTER-A", 2),))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(730, (("MASTER-A", 2),))
scenario = DeterministicScenario(routing_table=bridges)
sub = scenario.subscription_store.snapshot()[0]
sub.state.phase = SubscriptionPhase.ACTIVE
scenario.subscription_store.upsert(sub)
scenario.bridge._finalize_bridges_state()
scenario.routing._finalize_routing_state()
exported = scenario.bridge.get_bridges()
exported = scenario.routing.routing_table_for_report()
assert exported["730"][0]["ACTIVE"] is True
assert scenario.subscription_store.snapshot()[0].is_active()

@ -4,10 +4,10 @@ from __future__ import annotations
from typing import Any
from adn_server.application.subscription.bridges_export import export_bridges
from adn_server.application.subscription.store_sync import replace_store_from_bridges
from adn_server.application.subscription.routing_table_export import export_routing_table
from adn_server.application.subscription.store_sync import replace_store_from_routing_table
from adn_server.domain import bytes_3, int_id
from adn_server.infrastructure.bootstrap.peer_server import _make_echo_bridges
from adn_server.infrastructure.bootstrap.peer_server import _seed_echo_routing_table
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
@ -39,14 +39,14 @@ def test_replace_store_from_echo_bridges_round_trip():
"MASTER-B": {"MODE": "MASTER", "DEFAULT_UA_TIMER": 15},
}
}
bridges = _make_echo_bridges(config)
bridges = _seed_echo_routing_table(config)
store = InMemorySubscriptionStore()
replace_store_from_bridges(store, bridges)
replace_store_from_routing_table(store, bridges)
assert len(store.snapshot()) == len(_bridges_fingerprints(bridges))
now = 1_700_000_000.0
exported = export_bridges(store, now=now)
exported = export_routing_table(store, now=now)
assert _bridges_fingerprints(bridges) == _bridges_fingerprints(exported)
@ -76,9 +76,9 @@ def test_replace_store_clears_stale_entries():
}
]
}
replace_store_from_bridges(store, bridges_a)
replace_store_from_routing_table(store, bridges_a)
assert len(store.snapshot()) == 1
replace_store_from_bridges(store, bridges_b)
replace_store_from_routing_table(store, bridges_b)
assert len(store.snapshot()) == 1
(sub,) = store.snapshot()
assert int(sub.channel.tgid) == 200

@ -4,15 +4,37 @@ from __future__ import annotations
from typing import Any
from adn_server.application.subscription.bridges_import import subscriptions_from_bridges
from adn_server.application.subscription.routing_table_import import subscriptions_from_routing_table
from adn_server.application.subscription.router import SubscriptionRouter
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
from adn_server.infrastructure.bridge_router_impl import InMemoryBridgeRouter
from adn_server.infrastructure.subscription_store import InMemorySubscriptionStore
def _legacy_relay_tables(
bridges: dict[str, list[dict[str, Any]]],
system_name: str,
bridge_match_slot: int,
dst_int: int,
) -> list[str]:
dst_id_b = bytes_3(dst_int)
result: list[str] = []
for table_name, rows in bridges.items():
for row in rows:
if not row.get("ACTIVE"):
continue
if row.get("SYSTEM") != system_name:
continue
if int(row.get("TS") or 1) != bridge_match_slot:
continue
row_tgid = row.get("TGID") or b"\x00\x00\x00"
if row_tgid == dst_id_b or int_id(row_tgid) == dst_int:
result.append(table_name)
break
return result
def legacy_forward_targets(
bridges: dict[str, list[dict[str, Any]]],
*,
@ -25,12 +47,10 @@ def legacy_forward_targets(
"""Minimal legacy forward set: (target_system, ts, tgid_int) from BRIDGES scan.
When ``openbridge_targets`` is set, OBP dedup (``sys_ignore_obp``) applies only to those
systems — matching ``bridge_use_cases`` where dedup runs inside the OPENBRIDGE branch.
systems — matching ``routing_use_cases`` where dedup runs inside the OPENBRIDGE branch.
"""
router = InMemoryBridgeRouter()
router.set_bridges(bridges)
match_slot = 1 if source_is_obp else slot
tables = router.bridge_tables_with_active_source(source_system, match_slot, dst_tgid)
tables = _legacy_relay_tables(bridges, source_system, match_slot, dst_tgid)
targets: set[tuple[str, int, int]] = set()
seen_obp: set[tuple[str, int]] = set()
for table in tables:
@ -80,7 +100,7 @@ def _row(
}
def test_bridge_tables_with_active_source_matches_legacy():
def test_relay_tables_with_active_source_matches_legacy():
bridges = {
"730444": [
_row(system="MASTER-A", ts=1, tgid=730444, active=True),
@ -88,12 +108,10 @@ def test_bridge_tables_with_active_source_matches_legacy():
]
}
store = InMemorySubscriptionStore()
store.replace_all(subscriptions_from_bridges(bridges))
store.replace_all(subscriptions_from_routing_table(bridges))
sub_router = SubscriptionRouter(store)
legacy = InMemoryBridgeRouter()
legacy.set_bridges(bridges)
assert sub_router.bridge_tables_with_active_source("MASTER-A", 1, 730444) == tuple(
legacy.bridge_tables_with_active_source("MASTER-A", 1, 730444)
assert sub_router.relay_tables_with_active_source("MASTER-A", 1, 730444) == tuple(
_legacy_relay_tables(bridges, "MASTER-A", 1, 730444)
)
@ -106,7 +124,7 @@ def test_resolve_matches_legacy_forward_targets():
]
}
store = InMemorySubscriptionStore()
store.replace_all(subscriptions_from_bridges(bridges))
store.replace_all(subscriptions_from_routing_table(bridges))
ingress = VoiceIngress(source_system="MASTER-A", slot=1, dst_tgid=TgId(730444))
legs = SubscriptionRouter(store).resolve(ingress)
assert subscription_forward_targets(legs) == legacy_forward_targets(
@ -127,9 +145,9 @@ def test_obp_ingress_forwards_to_all_active_hbp_slots():
]
}
store = InMemorySubscriptionStore()
store.replace_all(subscriptions_from_bridges(bridges))
store.replace_all(subscriptions_from_routing_table(bridges))
router = SubscriptionRouter(store)
assert router.bridge_tables_with_active_source("OBP-CL", 1, 730444) == ("730444",)
assert router.relay_tables_with_active_source("OBP-CL", 1, 730444) == ("730444",)
ingress = VoiceIngress(
source_system="OBP-CL",
slot=2,
@ -162,7 +180,7 @@ def test_resolve_obp_dedupes_same_openbridge_peer_across_tables():
],
}
store = InMemorySubscriptionStore()
store.replace_all(subscriptions_from_bridges(bridges))
store.replace_all(subscriptions_from_routing_table(bridges))
ingress = VoiceIngress(
source_system="OBP-CL",
slot=2,
@ -191,7 +209,7 @@ def test_resolve_empty_when_no_active_source_row():
]
}
store = InMemorySubscriptionStore()
store.replace_all(subscriptions_from_bridges(bridges))
store.replace_all(subscriptions_from_routing_table(bridges))
ingress = VoiceIngress(source_system="MASTER-A", slot=1, dst_tgid=TgId(730444))
assert SubscriptionRouter(store).resolve(ingress) == ()
@ -209,7 +227,7 @@ def test_two_bridge_tables_same_dst_tgid():
],
}
store = InMemorySubscriptionStore()
store.replace_all(subscriptions_from_bridges(bridges))
store.replace_all(subscriptions_from_routing_table(bridges))
ingress = VoiceIngress(source_system="MASTER-A", slot=1, dst_tgid=TgId(730444))
legs = SubscriptionRouter(store).resolve(ingress)
assert subscription_forward_targets(legs) == {("OBP-CL", 1, 730444)}

@ -2,21 +2,21 @@
from __future__ import annotations
from adn_server.application.subscription.bridge_table_ops import (
make_single_bridge_store,
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_bridges
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_bridge, minimal_config
from tests.harness.deterministic import active_routing_table, minimal_config
def _store(bridges: dict | None = None) -> InMemorySubscriptionStore:
store = InMemorySubscriptionStore()
if bridges:
replace_store_from_bridges(store, bridges)
replace_store_from_routing_table(store, bridges)
return store
@ -38,7 +38,7 @@ def test_make_static_tg_store_creates_active_off_leg() -> None:
def test_ensure_obp_source_activates_ts1_leg() -> None:
bridges = active_bridge(7305, (("OBP-CL", 1), ("MASTER-A", 2)))
bridges = active_routing_table(7305, (("OBP-CL", 1), ("MASTER-A", 2)))
for entry in bridges["7305"]:
if entry["SYSTEM"] == "OBP-CL":
entry["ACTIVE"] = False
@ -48,7 +48,7 @@ def test_ensure_obp_source_activates_ts1_leg() -> None:
assert obp.is_active()
def test_make_single_bridge_store_obp_leg() -> None:
def test_ensure_dynamic_relay_store_obp_leg() -> None:
config = minimal_config(("MASTER-A",))
add_obp = {
"OBP-CL": {
@ -60,6 +60,6 @@ def test_make_single_bridge_store_obp_leg() -> None:
}
config["SYSTEMS"].update(add_obp)
store = _store()
make_single_bridge_store(store, 7305, "MASTER-A", 2, 10.0, config["SYSTEMS"], now=1.0)
ensure_dynamic_relay_store(store, 7305, "MASTER-A", 2, 10.0, config["SYSTEMS"], now=1.0)
obp = next(s for s in store.snapshot() if s.system.value == "OBP-CL")
assert obp.is_active()

@ -2,26 +2,27 @@
from __future__ import annotations
from adn_server.application.subscription.bridge_debug_ops import apply_bridge_debug_store
from adn_server.application.subscription.bridge_reset_ops import (
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_bridges
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_bridge
from tests.harness.deterministic import active_routing_table
def _store(bridges: dict) -> InMemorySubscriptionStore:
store = InMemorySubscriptionStore()
replace_store_from_bridges(store, bridges)
replace_store_from_routing_table(store, bridges)
return store
def test_stat_trimmer_removes_unused_stat_table() -> None:
def test_stat_trimmer_keeps_obp_stat_table_while_source_active() -> None:
"""Active OBP STAT source must not be trimmed before SYSTEM legs are activated."""
tg = bytes_3(12345)
bridges = {
"12345": [
@ -53,20 +54,55 @@ def test_stat_trimmer_removes_unused_stat_table() -> None:
}
store = _store(bridges)
apply_stat_trimmer_store(store)
assert len(store.snapshot()) == 2
def test_stat_trimmer_removes_unused_stat_table_when_source_idle() -> None:
tg = bytes_3(12345)
bridges = {
"12345": [
{
"SYSTEM": "OBP-CL",
"TS": 1,
"TGID": tg,
"ACTIVE": False,
"TIMEOUT": "",
"TO_TYPE": "STAT",
"ON": [],
"OFF": [],
"RESET": [],
"TIMER": 0,
},
{
"SYSTEM": "MASTER-A",
"TS": 2,
"TGID": tg,
"ACTIVE": False,
"TIMEOUT": 600.0,
"TO_TYPE": "ON",
"ON": [tg],
"OFF": [],
"RESET": [],
"TIMER": 0,
},
]
}
store = _store(bridges)
apply_stat_trimmer_store(store)
assert store.snapshot() == ()
def test_bridge_debug_removes_prohibited_numeric_keys() -> None:
bridges = active_bridge(52090, (("MASTER-A", 2),))
bridges = active_routing_table(52090, (("MASTER-A", 2),))
bridges["5"] = list(bridges["52090"])
store = _store(bridges)
apply_bridge_debug_store(store, {"MASTER-A": {"MODE": "MASTER"}}, now=1000.0)
apply_subscription_debug_store(store, {"MASTER-A": {"MODE": "MASTER"}}, now=1000.0)
assert all(s.table_key() != "5" for s in store.snapshot())
assert any(s.table_key() == "52090" for s in store.snapshot())
def test_deactivate_system_legs_store() -> None:
store = _store(active_bridge(52090, (("MASTER-A", 2), ("MASTER-B", 2))))
store = _store(active_routing_table(52090, (("MASTER-A", 2), ("MASTER-B", 2))))
deactivate_system_legs_store(store, "MASTER-A", now=5000.0)
master = next(s for s in store.snapshot() if s.system.value == "MASTER-A")
other = next(s for s in store.snapshot() if s.system.value == "MASTER-B")
@ -75,7 +111,7 @@ def test_deactivate_system_legs_store() -> None:
def test_restore_prohibited_static_leg() -> None:
bridges = active_bridge(9990, (("MASTER-A", 2),))
bridges = active_routing_table(9990, (("MASTER-A", 2),))
for entry in bridges["9990"]:
if entry["SYSTEM"] == "MASTER-A":
entry["ACTIVE"] = False

@ -2,27 +2,25 @@
from __future__ import annotations
from adn_server.application.bridge_use_cases import BridgeUseCases
from adn_server.application.subscription.store_sync import replace_store_from_bridges
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.subscription import TgId
from adn_server.domain.voice_routing import ForwardLeg
from adn_server.domain.dmr.bptc import encode_emblc
from adn_server.infrastructure.bridge_router_impl import InMemoryBridgeRouter
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 _bridge(bridges: dict) -> BridgeUseCases:
def _routing(routing_table: dict) -> RoutingUseCases:
config = minimal_config(("MASTER-A", "MASTER-B"))
router = InMemoryBridgeRouter()
router.set_bridges(bridges)
store = InMemorySubscriptionStore()
replace_store_from_bridges(store, bridges)
return BridgeUseCases(
router,
replace_store_from_routing_table(store, routing_table)
return RoutingUseCases(
InMemoryAclRouter(),
config,
store,
encode_emblc=encode_emblc,
@ -38,8 +36,8 @@ def test_forward_plan_returns_leg_filter() -> None:
_row(system="OBP-CL", ts=1, tgid=730444, active=False),
]
}
bridge = _bridge(bridges)
tables, legs = bridge._voice_forward_plan(
routing = _routing(bridges)
tables, legs = routing._voice_forward_plan(
system_name="MASTER-A",
peer_id=b"\x00\x00\x03\xe9",
rf_src=b"\x00\x2f\x8b\x01",

@ -1,6 +1,6 @@
"""In-process deterministic harness for BridgeUseCases.
"""In-process deterministic harness for RoutingUseCases.
Inject at ``BridgeUseCases.dmrd_received()`` and capture outbound
Inject at ``RoutingUseCases.dmrd_received()`` and capture outbound
``send_to_system`` calls without UDP or Twisted.
"""
@ -12,7 +12,7 @@ from dataclasses import dataclass, field
from time import perf_counter
from typing import Any
from adn_server.application.bridge_use_cases import BridgeUseCases
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
@ -23,8 +23,8 @@ from adn_server.domain.hbp_protocol import (
HBPF_SLT_VTERM,
HBPF_VOICE,
)
from adn_server.application.subscription.store_sync import replace_store_from_bridges
from adn_server.infrastructure.bridge_router_impl import InMemoryBridgeRouter
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
@ -198,17 +198,17 @@ class FakeReportSender:
def set_systems(self, systems: dict[str, Any]) -> None:
pass
def set_bridges(self, bridges: dict[str, Any]) -> None:
def set_routing_table(self, bridges: dict[str, Any]) -> None:
pass
def send_config(self, systems: dict[str, Any], *, incremental: bool = False) -> None:
pass
def send_bridge(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
def send_routing_table(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
pass
def send_bridge_event(self, event: str) -> None:
self._factory.send_bridge_event(event)
def send_routing_event(self, event: str) -> None:
self._factory.send_routing_event(event)
class FakeReportFactory:
@ -217,7 +217,7 @@ class FakeReportFactory:
def __init__(self) -> None:
self.events: list[str] = []
def send_bridge_event(self, msg: str) -> None:
def send_routing_event(self, msg: str) -> None:
self.events.append(msg)
@ -314,9 +314,9 @@ class CapturedBcsq:
@contextmanager
def patch_bridge_wall_time(clock: FakeClock):
"""Patch ``bridge_use_cases.time.time`` to the harness clock (OBP path)."""
import adn_server.application.bridge_use_cases as buc
def patch_routing_wall_time(clock: FakeClock):
"""Patch ``routing_use_cases.time.time`` to the harness clock (OBP path)."""
import adn_server.application.routing_use_cases as buc
original = buc.time.time
buc.time.time = clock.time
@ -326,7 +326,7 @@ def patch_bridge_wall_time(clock: FakeClock):
buc.time.time = original
def active_bridge(
def active_routing_table(
tg_id: int,
entries: tuple[tuple[str, int], ...],
timeout_minutes: int = 1,
@ -353,12 +353,12 @@ def active_bridge(
class DeterministicScenario:
"""Wires BridgeUseCases with fake protocols and outbound capture."""
"""Wires RoutingUseCases with fake protocols and outbound capture."""
def __init__(
self,
config: dict[str, Any] | None = None,
bridges: dict[str, list[dict[str, Any]]] | None = None,
routing_table: dict[str, list[dict[str, Any]]] | None = None,
*,
enable_reporting: bool = False,
) -> None:
@ -375,14 +375,16 @@ class DeterministicScenario:
if self.report_factory
else None
)
self.router = InMemoryBridgeRouter()
self.router.set_bridges(copy.deepcopy(bridges or {}))
self.acl_router = InMemoryAclRouter()
self.subscription_store = InMemorySubscriptionStore()
replace_store_from_bridges(self.subscription_store, self.router.get_bridges())
if routing_table:
replace_store_from_routing_table(
self.subscription_store, copy.deepcopy(routing_table)
)
self.protocols: dict[str, FakeHbpProtocol | FakeObpProtocol] = {}
self._wire_protocols_from_config()
self.bridge = BridgeUseCases(
self.router,
self.routing = RoutingUseCases(
self.acl_router,
self.config,
self.subscription_store,
send_to_system=self._send_capture,
@ -395,6 +397,10 @@ class DeterministicScenario:
ta_emblc_encoder=default_ta_emblc_encoder,
)
def seed_routing_table(self, routing_table: dict[str, list[dict[str, Any]]]) -> None:
"""Replace subscription store contents from a legacy monitor-shaped routing table."""
replace_store_from_routing_table(self.subscription_store, routing_table)
def _wire_protocols_from_config(self) -> None:
self.protocols.clear()
for name, syscfg in self.config.get("SYSTEMS", {}).items():
@ -508,7 +514,7 @@ class DeterministicScenario:
) -> bool | None:
args = spec.decoded_hbp_args()
pkt_time = self.clock.time() if ingress_pkt_time is None else ingress_pkt_time
ok = self.bridge.dmrd_received(system_name, ingress_pkt_time=pkt_time, **args)
ok = self.routing.dmrd_received(system_name, ingress_pkt_time=pkt_time, **args)
if ok is not False:
self._sync_hbp_slot(system_name, args)
return ok
@ -520,8 +526,8 @@ class DeterministicScenario:
) -> bool | None:
"""Inject a unit (private) call packet; patches bridge wall time to harness clock."""
args = spec.decoded_hbp_args()
with patch_bridge_wall_time(self.clock):
ok = self.bridge.dmrd_received(system_name, **args)
with patch_routing_wall_time(self.clock):
ok = self.routing.dmrd_received(system_name, **args)
if ok is not False:
self._sync_hbp_slot(system_name, args)
return ok
@ -535,7 +541,7 @@ class DeterministicScenario:
obp_source_server: bytes | None = None,
) -> bool | None:
args = spec.decoded_hbp_args()
return self.bridge.dmrd_received(
return self.routing.dmrd_received(
system_name,
obp_use_parsed=True,
obp_hops=obp_hops,

@ -9,19 +9,19 @@ from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encode
from .deterministic import (
DeterministicScenario,
active_bridge,
active_routing_table,
add_openbridge_system,
)
def obp_bridge_scenario(*obp_names: str, tg: int = 52090) -> DeterministicScenario:
entries = [(name, 1) for name in obp_names] + [("MASTER-A", 2)]
bridges = active_bridge(tg, tuple(entries))
bridges = active_routing_table(tg, tuple(entries))
config = DeterministicScenario().config
for name in obp_names:
add_openbridge_system(config, name)
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = str(tg)
return DeterministicScenario(config=config, bridges=bridges)
return DeterministicScenario(config=config, routing_table=bridges)
def talker_alias_config() -> dict[str, Any]:

@ -2,7 +2,7 @@
Session file format (one JSON object per line):
- ``meta`` — optional first line: ``config``, ``bridges``, ``apply_startup_bridges``, ``expect``
- ``meta`` — optional first line: ``config``, ``bridges``, ``apply_startup_subscriptions``, ``expect``
- ``ingress`` — inject one packet: ``channel`` (hbp|obp|unit), ``system``, ``dt`` (clock advance
seconds before inject), and either ``packet`` shortcut or ``spec`` fields for PacketSpec
- ``packet`` shortcuts: ``voice_head``, ``voice_burst``, ``voice_term`` (+ ``base``, ``seq``, …)
@ -36,7 +36,7 @@ from tests.harness.deterministic import (
DeterministicScenario,
PacketSpec,
minimal_config,
patch_bridge_wall_time,
patch_routing_wall_time,
)
SESSION_VERSION = 1
@ -61,8 +61,8 @@ class SessionMeta:
version: int = SESSION_VERSION
name: str = ""
config: dict[str, Any] | None = None
bridges: dict[str, list[dict[str, Any]]] | None = None
apply_startup_bridges: bool = False
routing_table: dict[str, list[dict[str, Any]]] | None = None
apply_startup_subscriptions: bool = False
expect: SessionExpect = field(default_factory=SessionExpect)
@ -128,12 +128,16 @@ def _parse_meta(obj: dict[str, Any]) -> SessionMeta:
forwards=dict(expect_raw.get("forwards") or {}),
dst_id=expect_raw.get("dst_id"),
)
routing_raw = obj.get("routing_table") or obj.get("bridges")
apply_startup = obj.get("apply_startup_subscriptions")
if apply_startup is None:
apply_startup = obj.get("apply_startup_bridges", False)
return SessionMeta(
version=int(obj.get("version", SESSION_VERSION)),
name=str(obj.get("name", "")),
config=obj.get("config"),
bridges=obj.get("bridges"),
apply_startup_bridges=bool(obj.get("apply_startup_bridges")),
routing_table=routing_raw,
apply_startup_subscriptions=bool(apply_startup),
expect=expect,
)
@ -224,15 +228,17 @@ class SessionReplayer:
def run(self) -> DeterministicScenario:
meta = self.session.meta
bridges = bridges_from_json(meta.bridges) if meta.bridges else None
routing_table = (
bridges_from_json(meta.routing_table) if meta.routing_table else None
)
scenario = DeterministicScenario(
config=meta.config or minimal_config(("MASTER-A", "MASTER-B")),
bridges=bridges,
routing_table=routing_table,
)
if meta.apply_startup_bridges:
scenario.bridge.apply_startup_bridges()
if meta.apply_startup_subscriptions:
scenario.routing.apply_startup_subscriptions()
with patch_bridge_wall_time(scenario.clock):
with patch_routing_wall_time(scenario.clock):
for event in self.session.events:
if event.dt:
scenario.clock.advance(event.dt)
@ -334,12 +340,12 @@ class SessionCapture:
"type": "meta",
"version": self.meta.version,
"name": self.meta.name,
"apply_startup_bridges": self.meta.apply_startup_bridges,
"apply_startup_subscriptions": self.meta.apply_startup_subscriptions,
}
if self.meta.config is not None:
meta_row["config"] = config_to_json(self.meta.config)
if self.meta.bridges is not None:
meta_row["bridges"] = _json_sanitize(self.meta.bridges)
if self.meta.routing_table is not None:
meta_row["routing_table"] = _json_sanitize(self.meta.routing_table)
if self.meta.expect.forwards or self.meta.expect.dst_id is not None:
meta_row["expect"] = {
"forwards": self.meta.expect.forwards,

@ -6,7 +6,7 @@ from typing import Any, Iterator
from adn_server.application.voice_use_cases import VoiceUseCases
from adn_server.domain import bytes_3, bytes_4
from tests.harness.deterministic import DeterministicScenario, FakeHbpProtocol, active_bridge
from tests.harness.deterministic import DeterministicScenario, FakeHbpProtocol, active_routing_table
class FakeVoiceProvider:
@ -51,8 +51,8 @@ def voice_master_scenario(tg: int = 91) -> tuple[DeterministicScenario, FakeMast
config["SYSTEMS"]["MASTER-A"]["PEERS"] = {
"1001": {"CALLSIGN": "TEST", "IP": "127.0.0.1", "PORT": 62032},
}
bridges = active_bridge(tg, (("MASTER-A", 2),))
scenario = DeterministicScenario(config=config, bridges=bridges)
bridges = active_routing_table(tg, (("MASTER-A", 2),))
scenario = DeterministicScenario(config=config, routing_table=bridges)
scenario.protocols["MASTER-A"] = master
master.STATUS[2] = {
"RX_TYPE": 2,
@ -62,7 +62,7 @@ def voice_master_scenario(tg: int = 91) -> tuple[DeterministicScenario, FakeMast
return scenario, master
def reflector_bridge_entry(system: str = "MASTER-A", reflector: int = 310) -> dict[str, list[dict[str, Any]]]:
def reflector_routing_entry(system: str = "MASTER-A", reflector: int = 310) -> dict[str, list[dict[str, Any]]]:
return {
"#{}".format(reflector): [
{
@ -97,7 +97,7 @@ def make_voice_uc(
FakeVoiceProvider(),
scenario.config,
get_protocols=lambda: {"MASTER-A": master},
get_bridges=scenario.bridge.get_bridges,
routing_table_for_report=scenario.routing.routing_table_for_report,
call_later=call_later,
audio_path=audio_path,
)

@ -4,13 +4,13 @@ from __future__ import annotations
import pytest
from tests.harness.assertions import assert_capture_unchanged, assert_inject_ok
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_routing_table
def test_hbp_loop_loser_when_other_hbp_owns_stream() -> None:
"""Second HBP source loses when another MASTER slot already has RX_STREAM_ID."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
stream_id = 0x41414141
scenario.seed_hbp_slot_stream("MASTER-B", 2, stream_id, tgid=91)
base = PacketSpec(dst_id=91, stream_id=stream_id, slot=2)
@ -28,8 +28,8 @@ def test_hbp_loop_loser_when_other_hbp_owns_stream() -> None:
@pytest.mark.behavior
def test_hbp_loop_winner_forwards_until_loser_detected() -> None:
"""Regression: VHEAD bridges; loop loser burst does not add forwards."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
stream_id = 0x42424242
scenario.seed_hbp_slot_stream("MASTER-B", 2, stream_id, tgid=91)
base = PacketSpec(dst_id=91, stream_id=stream_id, slot=2)

@ -4,16 +4,16 @@ from __future__ import annotations
import pytest
from tests.harness.assertions import assert_capture_unchanged
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_routing_table
import adn_server.application.bridge_use_cases as buc
import adn_server.application.routing_use_cases as buc
@pytest.mark.behavior
def test_hbp_rate_limit_drops_excessive_ingress_rate() -> None:
"""Regression: excessive ingress rate triggers drop; post-drop forwards stall."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
base = PacketSpec(dst_id=91, stream_id=0x55667788)
t0 = scenario.clock.time()
@ -48,8 +48,8 @@ def test_hbp_rate_limit_drops_excessive_ingress_rate() -> None:
def test_hbp_rate_limit_uses_ingress_time_not_wall_clock() -> None:
"""Rate control must use ingress_pkt_time even when wall clock is frozen."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
base = PacketSpec(dst_id=91, stream_id=0x34343434)
t0 = 1_700_000_200.0

@ -6,15 +6,15 @@ from tests.harness.assertions import assert_inject_ok
from tests.harness.deterministic import (
DeterministicScenario,
PacketSpec,
active_bridge,
active_routing_table,
add_openbridge_system,
)
def test_hbp_ingress_sets_rx_start_on_new_stream() -> None:
"""New HBP group stream records RX_START from ingress_pkt_time."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
base = PacketSpec(dst_id=91, stream_id=0x12121212)
t0 = 1_700_000_100.0
@ -31,8 +31,8 @@ def test_hbp_ingress_sets_rx_start_on_new_stream() -> None:
def test_hbp_rate_drop_prevents_bridge_forward() -> None:
"""After ingress RATE DROP, no further packets are bridged."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
base = PacketSpec(dst_id=91, stream_id=0x56565656)
t0 = scenario.clock.time()
@ -65,11 +65,11 @@ def test_hbp_rate_drop_prevents_bridge_forward() -> None:
def test_hbp_loop_loser_when_obp_already_has_stream() -> None:
"""Regression: HBP loses loop when OBP already owns stream_id; no bridge forward."""
bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2)))
bridges = active_routing_table(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)
scenario = DeterministicScenario(config=config, routing_table=bridges)
stream_id = 0x77778888
scenario.seed_obp_stream("OBP-CL", stream_id, tgid=52090)

@ -4,15 +4,15 @@ 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
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_routing_table
from adn_server.domain import bytes_3, bytes_4
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD
def test_hbp_source_timeout_drops_after_180_seconds() -> None:
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
base = PacketSpec(dst_id=91, stream_id=0x90909090)
t0 = scenario.clock.time()
@ -32,8 +32,8 @@ def test_hbp_source_timeout_drops_after_180_seconds() -> None:
def test_hbp_stream_collision_drops_conflicting_new_stream() -> None:
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
t0 = scenario.clock.time()
slot = scenario.protocols["MASTER-A"].STATUS[2]
slot.update(
@ -61,8 +61,8 @@ def test_hbp_stream_collision_drops_conflicting_new_stream() -> None:
@pytest.mark.behavior
def test_hbp_collision_allows_same_subscriber_rekey() -> None:
"""Regression: same RF source may start a new stream while prior call is still open."""
bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(bridges=bridges)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
t0 = scenario.clock.time()
rf_src = 3120001
slot = scenario.protocols["MASTER-A"].STATUS[2]

@ -0,0 +1,26 @@
"""ACL range checks (legacy acl_check parity)."""
from __future__ import annotations
from adn_server.infrastructure.acl_router import InMemoryAclRouter
def test_acl_permit_in_range() -> None:
router = InMemoryAclRouter()
assert router.acl_check(100, (True, [(1, 200)])) is True
def test_acl_deny_out_of_range() -> None:
router = InMemoryAclRouter()
assert router.acl_check(300, (True, [(1, 200)])) is False
def test_acl_invert_action() -> None:
router = InMemoryAclRouter()
assert router.acl_check(50, (False, [(1, 100)])) is False
assert router.acl_check(150, (False, [(1, 100)])) is True
def test_acl_bytes_id() -> None:
router = InMemoryAclRouter()
assert router.acl_check(b"\x00\x00\x64", (True, [(1, 200)])) is True

@ -1,115 +0,0 @@
"""BRIDGES source index parity with legacy full scan."""
from __future__ import annotations
from adn_server.domain import bytes_3, int_id
from adn_server.infrastructure.bridge_router_impl import InMemoryBridgeRouter
def _legacy_scan_tables(
bridges: dict,
system_name: str,
bridge_match_slot: int,
dst_id_b: bytes,
dst_int: int,
) -> list[str]:
result: list[str] = []
def row_is_active_source(row: dict) -> bool:
return bool(
row.get("SYSTEM") == system_name
and row.get("TS") == bridge_match_slot
and row.get("ACTIVE")
and (
row.get("TGID") == dst_id_b
or int_id(row.get("TGID") or b"\x00\x00\x00") == dst_int
)
)
for bridge_name, rows in bridges.items():
if any(row_is_active_source(r) for r in rows):
result.append(bridge_name)
return result
def test_index_matches_legacy_scan_static_tg() -> None:
router = InMemoryBridgeRouter()
tgid = bytes_3(91)
router.set_bridges(
{
"91": [
{"SYSTEM": "MASTER-A", "TS": 2, "TGID": tgid, "ACTIVE": True},
{"SYSTEM": "MASTER-B", "TS": 2, "TGID": tgid, "ACTIVE": True},
]
}
)
dst_b = tgid
dst_int = 91
legacy = _legacy_scan_tables(router.get_bridges(), "MASTER-A", 2, dst_b, dst_int)
indexed = router.bridge_tables_with_active_source("MASTER-A", 2, dst_int)
assert indexed == legacy == ["91"]
def test_index_matches_legacy_scan_multiple_tables() -> None:
router = InMemoryBridgeRouter()
tgid = bytes_3(310)
router.set_bridges(
{
"91": [
{"SYSTEM": "MASTER-A", "TS": 2, "TGID": bytes_3(91), "ACTIVE": True},
{"SYSTEM": "MASTER-B", "TS": 2, "TGID": bytes_3(91), "ACTIVE": True},
],
"#310": [
{"SYSTEM": "MASTER-A", "TS": 2, "TGID": tgid, "ACTIVE": True},
{"SYSTEM": "OBP-1", "TS": 1, "TGID": tgid, "ACTIVE": True},
],
}
)
legacy = _legacy_scan_tables(router.get_bridges(), "MASTER-A", 2, tgid, 310)
indexed = router.bridge_tables_with_active_source("MASTER-A", 2, 310)
assert indexed == legacy == ["#310"]
def test_index_empty_when_no_active_source() -> None:
router = InMemoryBridgeRouter()
router.set_bridges(
{
"91": [
{"SYSTEM": "MASTER-A", "TS": 2, "TGID": bytes_3(91), "ACTIVE": False},
{"SYSTEM": "MASTER-B", "TS": 2, "TGID": bytes_3(91), "ACTIVE": True},
]
}
)
assert router.bridge_tables_with_active_source("MASTER-A", 2, 91) == []
def test_index_updates_after_in_place_mutation() -> None:
router = InMemoryBridgeRouter()
bridges = router.get_bridges()
bridges["91"] = [
{"SYSTEM": "MASTER-A", "TS": 2, "TGID": bytes_3(91), "ACTIVE": False},
{"SYSTEM": "MASTER-B", "TS": 2, "TGID": bytes_3(91), "ACTIVE": True},
]
assert router.bridge_tables_with_active_source("MASTER-A", 2, 91) == []
bridges["91"][0]["ACTIVE"] = True
legacy = _legacy_scan_tables(bridges, "MASTER-A", 2, bytes_3(91), 91)
assert router.bridge_tables_with_active_source("MASTER-A", 2, 91) == legacy == ["91"]
def test_index_many_bridge_tables() -> None:
"""Regression: index order and membership match legacy scan with many tables."""
router = InMemoryBridgeRouter()
tgid = bytes_3(500)
bridges: dict[str, list] = {}
for i in range(120):
bridges[str(1000 + i)] = [
{"SYSTEM": "MASTER-X", "TS": 1, "TGID": bytes_3(1000 + i), "ACTIVE": True},
]
bridges["500"] = [
{"SYSTEM": "MASTER-A", "TS": 2, "TGID": tgid, "ACTIVE": True},
{"SYSTEM": "MASTER-B", "TS": 2, "TGID": tgid, "ACTIVE": True},
]
router.set_bridges(bridges)
legacy = _legacy_scan_tables(router.get_bridges(), "MASTER-A", 2, tgid, 500)
indexed = router.bridge_tables_with_active_source("MASTER-A", 2, 500)
assert indexed == legacy == ["500"]

@ -55,7 +55,7 @@ def test_start_applies_username_password(monkeypatch):
fake,
)
pub = PahoReportMqttPublisher(_settings())
pub.start(wire=object(), get_systems=lambda: {}, get_bridges=lambda: {})
pub.start(wire=object(), get_systems=lambda: {}, routing_table_for_report=lambda: {})
assert pub._client is not None
assert pub._client.auth == ("ops", "secret")
@ -68,6 +68,6 @@ def test_start_tls_with_cafile(monkeypatch):
)
broker = MqttBroker(host="secure", port=8883, use_tls=True, display_url="mqtts://secure:8883")
pub = PahoReportMqttPublisher(_settings(broker=broker, cafile="/etc/ssl/certs/ca.pem"))
pub.start(wire=object(), get_systems=lambda: {}, get_bridges=lambda: {})
pub.start(wire=object(), get_systems=lambda: {}, routing_table_for_report=lambda: {})
assert pub._client is not None
assert pub._client.tls == {"ca_certs": "/etc/ssl/certs/ca.pem"}

@ -17,8 +17,8 @@ class _RecordingMqtt:
self.stopped = False
self.started = False
def start(self, wire: Any, get_systems: Any, get_bridges: Any) -> None:
del wire, get_systems, get_bridges
def start(self, wire: Any, get_systems: Any, routing_table_for_report: Any) -> None:
del wire, get_systems, routing_table_for_report
self.started = True
def publish_frames(self, frames: tuple[bytes, ...]) -> None:

@ -94,7 +94,7 @@ class _FakeReportFactory:
def set_systems(self, *_a, **_k):
pass
def set_bridges(self, *_a, **_k):
def set_routing_table(self, *_a, **_k):
pass
def set_config(self, *_a, **_k):

@ -4,7 +4,7 @@ from __future__ import annotations
from unittest.mock import MagicMock
from adn_server.application.bridge.helpers import (
from adn_server.application.routing.helpers import (
export_peer_ua_sessions,
register_peer_ua_session,
)
@ -47,7 +47,7 @@ def test_disconnect_clears_internal_sessions_without_bridge_event() -> None:
proto._on_peer_disconnected(peer_id)
report.send_bridge_event.assert_not_called()
report.send_routing_event.assert_not_called()
assert export_peer_ua_sessions(sys_cfg, peer_id, now=1_000_100.0) == {}

@ -16,8 +16,8 @@ class RecordingMqtt(ReportMqttPublisher):
self.dashboard_calls = 0
self.started = False
def start(self, wire: Any, get_systems: Any, get_bridges: Any) -> None:
del wire, get_systems, get_bridges
def start(self, wire: Any, get_systems: Any, routing_table_for_report: Any) -> None:
del wire, get_systems, routing_table_for_report
self.started = True
def publish_frames(self, frames: tuple[bytes, ...]) -> None:
@ -41,20 +41,20 @@ def test_send_config_publishes_state_not_tcp_wire():
assert mqtt.dashboard_calls == 1
def test_send_bridge_does_not_mirror_tcp_wire_to_mqtt():
def test_send_routing_table_does_not_mirror_tcp_wire_to_mqtt():
mqtt = RecordingMqtt()
factory = ReportServerFactory({"REPORTS": {}}, mqtt=mqtt)
factory.set_bridges({"73010": {"ACTIVE": True}})
factory.send_bridge()
factory.set_routing_table({"73010": {"ACTIVE": True}})
factory.send_routing_table()
assert mqtt.frames == []
def test_send_bridge_event_publishes_voice_event_to_mqtt():
def test_send_routing_event_publishes_voice_event_to_mqtt():
mqtt = RecordingMqtt()
factory = ReportServerFactory({"REPORTS": {"PROTOCOL": "v2"}}, mqtt=mqtt)
factory.set_systems({})
factory.set_bridges({})
factory.send_bridge_event("GROUP VOICE,START,RX,MASTER-A,2155905152,1001,3120001,2,52090")
factory.set_routing_table({})
factory.send_routing_event("GROUP VOICE,START,RX,MASTER-A,2155905152,1001,3120001,2,52090")
assert len(mqtt.frames) == 1
frame = mqtt.frames[0][0]
assert frame[:1] == REPORT_OPCODES["VOICE_EVENT_SND"]

@ -30,7 +30,7 @@ def test_connect_sends_hello_topology_and_routing() -> None:
"MASTER-A": {"MODE": "MASTER", "ENABLED": True, "IP": "10.0.0.1", "PORT": 62030},
}
)
factory.set_bridges(
factory.set_routing_table(
{
"52090": [
{"SYSTEM": "MASTER-A", "TS": 2, "TGID": 52090, "ACTIVE": True, "TO_TYPE": "ON"},
@ -54,7 +54,7 @@ def test_bridge_event_emits_voice_event_json() -> None:
factory = ReportServerFactory(_report_config())
client = _CapturingClient()
factory.clients.append(client)
factory.send_bridge_event("GROUP VOICE,START,RX,MASTER-A,2155905152,1001,3120001,2,52090")
factory.send_routing_event("GROUP VOICE,START,RX,MASTER-A,2155905152,1001,3120001,2,52090")
assert len(client.messages) == 1
assert client.messages[0][:1] == REPORT_OPCODES["VOICE_EVENT_SND"]
voice = json.loads(client.messages[0][1:].decode())
@ -65,7 +65,7 @@ def test_bridge_event_emits_voice_event_json() -> None:
def test_incremental_bridge_update_sends_delta() -> None:
factory = ReportServerFactory(_report_config())
factory.set_bridges(
factory.set_routing_table(
{
"52090": [
{"SYSTEM": "MASTER-A", "TS": 2, "TGID": 52090, "ACTIVE": True, "TO_TYPE": "ON"},
@ -74,15 +74,15 @@ def test_incremental_bridge_update_sends_delta() -> None:
)
client = _CapturingClient()
factory.clients.append(client)
factory.send_bridge(incremental=False)
factory.set_bridges(
factory.send_routing_table(incremental=False)
factory.set_routing_table(
{
"52090": [
{"SYSTEM": "MASTER-A", "TS": 2, "TGID": 52090, "ACTIVE": False, "TO_TYPE": "ON"},
],
}
)
factory.send_bridge(incremental=True)
factory.send_routing_table(incremental=True)
assert len(client.messages) == 2
assert client.messages[0][:1] == REPORT_OPCODES["ROUTING_TABLE_SND"]
assert client.messages[1][:1] == REPORT_OPCODES["DELTA_SND"]
@ -133,7 +133,7 @@ def test_inject_proxy_topology_expanded_for_monitor() -> None:
assert 730039101 in peer_keys
def test_send_bridge_event_remaps_inject_proxy_system_name() -> None:
def test_send_routing_event_remaps_inject_proxy_system_name() -> None:
peer = bytes_4(730039101)
config = {
"REPORTS": {"REPORT": True, "REPORT_CLIENTS": ["*"]},
@ -160,7 +160,7 @@ def test_send_bridge_event_remaps_inject_proxy_system_name() -> None:
factory.set_peer_slot_map(lambda: {peer: 4})
client = _CapturingClient()
factory.clients.append(client)
factory.send_bridge_event(
factory.send_routing_event(
"GROUP VOICE,START,TX,SYSTEM,2693411696,9990,7300392,2,9990"
)
assert len(client.messages) == 1

@ -0,0 +1,68 @@
"""Hot-path indexes on InMemorySubscriptionStore."""
from __future__ import annotations
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.infrastructure.subscription_store import InMemorySubscriptionStore
def _row(*, system: str, ts: int, tgid: int, active: bool) -> dict:
return {
"SYSTEM": system,
"TS": ts,
"TGID": bytes_3(tgid),
"ACTIVE": active,
"TIMEOUT": 600.0,
"TO_TYPE": "OFF" if active else "ON",
"ON": [bytes_3(tgid)],
"OFF": [],
"RESET": [],
"TIMER": 0.0,
}
def test_relay_tables_index_matches_full_scan() -> None:
bridges = {
"7147": [
_row(system="OBP-CL", ts=1, tgid=7147, active=True),
_row(system="SYSTEM", ts=2, tgid=7147, active=True),
]
}
store = InMemorySubscriptionStore()
store.replace_all(subscriptions_from_routing_table(bridges))
router = SubscriptionRouter(store)
assert store.relay_tables_with_active_source("OBP-CL", 1, 7147) == ("7147",)
assert router.relay_tables_with_active_source("OBP-CL", 1, 7147) == ("7147",)
def test_has_active_target_leg_tracks_upsert() -> None:
store = InMemorySubscriptionStore()
store.replace_all(subscriptions_from_routing_table({"7147": [_row(system="SYSTEM", ts=2, tgid=7147, active=True)]}))
assert store.has_active_target_leg("SYSTEM", 2, 7147) is True
sub = store.snapshot()[0]
from dataclasses import replace
idle = replace(sub, state=replace(sub.state, phase=SubscriptionPhase.IDLE))
store.upsert(idle)
assert store.has_active_target_leg("SYSTEM", 2, 7147) is False
def test_resolve_uses_table_index() -> None:
bridges = {
"7147": [
_row(system="OBP-CL", ts=1, tgid=7147, active=True),
_row(system="SYSTEM", ts=2, tgid=7147, active=True),
]
}
store = InMemorySubscriptionStore()
store.replace_all(subscriptions_from_routing_table(bridges))
legs = SubscriptionRouter(store).resolve(
VoiceIngress(source_system="OBP-CL", slot=1, dst_tgid=TgId(7147), source_is_obp=True)
)
assert len(legs) == 1
assert legs[0].target_system == "SYSTEM"

@ -7,9 +7,9 @@ from tests.harness.deterministic import (
FakeReportFactory,
FakeReportSender,
PacketSpec,
active_bridge,
active_routing_table,
add_openbridge_system,
patch_bridge_wall_time,
patch_routing_wall_time,
)
from tests.harness.scenarios import obp_bridge_scenario
@ -22,7 +22,7 @@ def test_obp_first_packet_bypasses_rate_control() -> None:
scenario = obp_bridge_scenario("OBP-CL")
base = PacketSpec(dst_id=52090, stream_id=0xAABBCCDD, slot=1)
with patch_bridge_wall_time(scenario.clock):
with patch_routing_wall_time(scenario.clock):
ok = scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base))
assert ok is not False
obp_st = scenario.protocols["OBP-CL"].STATUS[bytes_4(base.stream_id)]
@ -35,7 +35,7 @@ def test_obp_loop_loser_drops_second_obp_source() -> None:
stream_id = 0x01020304
base = PacketSpec(dst_id=52090, stream_id=stream_id, slot=1)
with patch_bridge_wall_time(scenario.clock):
with patch_routing_wall_time(scenario.clock):
scenario.inject_obp("OBP-A", DeterministicScenario.voice_head_spec(base))
scenario.inject_obp(
"OBP-A",
@ -51,17 +51,17 @@ def test_obp_loop_loser_drops_second_obp_source() -> None:
def test_obp_loop_loser_sends_bcsq_once_when_enhanced() -> None:
"""Loop loser sends BCSQ once (not on every subsequent packet)."""
bridges = active_bridge(52090, (("OBP-A", 1), ("OBP-B", 1), ("MASTER-A", 2)))
bridges = active_routing_table(52090, (("OBP-A", 1), ("OBP-B", 1), ("MASTER-A", 2)))
config = DeterministicScenario().config
add_openbridge_system(config, "OBP-A")
add_openbridge_system(config, "OBP-B", enhanced=True)
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario = DeterministicScenario(config=config, routing_table=bridges)
stream_id = 0x0D0E0F10
base = PacketSpec(dst_id=52090, stream_id=stream_id, slot=1)
with patch_bridge_wall_time(scenario.clock):
with patch_routing_wall_time(scenario.clock):
scenario.inject_obp("OBP-A", DeterministicScenario.voice_head_spec(base))
scenario.inject_obp(
"OBP-A",
@ -90,12 +90,12 @@ def test_obp_vterm_sets_fin_and_drops_late_packets() -> None:
scenario.config.setdefault("REPORTS", {})["REPORT"] = True
scenario.report_factory = FakeReportFactory()
scenario.reporting = ReportingUseCases(FakeReportSender(scenario.report_factory), scenario.config)
scenario.bridge._reporting = scenario.reporting
scenario.routing._reporting = scenario.reporting
base = PacketSpec(dst_id=52090, stream_id=0x99887766, slot=1)
sid = bytes_4(base.stream_id)
with patch_bridge_wall_time(scenario.clock):
with patch_routing_wall_time(scenario.clock):
scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base))
scenario.inject_obp(
"OBP-CL",

@ -5,21 +5,21 @@ from __future__ import annotations
from tests.harness.deterministic import (
DeterministicScenario,
PacketSpec,
active_bridge,
active_routing_table,
add_openbridge_system,
patch_bridge_wall_time,
patch_routing_wall_time,
)
def test_obp_rate_limit_uses_start_epoch_not_elapsed() -> None:
"""Regression: OBP must not RATE DROP at normal cadence (packets/START)."""
bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2)))
bridges = active_routing_table(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)
scenario = DeterministicScenario(config=config, routing_table=bridges)
with patch_bridge_wall_time(scenario.clock):
with patch_routing_wall_time(scenario.clock):
base = PacketSpec(dst_id=52090, stream_id=0x11223344, slot=1)
scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base))
accepted = 0

@ -4,7 +4,7 @@ from __future__ import annotations
import pytest
from tests.harness.assertions import assert_not_forwarded
from tests.harness.deterministic import DeterministicScenario, PacketSpec, patch_bridge_wall_time
from tests.harness.deterministic import DeterministicScenario, PacketSpec, patch_routing_wall_time
from tests.harness.scenarios import obp_bridge_scenario
from adn_server.domain import bytes_4
@ -18,7 +18,7 @@ def test_obp_unit_data_loop_loser_when_hbp_owns_stream() -> None:
scenario.seed_hbp_slot_stream("MASTER-A", 2, stream_id, tgid=52090)
base = PacketSpec(call_type="unit", dst_id=1000001, stream_id=stream_id, slot=1)
with patch_bridge_wall_time(scenario.clock):
with patch_routing_wall_time(scenario.clock):
scenario.inject_obp("OBP-CL", DeterministicScenario.unit_data_header_spec(base))
assert_not_forwarded(scenario, "MASTER-B")
@ -33,7 +33,7 @@ def test_obp_unit_data_loop_loser_for_second_obp_source() -> None:
stream_id = 0x72727272
base = PacketSpec(call_type="unit", dst_id=1000001, stream_id=stream_id, slot=1)
with patch_bridge_wall_time(scenario.clock):
with patch_routing_wall_time(scenario.clock):
scenario.inject_obp("OBP-A", DeterministicScenario.unit_data_header_spec(base))
scenario.inject_obp("OBP-B", DeterministicScenario.unit_data_header_spec(base))

@ -4,7 +4,7 @@ from __future__ import annotations
import pytest
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge, minimal_config
from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_routing_table, minimal_config
from tests.harness.session_replay import (
FIXTURES_DIR,
IngressEvent,
@ -24,7 +24,7 @@ _HBP_FIXTURE = FIXTURES_DIR / "hbp_group_voice_short.jsonl"
def test_load_session_parses_meta_and_ingress() -> None:
session = load_session(_HBP_FIXTURE)
assert session.meta.name == "hbp_group_voice_short"
assert session.meta.apply_startup_bridges is True
assert session.meta.apply_startup_subscriptions is True
assert len(session.events) == 2
assert session.meta.expect.forwards["MASTER-B"] == 2
@ -41,10 +41,10 @@ def test_replay_matches_inline_harness() -> None:
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)))
bridges = active_routing_table(52090, (("MASTER-A", 2), ("MASTER-B", 2)))
inline = DeterministicScenario(config=config, bridges=bridges)
inline.bridge.apply_startup_bridges()
inline = DeterministicScenario(config=config, routing_table=bridges)
inline.routing.apply_startup_subscriptions()
base = PacketSpec(dst_id=52090, stream_id=0x80808080, slot=2)
inline.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
inline.inject_hbp(
@ -60,11 +60,11 @@ def test_replay_matches_inline_harness() -> None:
@pytest.mark.behavior
def test_capture_roundtrip(tmp_path) -> None:
"""SessionCapture writes JSONL that SessionReplayer can load."""
bridges = bridges_to_json(active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))))
bridges = bridges_to_json(active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2))))
meta = SessionMeta(
name="roundtrip",
bridges=bridges,
apply_startup_bridges=False,
routing_table=bridges,
apply_startup_subscriptions=False,
expect=SessionExpect(forwards={"MASTER-B": 1}, dst_id=91),
)
cap = SessionCapture(meta=meta)
@ -91,14 +91,14 @@ def test_capture_startup_voice_session() -> None:
config = minimal_config(("MASTER-A", "MASTER-B"))
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090"
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10
bridges = bridges_to_json(active_bridge(52090, (("MASTER-A", 2), ("MASTER-B", 2))))
bridges = bridges_to_json(active_routing_table(52090, (("MASTER-A", 2), ("MASTER-B", 2))))
base = {"dst_id": 52090, "stream_id": 0x80808080, "slot": 2, "peer_id": 1001, "rf_src": 3120001}
cap = SessionCapture(
meta=SessionMeta(
name="capture_startup_voice",
config=config,
bridges=bridges,
apply_startup_bridges=True,
routing_table=bridges,
apply_startup_subscriptions=True,
expect=SessionExpect(forwards={"MASTER-B": 2}, dst_id=52090),
)
)

@ -39,7 +39,7 @@ def test_merge_system_config_preserves_peers_and_static_tgs() -> None:
def test_merge_new_master_keeps_yaml_static_tg() -> None:
"""New MASTER on reload keeps YAML static lists (apply_startup_bridges materializes them)."""
"""New MASTER on reload keeps YAML static lists (apply_startup_subscriptions materializes them)."""
new = {
"MODE": "MASTER",
"ENABLED": True,

@ -7,15 +7,15 @@ from tests.harness.assertions import assert_forwarded, assert_inject_ok
from tests.harness.deterministic import (
DeterministicScenario,
PacketSpec,
active_bridge,
active_routing_table,
add_openbridge_system,
patch_bridge_wall_time,
patch_routing_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)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
base = PacketSpec(dst_id=91, stream_id=0xABCD1234)
burst = DeterministicScenario.voice_burst_spec(base, seq=2, dtype_vseq=2)
t0 = scenario.clock.time()
@ -33,8 +33,8 @@ def test_hbp_crc_dedup_drops_duplicate_seq_gt_zero() -> None:
@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)
bridges = active_routing_table(91, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario = DeterministicScenario(routing_table=bridges)
base = PacketSpec(dst_id=91, stream_id=0xDEADBEEF, seq=0)
vhead = DeterministicScenario.voice_head_spec(base)
t0 = scenario.clock.time()
@ -47,15 +47,15 @@ def test_hbp_crc_dedup_allows_seq_zero_repeat() -> None:
def test_obp_crc_dedup_drops_duplicate_seq_gt_zero() -> None:
bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2)))
bridges = active_routing_table(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)
scenario = DeterministicScenario(config=config, routing_table=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):
with patch_routing_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)

@ -8,7 +8,7 @@ from tests.harness.deterministic import DeterministicScenario, PacketSpec, minim
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 _make_echo_bridges
from adn_server.infrastructure.bootstrap.peer_server import _seed_echo_routing_table
def _echo_scenario_config() -> dict:
@ -47,7 +47,7 @@ def _echo_scenario_config() -> dict:
def _bridges_after_bridgereset(config: dict) -> dict:
bridges = _make_echo_bridges(config)
bridges = _seed_echo_routing_table(config)
for entry in bridges["9990"]:
if entry["SYSTEM"] == "ECHO":
entry["ACTIVE"] = False
@ -64,11 +64,11 @@ def test_bridge_reset_restores_echo_leg_with_use_acl() -> None:
config = _echo_scenario_config()
config["SYSTEMS"]["ECHO"]["_reset"] = True
bridges = _bridges_after_bridgereset(config)
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario = DeterministicScenario(config=config, routing_table=bridges)
scenario.bridge.bridge_reset_loop()
scenario.routing.subscription_reset_loop()
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
echo_leg = next(e for e in scenario.routing.routing_table_for_report()["9990"] if e["SYSTEM"] == "ECHO")
assert echo_leg["ACTIVE"] is True
assert echo_leg["TO_TYPE"] == "NONE"
@ -79,11 +79,11 @@ def test_options_config_restores_echo_after_bridgereset() -> None:
echo_cfg = config["SYSTEMS"]["ECHO"]
echo_cfg["OPTIONS"] = "TS2=9990;"
bridges = _bridges_after_bridgereset(config)
scenario = DeterministicScenario(config=config, bridges=bridges)
scenario = DeterministicScenario(config=config, routing_table=bridges)
scenario.bridge.options_config_for_system("ECHO")
scenario.routing.options_config_for_system("ECHO")
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
echo_leg = next(e for e in scenario.routing.routing_table_for_report()["9990"] if e["SYSTEM"] == "ECHO")
assert echo_leg["ACTIVE"] is True
assert echo_leg["TO_TYPE"] == "NONE"
@ -95,8 +95,8 @@ def test_echo_voice_forwards_after_bridgereset_and_options() -> None:
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")
scenario = DeterministicScenario(config=config, routing_table=bridges)
scenario.routing.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))
@ -116,11 +116,11 @@ def test_bridge_reset_with_yaml_tgid_ts_acl_only() -> None:
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 = DeterministicScenario(config=config, routing_table=bridges)
scenario.bridge.bridge_reset_loop()
scenario.routing.subscription_reset_loop()
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
echo_leg = next(e for e in scenario.routing.routing_table_for_report()["9990"] if e["SYSTEM"] == "ECHO")
assert echo_leg["ACTIVE"] is True
assert echo_leg["TO_TYPE"] == "NONE"
@ -133,9 +133,9 @@ def test_bridge_reset_acl_uses_processed_tg2_acl_not_yaml_string() -> None:
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 = DeterministicScenario(config=config, routing_table=bridges)
scenario.bridge.bridge_reset_loop()
scenario.routing.subscription_reset_loop()
echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO")
echo_leg = next(e for e in scenario.routing.routing_table_for_report()["9990"] if e["SYSTEM"] == "ECHO")
assert echo_leg["ACTIVE"] is True

@ -12,11 +12,11 @@ def test_options_config_for_system_applies_static_tg_from_yaml_options() -> None
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = ""
scenario = DeterministicScenario(config=config)
scenario.bridge.options_config_for_system("MASTER-A")
scenario.routing.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()
assert "52090" in scenario.routing.routing_table_for_report()
def test_options_config_for_system_skips_master_without_options() -> None:
@ -26,8 +26,8 @@ def test_options_config_for_system_skips_master_without_options() -> None:
config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] = ""
scenario = DeterministicScenario(config=config)
scenario.bridge.options_config_for_system("MASTER-A")
scenario.bridge.options_config_for_system("MASTER-B")
scenario.routing.options_config_for_system("MASTER-A")
scenario.routing.options_config_for_system("MASTER-B")
assert scenario.config["SYSTEMS"]["MASTER-A"].get("TS2_STATIC", "") == ""
assert scenario.config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] == "91"
@ -41,7 +41,7 @@ def test_apply_startup_runs_options_config_for_each_master() -> None:
config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = ""
scenario = DeterministicScenario(config=config)
scenario.bridge.apply_startup_bridges()
scenario.routing.apply_startup_subscriptions()
assert scenario.config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] == "52090"
assert "52090" in scenario.bridge.get_bridges()
assert "52090" in scenario.routing.routing_table_for_report()

@ -2,8 +2,8 @@
from __future__ import annotations
from adn_server.infrastructure.bootstrap.peer_server import _make_echo_bridges
from tests.bridge.test_echo_bridgereset import _echo_scenario_config
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
@ -17,8 +17,8 @@ def _prod_like_config() -> dict:
def test_parrot_routing_missing_without_echo_store_seed() -> None:
scenario = DeterministicScenario(config=_prod_like_config(), bridges={})
scenario.bridge.apply_startup_bridges()
scenario = DeterministicScenario(config=_prod_like_config(), routing_table={})
scenario.routing.apply_startup_subscriptions()
base = PacketSpec(dst_id=9990, stream_id=0xABCDEF01, slot=2)
scenario.inject_hbp("SYSTEM", DeterministicScenario.voice_head_spec(base))
@ -28,8 +28,8 @@ def test_parrot_routing_missing_without_echo_store_seed() -> None:
def test_parrot_routing_works_after_echo_seed_on_vhead() -> None:
config = _prod_like_config()
scenario = DeterministicScenario(config=config, bridges=_make_echo_bridges(config))
scenario.bridge.apply_startup_bridges()
scenario = DeterministicScenario(config=config, routing_table=_seed_echo_routing_table(config))
scenario.routing.apply_startup_subscriptions()
base = PacketSpec(dst_id=9990, stream_id=0xABCDEF02, slot=2)
scenario.inject_hbp("SYSTEM", DeterministicScenario.voice_head_spec(base))

@ -4,14 +4,14 @@ from __future__ import annotations
from adn_server.application.subscription.router import SubscriptionRouter
from adn_server.application.subscription.store_sync import replace_store_from_bridges
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,
PacketSpec,
active_bridge,
active_routing_table,
)
from adn_server.domain import bytes_3, bytes_4
@ -28,8 +28,8 @@ def _proxy_system_scenario(
sys_cfg.pop("OPTIONS", None)
sys_cfg["TS1_STATIC"] = ""
sys_cfg["TS2_STATIC"] = ""
scenario = DeterministicScenario(config=config, bridges={})
scenario.bridge._get_protocols = lambda: scenario.protocols # noqa: SLF001
scenario = DeterministicScenario(config=config, routing_table={})
scenario.routing._get_protocols = lambda: scenario.protocols # noqa: SLF001
proto = scenario.protocols["MASTER-A"]
assert isinstance(proto, FakeHbpProtocol)
peer_id = bytes_4(1001)
@ -47,23 +47,23 @@ def _proxy_system_scenario(
def test_rpto_single_and_timer_override_yaml() -> None:
scenario = _proxy_system_scenario(single_mode_yaml=False)
scenario.bridge.options_config_for_system(
scenario.routing.options_config_for_system(
"MASTER-A",
peer_options=b"TS2=730444,52090;SINGLE=1;TIMER=5;",
)
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
assert sys_cfg["SINGLE_MODE"] is True
assert scenario.bridge.get_bridges()["730444"][0]["TIMEOUT"] == 300.0
assert scenario.bridge.get_bridges()["52090"][0]["TIMEOUT"] == 300.0
assert scenario.routing.routing_table_for_report()["730444"][0]["TIMEOUT"] == 300.0
assert scenario.routing.routing_table_for_report()["52090"][0]["TIMEOUT"] == 300.0
def test_options_config_reads_connected_peer_without_yaml_options() -> None:
scenario = _proxy_system_scenario(single_mode_yaml=False)
bridges = active_bridge(730444, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario.router.set_bridges(bridges)
bridges = active_routing_table(730444, (("MASTER-A", 2), ("MASTER-B", 2)))
scenario.seed_routing_table(bridges)
scenario.bridge.options_config_for_system("MASTER-A")
scenario.routing.options_config_for_system("MASTER-A")
assert scenario.config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] is True
@ -124,18 +124,18 @@ def test_single_mode_deactivates_other_static_tg_after_rpto() -> None:
},
],
}
scenario.router.set_bridges(bridges)
scenario.bridge.options_config_for_system(
scenario.seed_routing_table(bridges)
scenario.routing.options_config_for_system(
"MASTER-A",
peer_options=b"TS2=730444,52090;SINGLE=1;TIMER=5;",
)
scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(730444), pkt_time=3000.0)
scenario.routing.apply_in_band_signalling("MASTER-A", 2, bytes_3(730444), pkt_time=3000.0)
assert scenario.bridge.get_bridges()["730444"][0]["ACTIVE"] is True
assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is False
assert scenario.routing.routing_table_for_report()["730444"][0]["ACTIVE"] is True
assert scenario.routing.routing_table_for_report()["52090"][0]["ACTIVE"] is False
scenario.bridge._sync_subscription_store()
scenario.routing._sync_subscription_store()
legs = SubscriptionRouter(scenario.subscription_store).resolve(
VoiceIngress(
source_system="MASTER-B",
@ -153,13 +153,13 @@ def test_ua_bridge_creation_pushes_routing_snapshot() -> None:
sent: list[tuple[dict, bool]] = []
class _Rec:
def send_bridge(self, bridges, *, incremental: bool = False) -> None:
def send_routing_table(self, bridges, *, incremental: bool = False) -> None:
sent.append((bridges, incremental))
def send_bridge_event(self, _event: str) -> None:
def send_routing_event(self, _event: str) -> None:
pass
scenario.bridge._reporting = _Rec() # noqa: SLF001
scenario.routing._reporting = _Rec() # noqa: SLF001
base = PacketSpec(dst_id=7304, stream_id=0x22222222, slot=2, peer_id=1001)
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
@ -177,7 +177,7 @@ def test_ua_bridge_uses_transmitting_peer_timer_minutes() -> None:
base = PacketSpec(dst_id=7304, stream_id=0x11111111, slot=2, peer_id=1001)
scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base))
bridges = scenario.bridge.get_bridges()
bridges = scenario.routing.routing_table_for_report()
assert "7304" in bridges
legs = [e for e in bridges["7304"] if e["SYSTEM"] == "MASTER-A" and e["TS"] == 2]
assert len(legs) == 1
@ -189,8 +189,8 @@ def test_peer_timer_does_not_override_other_peer_static_tg_timeout() -> None:
"""Each hotspot TIMER applies only to that peer's static TGs (no max() across peers)."""
config = DeterministicScenario().config
config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 60
scenario = DeterministicScenario(config=config, bridges={})
scenario.bridge._get_protocols = lambda: scenario.protocols # noqa: SLF001
scenario = DeterministicScenario(config=config, routing_table={})
scenario.routing._get_protocols = lambda: scenario.protocols # noqa: SLF001
proto = scenario.protocols["MASTER-A"]
assert isinstance(proto, FakeHbpProtocol)
proto._peers = {
@ -208,23 +208,23 @@ def test_peer_timer_does_not_override_other_peer_static_tg_timeout() -> None:
},
}
scenario.bridge.options_config_for_system("MASTER-A")
scenario.routing.options_config_for_system("MASTER-A")
bridges = scenario.bridge.get_bridges()
bridges = scenario.routing.routing_table_for_report()
assert bridges["730444"][0]["TIMEOUT"] == 300.0 * 60.0
assert bridges["214091"][0]["TIMEOUT"] == 5.0 * 60.0
scenario.bridge.options_config_for_system(
scenario.routing.options_config_for_system(
"MASTER-A",
peer_options=b"TS2=214091;SINGLE=1;TIMER=5;",
)
bridges = scenario.bridge.get_bridges()
bridges = scenario.routing.routing_table_for_report()
assert bridges["730444"][0]["TIMEOUT"] == 300.0 * 60.0
assert bridges["214091"][0]["TIMEOUT"] == 5.0 * 60.0
def test_make_static_tg_preserves_single_mode_deactivation_on_options_refresh() -> None:
"""OPTIONS refresh (make_static_tg) must not re-activate TGs turned off by SINGLE VTERM."""
def test_make_static_tg_rearms_static_legs_like_legacy() -> None:
"""Legacy make_static_tg always sets ACTIVE True (TO_TYPE OFF); SINGLE contention is in-band only."""
scenario = _proxy_system_scenario(single_mode_yaml=True)
bridges = {
"730444": [
@ -256,11 +256,11 @@ def test_make_static_tg_preserves_single_mode_deactivation_on_options_refresh()
}
],
}
scenario.router.set_bridges(bridges)
replace_store_from_bridges(scenario.subscription_store, bridges)
scenario.seed_routing_table(bridges)
replace_store_from_routing_table(scenario.subscription_store, bridges)
scenario.bridge.make_static_tg(730444, 2, 5.0, "MASTER-A")
scenario.bridge.make_static_tg(7305, 2, 5.0, "MASTER-A")
scenario.routing.make_static_tg(730444, 2, 5.0, "MASTER-A")
scenario.routing.make_static_tg(7305, 2, 5.0, "MASTER-A")
assert scenario.bridge.get_bridges()["730444"][0]["ACTIVE"] is False
assert scenario.bridge.get_bridges()["7305"][0]["ACTIVE"] is True
assert scenario.routing.routing_table_for_report()["730444"][0]["ACTIVE"] is True
assert scenario.routing.routing_table_for_report()["7305"][0]["ACTIVE"] is True

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

Loading…
Cancel
Save

Powered by TurnKey Linux.