Merge pull request #1 from ce5rpy/develop

V2 peer server: architecture, report wire, subscription routing, and integrated proxy
pull/4/head v2.0.0-rc.1
ce5rpy 4 months ago committed by GitHub
commit f194a185aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

9
.gitignore vendored

@ -4,13 +4,13 @@
# Production config (use *.example.yaml as template)
adn-server.yaml
adn-parrot.yaml
adn-echo.yaml
adn-voice.yaml
*.local.yaml
*.local.yml
# Local/helper scripts (keep scripts out of git)
scripts/
# Local/helper scripts (repo root only; not tests/scripts/)
/scripts/
# Env and secrets
.env
@ -36,6 +36,9 @@ json/*
# Internal
docs-priv/
# Dev-only session captures; anonymize before promoting to fixtures/
tests/fixtures/sessions/_capture/
# Docs policy: keep source markdown + mkdocs configs in git; ignore generated assets
docs/build-assets/
requirements-docs.txt

@ -1,6 +1,47 @@
# Changelog
All notable changes to **adn-server** are documented here. Versioning follows [SemVer](https://semver.org/).
All notable changes to **adn-server** are documented here.
## [Unreleased]
## [2.0.0-rc.1] - 2026-06-12
First v2 release candidate since **1.0.0** (~70 commits). HBP/OpenBridge on-wire behaviour preserved.
### Added
- **Report v2** — JSON HELLO, slim `dashboard_state` TCP wire (no full topology/routing snapshots to monitor), bounded report queue, optional MQTT.
- **Unified binary** — `adn-server.py` with `--echo`, `--doctor`, `--no-proxy`; wiring in `bootstrap/peer_server.py`.
- **Integrated proxy** — in-process UDP fan-in (`PROXY`), inject-only MASTER, self-service MySQL OPTIONS, peer blacklist/timers parity with legacy proxy.
- **Subscription routing** — `SubscriptionStore` + `SubscriptionRouter` as runtime authority; store-native timers, OPTIONS/static TG, in-band ON/OFF; `MeshCodecRegistry` on OpenBridge.
- **Inject-only production path** — per-peer OPTIONS downlink filter, monitor topology expansion (virtual SYSTEM slots), CONFIG push on peer connect.
- **Performance** — O(1) BRIDGES source index; peer downlink index for inject fan-out; adaptive CONFIG_SND debounce on mass login.
### Changed
- **Architecture** — `bridge_use_cases` split into `routing_use_cases` + mixins; `RuntimeContext` and atomic SIGHUP reload; DMR codecs vendored to `domain/dmr/`; mesh codecs extracted from `udp_hbp`.
- **OPTIONS / static TG** — event-driven refresh (RPTO, startup, reload, dmrd fallback) instead of 26 s periodic loop.
- **Talker Alias** — embed on local REPEAT; passthrough and dedupe fixes on relay path.
### Fixed
- OpenBridge packet control and rate-limit parity with legacy.
- Echo playback when sequence byte wraps; echo TG 9990 bootstrap.
- Inject TG 4000: clear dynamics once per PTT per peer.
- Unit data (ARS/LRRP) downlink for 7-digit private calls; monitor TS chip (no spurious `PRIVATE VOICE` on unit data).
- OBP → HBP voice forwarding and downlink CPU under multi-peer inject load.
### Removed
- Standalone **`adn-parrot.py`** — use `adn-server.py --echo`.
- Periodic **26 s `options_config_loop`**.
- Legacy pickle CONFIG/BRIDGE snapshots on the **server → monitor** wire (monitor uses v2 slim ingest).
### Compatibility
- **Monitor:** adn-monitor **2.0.0-rc.1** (report v2 slim wire, HELLO JSON).
- **Wire:** HBP and OpenBridge on-wire formats unchanged vs legacy ADN DMR Server.
- **Config:** same `adn-server.yaml` shape; add optional `PROXY` / `SELF_SERVICE` / `REPORTS.MQTT` sections.
## [1.0.0] - 2026-06-06
@ -13,7 +54,7 @@ First stable public release.
- HBP MASTER/PEER and OpenBridge forwarding with legacy parity (loops, BCSQ, packet control).
- Talker Alias: DMRA packets and embedded LC overlay (UTF-8 / ISO-8859-1 / 7-bit formats).
- Voice: announcements, TTS pipeline, on-demand AMBE playback, recording.
- Parrot entrypoint (`adn-parrot.py`) for TG 9990 echo/playback.
- Echo playback entrypoint (`adn-server.py --echo`, `adn-echo.yaml`) for TG 9990.
- Monitor report TCP: HELLO JSON (mode v2), CONFIG_SND / BRIDGE_SND (pickle), BRDG_EVENT.
- Hot reload: `adn-server.yaml` (SIGHUP), `adn-voice.yaml` (15 s loop), log reopen (SIGUSR2).
- MkDocs user guide (EN/ES).
@ -21,7 +62,7 @@ First stable public release.
### Fixed (highlights since parity baseline)
- OpenBridge packet control and ingress timing.
- Parrot playback sequence preservation on long QSOs.
- Echo playback sequence preservation on long QSOs.
- Config validator accepts numeric MMDVM option fields in YAML.
- OBP END/TX reporting and STATUS lifecycle alignment with legacy.

@ -1,6 +1,6 @@
# ADN DMR Peer Server
**Version 1.0.0** — first stable release (SemVer). Compatible with **adn-monitor 1.0.0**.
**Version 1.0.0** — first stable release. Compatible with **adn-monitor 1.0.0**.
ADN DMR conference bridge server. Configuration is YAML; the codebase follows clean architecture (domain, application, infrastructure).
@ -21,6 +21,8 @@ GPL v3. Derived from FreeDMR / HBlink.
Copy `adn-server.example.yaml` to `adn-server.yaml` and edit with your settings. Production config is not committed.
The example includes an **integrated hotspot proxy** (`PROXY`) and optional **MySQL self-service** (`SELF_SERVICE`). For self-service, install the optional extra: `pip install -e ".[selfservice]"`. See [Hotspot proxy (integrated)](docs/en/server/user-guide/hotspot-proxy.md). Disable standalone **`adn-proxy`** if you use the integrated proxy on the same host.
### Voice configuration
Voice features (announcements, TTS, recording) use a separate config file. Copy `adn-voice.example.yaml` to `adn-voice.yaml` and edit. If the file does not exist, voice features are disabled (no error). Changes are hot-reloaded every 15 seconds.
@ -43,6 +45,15 @@ Use the same `python3` you use for the project (e.g. pyenv’s `3.11.8`). Previe
Output: **`site/en/`** and **`site/es/`** under gitignored **`site/`**.
## Tests
```bash
python3 -m pip install -e ".[dev]"
python3 -m pytest tests/ -q
```
See [Testing](docs/en/server/development/testing.md) in the docs site. File index: [`tests/README.md`](tests/README.md).
## Run
```bash
@ -55,15 +66,19 @@ Options:
```bash
python adn-server.py -c /path/to/adn-server.yaml
python adn-server.py --logging DEBUG
python adn-server.py --doctor # config, ports, peers (exit 1 on errors)
python adn-server.py --no-proxy # disable integrated PROXY
```
## Parrot (Playback)
## Echo (playback)
A separate entrypoint records incoming group voice and plays it back (echo/parrot).
Separate process (same binary) records group voice and plays it back on TG 9990.
```bash
cp adn-parrot.example.yaml adn-parrot.yaml
python adn-parrot.py
cp adn-echo.example.yaml adn-echo.yaml
python adn-server.py --echo -c adn-echo.yaml
```
See [Parrot (playback)](docs/en/server/user-guide/parrot.md) in the docs site for an overview; extended notes may exist in private `docs-priv/` checkouts.
See [Echo (playback)](docs/en/server/user-guide/echo.md) in the docs site for an overview.
**systemd:** example units in `examples/systemd/` (`adn-server.service`, `adn-echo.service`).

@ -0,0 +1,25 @@
# ADN DMR Echo — minimal PEER config (copy to adn-echo.yaml; do not commit).
#
# Separate process, same binary as adn-server:
# python adn-server.py --echo -c adn-echo.yaml
#
# Connects to ECHO (MASTER) in adn-server.yaml — match MASTER_PORT and PASSPHRASE.
GLOBAL:
SERVER_ID: 9990
LOGGER:
LOG_FILE: /var/log/adn-server/echo.log
LOG_LEVEL: INFO
SYSTEMS:
ECHO:
MODE: PEER
IP: 127.0.0.1
PORT: 54915
MASTER_IP: 127.0.0.1
MASTER_PORT: 54917 # = ECHO.PORT in adn-server.yaml
PASSPHRASE: "<match ECHO.PASSPHRASE>"
RADIO_ID: 9990
CALLSIGN: ECHO
OPTIONS: "TS2=9990;"

@ -1,84 +0,0 @@
# ADN DMR Parrot (playback) - example configuration
# Copy to adn-parrot.yaml and set real secrets there (never commit adn-parrot.yaml).
#
# The parrot runs as a PEER that connects to the ECHO MASTER (defined in adn-server.yaml).
# When a user transmits to TG 9990, the bridge forwards the call to ECHO, the parrot
# records the audio and plays it back.
GLOBAL:
PATH: ./
PING_TIME: 10
MAX_MISSED: 3
USE_ACL: true
REG_ACL: PERMIT:ALL
SUB_ACL: DENY:1
TGID_TS1_ACL: PERMIT:ALL
TGID_TS2_ACL: PERMIT:ALL
ALLOW_NULL_PASSPHRASE: false
ANNOUNCEMENT_LANGUAGES: en_GB
SERVER_ID: 9990
DATA_GATEWAY: false
VALIDATE_SERVER_IDS: false
REPORTS:
REPORT: false
REPORT_INTERVAL: 60
REPORT_PORT: 4821
REPORT_CLIENTS: "127.0.0.1"
LOGGER:
# ENABLED: false # omit or true = normal; false = no console/file logs (NullHandler)
LOG_FILE: /var/log/adn-server/parrot.log
LOG_HANDLERS: file-timed
LOG_LEVEL: DEBUG
LOG_NAME: ADN
ALIASES:
TRY_DOWNLOAD: false
PATH: ./json/
PEER_FILE: peer_ids.json
SUBSCRIBER_FILE: subscriber_ids.json
TGID_FILE: talkgroup_ids.json
PEER_URL: https://servers.adn.systems/peer_ids.json
SUBSCRIBER_URL: https://servers.adn.systems/subscriber_ids.json
TGID_URL: https://servers.adn.systems/talkgroup_ids.json
LOCAL_SUBSCRIBER_FILE: local_subscriber_ids.json
STALE_DAYS: 1
SUB_MAP_FILE: ""
SERVER_ID_URL: https://servers.adn.systems/server_ids.tsv
SERVER_ID_FILE: server_ids.tsv
SYSTEMS:
PARROT:
MODE: PEER
ENABLED: true
LOOSE: false
EXPORT_AMBE: false
IP: 127.0.0.1
PORT: 54915 # parrot local port
MASTER_IP: 127.0.0.1
MASTER_PORT: 54917 # must match ECHO.PORT in adn-server.yaml
PASSPHRASE: "<set-in-adn-parrot.yaml>" # must match ECHO.PASSPHRASE
CALLSIGN: ECHO
RADIO_ID: 9990
RX_FREQ: 000000000
TX_FREQ: 000000000
TX_POWER: 25
COLORCODE: 1
SLOTS: 2
LATITUDE: 0
LONGITUDE: 0
HEIGHT: 0
LOCATION: "9990 Parrot"
DESCRIPTION: ECHO
URL: adn.systems
SOFTWARE_ID: "20170620"
PACKAGE_ID: MMDVM_ADN-Systems
GROUP_HANGTIME: 5
OPTIONS: "TS2=9990;"
USE_ACL: true
SUB_ACL: DENY:1
TGID_TS1_ACL: DENY:ALL
TGID_TS2_ACL: PERMIT:9990
ANNOUNCEMENT_LANGUAGE: en_GB
ALLOW_UNREG_ID: true

@ -29,6 +29,18 @@ REPORTS:
REPORT_INTERVAL: 60
REPORT_PORT: 4321
REPORT_CLIENTS: "127.0.0.1"
# Optional MQTT (disabled unless explicitly enabled).
# MQTT:
# ENABLED: true
# URL: mqtt://127.0.0.1:1883
# # Auth: USERNAME/PASSWORD here, or mqtt://user:pass@host:1883 in URL (YAML overrides URL).
# USERNAME: "<set-in-adn-server.yaml>"
# PASSWORD: "<set-in-adn-server.yaml>"
# # TLS (mqtts://): optional CA bundle for broker verification
# CAFILE: ""
# TOPIC_PREFIX: adn/73010
# QOS: 0
# Wire: state (retained), voice_event (live)
LOGGER:
# ENABLED: false # omit or true = normal; false = no console/file logs (NullHandler)
@ -55,20 +67,36 @@ ALIASES:
CHECKSUM_FILE: file_checksums.json
KEYS_FILE: keys.json
# Systems: MASTER, PEER, OPENBRIDGE. Names match legacy [SYSTEM], [D-APRS], [ECHO], [OBP-*].
#
# SYSTEM + GENERATOR>1 expands to SYSTEM-0..SYSTEM-(GENERATOR-1), each with UDP PORT+n
# (e.g. PORT 56400 + GENERATOR 102 → listeners 56400–56501). Hotspot proxy must use the same
# PROXY.PORT and PROXY.GENERATOR in adn-monitor/proxy/adn-proxy.example.yaml.
# Integrated hotspot proxy (always enabled). Hotspots use PROXY.LISTEN_PORT only.
PROXY:
LISTEN_PORT: 62031
LISTEN_IP: ""
TARGET_SYSTEM: SYSTEM
TIMEOUT: 30
DEBUG: false
CLIENT_INFO: true
BLACK_LIST: []
IP_BLACK_LIST: {}
# Self-service: same MySQL + PBKDF2 as monitor/adn-monitor.yaml (Clients table).
SELF_SERVICE:
USE_SELFSERVICE: true
DB_SERVER: localhost
DB_USERNAME: hbmon
DB_PASSWORD: "<set-in-adn-server.yaml>"
DB_NAME: hbmon
DB_PORT: 3306
PBKDF2_SALT: "ADN"
PBKDF2_ITERATIONS: 2000
# SYSTEM is inject-only (no PORT / GENERATOR on proxy target — use MAX_PEERS).
SYSTEMS:
SYSTEM:
MODE: MASTER
ENABLED: true
REPEAT: true
MAX_PEERS: 2
MAX_PEERS: 102
EXPORT_AMBE: false
IP: 127.0.0.1
PORT: 56400
PASSPHRASE: "<set-in-adn-server.yaml>"
GROUP_HANGTIME: 5
USE_ACL: true
@ -83,7 +111,6 @@ SYSTEMS:
TS2_STATIC: ""
DEFAULT_REFLECTOR: 0
ANNOUNCEMENT_LANGUAGE: es_ES
GENERATOR: 102
ALLOW_UNREG_ID: false
PROXY_CONTROL: false
OVERRIDE_IDENT_TG: ""

@ -28,8 +28,10 @@ Run the ADN DMR Peer Server from the project root.
python adn-server.py
python adn-server.py -c adn-server.yaml
python adn-server.py --logging DEBUG
python adn-server.py --echo -c adn-echo.yaml
python adn-server.py --doctor
Config default: adn-server.yaml in this directory.
Config default: adn-server.yaml (or adn-echo.yaml with --echo).
"""
from __future__ import annotations

@ -43,12 +43,14 @@ More: [Introduction](server/user-guide/introduction.md).
## ADN Monitor
Dashboard, WebSocket live view, optional **PHP API**, **MySQL** self-service, and **hotspot proxy** — see [Monitor overview](monitor/index.md).
Dashboard, WebSocket live view, FastAPI API, **MySQL** self-service — see [Monitor overview](monitor/index.md). **Hotspot proxy** is integrated in **adn-server**.
| I want to… | Start here |
|------------|------------|
| `adn-monitor.yaml`, `adn-proxy.yaml`, layout | [Monitor configuration](monitor/configuration.md), [Hotspot proxy](monitor/hotspot-proxy.md) |
| Hotspot proxy (UDP, `PROXY`, peer port range) | [Hotspot proxy](monitor/hotspot-proxy.md) |
| `adn-server.yaml` — integrated `PROXY` / `SELF_SERVICE` | [Hotspot proxy (integrated)](server/user-guide/hotspot-proxy.md) |
| `adn-monitor.yaml`, layout | [Monitor configuration](monitor/configuration.md) |
| Integrated hotspot proxy | [Hotspot proxy](server/user-guide/hotspot-proxy.md) |
| Standalone hotspot proxy (UDP port range) | [Hotspot proxy — standalone](monitor/hotspot-proxy.md#standalone-proxy-legacy-adn-monitor-repo) |
| Self-service | [Self-service](monitor/self-service.md) |
| How it connects to the server | [Monitoring and reports](server/user-guide/monitoring.md) |

@ -4,57 +4,52 @@
Under `monitor/src/adn_monitor/`:
- **Domain** — value objects, errors, opcode types.
- **Application** — `MonitorState`, `process_message` in `monitor_controller.py`, alias service, Last Heard / TG count use cases, time formatting.
- **Infrastructure** — YAML `load_config`, Twisted **TCP client** (`ReportClientFactory`) to the peer server, **WebSocket** factory for the dashboard, MySQL repositories, pickle/json decoders for `CONFIG_SND` / `BRIDGE_SND`.
- **Domain** — value objects, errors, opcode types, `UserSession`.
- **Application** — `MonitorState`, report/dashboard use cases, auth and self-service, alias service, Last Heard / TG count.
- **Infrastructure** — YAML `load_config`, FastAPI (REST + `/ws`), TCP ingest (Twisted thread) or MQTT, MySQL repositories, pickle/json decoders.
The monitor **connects outbound** to **`ADN_CONNECTION.ADN_IP:ADN_PORT`** and receives length-prefixed (netstring-style) messages. It updates in-memory **CTABLE** (masters/peers/OpenBridge) and **BTABLE** (bridges), and persists **BRDG_EVENT** outcomes when MySQL is configured.
Composition root: `infrastructure/fastapi/composition.py` (`build_monitor_api`).
## Report protocol (from the peer server)
## Unified process (`monitor.py`)
Same opcodes as documented for the server: **CONFIG_SND**, **BRIDGE_SND**, **BRDG_EVENT**, etc. The monitor decodes and applies them in `process_message` — see [Monitoring and reports](../server/user-guide/monitoring.md).
Single uvicorn/FastAPI process:
## WebSocket
| Route / role | Content |
|--------------|---------|
| `/api/*` | Dashboard config, auth, self-service, alias/status proxies |
| `/ws` | `conf,<group>` protocol — CTABLE, Last Heard, voice |
| Ingest (background) | `MONITOR_APP.INGEST`: `tcp` (client to `ADN_CONNECTION`) or `mqtt` |
| Aliases (background) | Download → import with staging + RENAME |
`monitor.py` runs a Twisted **WebSocket** on **`WEBSOCKET_SERVER.WEBSOCKET_PORT`**, pushing JSON snapshots at **`FREQUENCY`** so the React app updates without polling for core state.
Report ingest connects outbound to **`ADN_CONNECTION.ADN_IP:ADN_PORT`** (TCP) or subscribes to MQTT topics. It updates **CTABLE** / **BTABLE** and persists Last Heard when MySQL is configured.
## PHP backend
## Report protocol (from the peer server)
- **Slim 4** front controller: `backend/public/index.php`.
- Loads **`adn-monitor.yaml`** via **`ADN_CONFIG_PATH`** (same as monitor).
- **`/api/config/dashboard`** — title, language, feature flags (`selfService`, `showConsole`, …) from **`DASHBOARD`**.
- **`/api/auth/*`** — session cookie auth when **SELF_SERVICE** DB is available.
- **`/api/self-service/*`** — device options (see [Self-service](self-service.md)).
- **`/api/aliases/*`** — optional proxy to TG/bridge list URLs from **ALIASES**.
Legacy pickle, v1 HELLO, and v2 slim (`dashboard_state` + `voice_event`) — see [Monitoring and reports](../server/user-guide/monitoring.md).
## Frontend
- **Vite + React** under `frontend/`; build produces static assets served by nginx/Apache or similar.
- Uses **`API_BASE`** (build-time) to reach the PHP API and **WebSocket URL** for live data.
- Dev: Vite proxies `/api` and `/ws` to `MONITOR_APP.LISTEN_PORT`.
- Production: nginx serves `frontend/dist/` and proxies `/api` + `/ws` to the same FastAPI port.
## Hotspot proxy
- Entry: `proxy/proxy.py`; package `src/adn_proxy/` (domain / application / infrastructure).
- Reads **`PROXY`** and **`SELF_SERVICE`** from **`adn-proxy.yaml`** by default (or from the same file as the monitor when **`ADN_CONFIG_PATH`** is used without **`ADN_PROXY_CONFIG_PATH`** — see [Hotspot proxy](hotspot-proxy.md#configuration-file)).
- For each hotspot client, allocates a UDP port in **`PORT`…`PORT+GENERATOR-1`** (YAML **`PORT`** + **`GENERATOR`**, matching **`adn-server`**) and forwards to **`MASTER`**.
- When **self-service** updates **`Clients.options`** and sets **`modified=1`**, the proxy sends **RPTO** to the **master** on a timer (~10 s). The **peer server** then applies options to the hotspot path (see [Self-service](self-service.md)).
**Why it is not part of the peer server binary:** it shares deployment, **`SELF_SERVICE`** MySQL, and packaging with the monitor stack — see [Why it ships with the monitor](hotspot-proxy.md#why-it-ships-with-the-monitor-not-inside-the-peer-server).
**Integrated (default):** **`adn-server.py`** runs UDP fan-in from **`PROXY.LISTEN_PORT`** into **`PROXY.TARGET_SYSTEM`**; **`SELF_SERVICE`** in **`adn-server.yaml`** drives **RPTO** from MySQL **`Clients`**. See [Hotspot proxy (integrated)](../server/user-guide/hotspot-proxy.md).
**Details:** [Hotspot proxy](hotspot-proxy.md) (config keys, peer server port range, startup).
The standalone **`adn-proxy`** process was removed from the **adn-monitor** repository; use integrated **`PROXY`** only.
## Typical deployment topology
```text
[Hotspots] --UDP--> [Proxy :LISTEN_PORT] --UDP--> [Peer server :PORT..PORT+GENERATOR-1]
[Hotspots] --UDP--> [adn-server PROXY] --UDP--> [peer MASTER]
|
v
MySQL (Clients)
[Peer server :REPORT_PORT] <--- TCP --- [monitor.py : connects as client]
[Peer server :REPORT_PORT] <--- TCP or MQTT --- [monitor.py ingest]
[Browser] --HTTPS--> [PHP API + static frontend]
[Browser] --WS----> [monitor WebSocket :9000]
[Browser] --HTTPS--> [Nginx: static frontend + proxy /api,/ws --> MONITOR_APP.LISTEN_PORT]
```
---

@ -1,8 +1,8 @@
# Configuration (`adn-monitor.yaml`)
This document describes **`adn-monitor.yaml`**, used by the **Python monitor** (`monitor/monitor.py`) and the **PHP backend** (`backend/public/index.php`). Default path is usually **`monitor/adn-monitor.yaml`** (override with **`ADN_CONFIG_PATH`**).
This document describes **`adn-monitor.yaml`**, used by **`monitor/monitor.py`** (FastAPI: REST, WebSocket, report ingest). Default path is usually **`monitor/adn-monitor.yaml`** (override with **`ADN_CONFIG_PATH`**).
The **hotspot proxy** loads a **separate** file by default — **`proxy/adn-proxy.yaml`** — see [Hotspot proxy](hotspot-proxy.md). **`SELF_SERVICE`** (MySQL / PBKDF2) must stay **identical** between the two YAML files when both are used.
**Hotspot proxy** is configured in **`adn-server.yaml`** (`PROXY` + `SELF_SERVICE`) — see [Hotspot proxy (integrated)](../server/user-guide/hotspot-proxy.md). **`SELF_SERVICE`** (MySQL / PBKDF2) must stay **identical** between **`adn-server.yaml`** and **`adn-monitor.yaml`**.
The example shipped in the **adn-monitor** repo (`monitor/adn-monitor.yaml.example`) is the template for the monitor; keys below match that file and `monitor/src/adn_monitor/infrastructure/config_loader.py` (internal names may differ).
@ -37,31 +37,13 @@ Must match the **ADN DMR Peer Server** reporting configuration.
## `SELF_SERVICE`
MySQL credentials and PBKDF2 parameters for **login** and **`Clients`** table access. **PBKDF2_SALT** and **PBKDF2_ITERATIONS** must match **`hotspot_proxy_self_service.py`** (or your password-registration tool) so stored password hashes verify in PHP and Python.
MySQL credentials and PBKDF2 parameters for **login**, **self-service**, and **`Clients`** table access. **PBKDF2_SALT** and **PBKDF2_ITERATIONS** must match **`hotspot_proxy_self_service.py`** (or your password-registration tool) so stored password hashes verify in the monitor API and **adn-server** integrated proxy.
| Key | Meaning |
|-----|---------|
| **USE_SELFSERVICE** | Used by the **proxy** config loader to enable DB-backed options / self-service paths (see proxy README). |
| **DB_SERVER**, **DB_USERNAME**, **DB_PASSWORD**, **DB_NAME**, **DB_PORT** | MySQL connection for **`Clients`** (and related) tables. |
If the PHP backend cannot connect, **auth** and **self-service** API routes are not registered (see `backend/public/index.php`).
---
## `PROXY`
Hotspot **UDP proxy** — full guide: [Hotspot proxy](hotspot-proxy.md). In current layouts, these keys live in **`proxy/adn-proxy.yaml`**, not in `adn-monitor.yaml`. **Legacy:** a single file can still contain **PROXY** if the proxy is started with **`ADN_CONFIG_PATH`** pointing at that file (see resolution order in [Hotspot proxy](hotspot-proxy.md#configuration-file)).
Summary: **`PORT`** + **`GENERATOR`** must match **`SYSTEM.PORT`** + **`SYSTEM.GENERATOR`** in `adn-server`; each client is forwarded to **`MASTER`** at one UDP port in **`PORT`…`PORT+GENERATOR-1`** (see also [Architecture](architecture.md)).
| Key | Meaning |
|-----|---------|
| **MASTER** | Peer server host (IP or DNS; resolved at proxy startup). |
| **LISTEN_PORT** / **LISTEN_IP** | Where the proxy accepts hotspot UDP (empty IP often means all interfaces). |
| **PORT** / **DESTPORT_START** | Base UDP port on **`MASTER`** (same as server SYSTEM **PORT**). |
| **GENERATOR** | Count of consecutive UDP ports on **`MASTER`** (same integer as server SYSTEM **GENERATOR**). |
| **TIMEOUT**, **STATS**, **DEBUG**, **CLIENT_INFO** | Behaviour and logging. |
| **BLACK_LIST** / **IP_BLACK_LIST** | Optional block lists. |
If MySQL is unavailable, **auth** and **self-service** API routes are not registered.
---
@ -73,7 +55,7 @@ Comma-separated **network IDs** (as strings). Traffic from those OpenBridge sour
## `ALIASES`
Similar idea to the peer server: download **peer / subscriber / TGID** JSON and optional checksums. Keys include **PATH**, **\*_FILE**, **\*_URL**, **STALE_HOURS**, **REVIEW_INTERVAL_MINUTES**, **CHECKSUM_***, **TG_LIST_URL**, **BRIDGE_LIST_URL** (backend proxy for frontend pages).
Similar idea to the peer server: download **peer / subscriber / TGID** JSON and optional checksums. Keys include **PATH**, **\*_FILE**, **\*_URL**, **STALE_HOURS**, **REVIEW_INTERVAL_MINUTES**, **CHECKSUM_***, **TG_LIST_URL**, **BRIDGE_LIST_URL** (API proxy for frontend pages).
---
@ -85,18 +67,24 @@ Similar idea to the peer server: download **peer / subscriber / TGID** JSON and
| **LOG_FILE** | Monitor log filename (e.g. `adn-monitor.log`). |
| **LOG_LEVEL** | e.g. `INFO`, `DEBUG`. |
The **hotspot proxy** log filename is set in **`proxy/adn-proxy.yaml`** under **LOGGER** as **`PROXY_LOG_FILE`** (see [Hotspot proxy](hotspot-proxy.md)).
---
## `WEBSOCKET_SERVER`
## `MONITOR_APP`
| Key | Meaning |
|-----|---------|
| **WEBSOCKET_PORT** | Port for Twisted WebSocket pushing JSON state to browsers. |
| **FREQUENCY** | Push interval (seconds). |
| **LISTEN_HOST** | Bind address (`""` = all IPv4). |
| **LISTEN_PORT** | HTTP port (e.g. `8080`): `/api/*` and `/ws`. |
| **INGEST** | `tcp` (client to `ADN_CONNECTION`) or `mqtt` (broker topics). |
| **MQTT** | Required when `INGEST: mqtt` (`URL`, `TOPIC_PREFIX`, `QOS`). |
| **FREQUENCY** | Background periodic resync (seconds); live updates are event-driven. |
| **CLIENT_TIMEOUT** | Drop idle WS clients after N seconds (`0` = disable). |
| **USE_SSL**, **SSL_PATH**, **SSL_CERTIFICATE**, **SSL_PRIVATEKEY** | Optional WSS. |
| **CORS_ORIGINS** | Allowed origins for dev (optional). |
In production, Nginx proxies `/api` and `/ws` to **LISTEN_PORT**. No separate WebSocket port is needed.
Obsolete **`WEBSOCKET_SERVER`** YAML (Twisted on a separate port) is ignored; use **`MONITOR_APP`**.
---
@ -107,7 +95,7 @@ The **hotspot proxy** log filename is set in **`proxy/adn-proxy.yaml`** under **
| **DASHTITLE** | Header title. |
| **BACKGROUND** | Use `bk.jpg` background if `true`. |
| **LANGUAGE** | Default UI language (`en`, `es`, …). |
| **SELF_SERVICE** | If `true`, the UI can show the **Self-service** nav entry (backend must expose API + DB). |
| **SELF_SERVICE** | If `true`, the UI can show the **Self-service** nav entry (monitor API + MySQL required). |
| **SHOW_CONSOLE** | Show console page (call start/end messages). |
| **MIN_DURATION** | Minimum call duration (seconds) for **dashboard** Last Heard table (Last Heard page may still show shorter). |
| **nav_links**, **footer**, **news** | Optional structured links / marquee items. |
@ -116,9 +104,8 @@ The **hotspot proxy** log filename is set in **`proxy/adn-proxy.yaml`** under **
## Environment
- **`ADN_CONFIG_PATH`**: Absolute path to **`adn-monitor.yaml`** for the **monitor** and **PHP backend**.
- **`ADN_PROXY_CONFIG_PATH`** (optional): Absolute path to **`adn-proxy.yaml`** for the hotspot proxy. If unset, the proxy falls back to **`ADN_CONFIG_PATH`** (legacy combined file), then to **`proxy/adn-proxy.yaml`** by default — details in [Hotspot proxy](hotspot-proxy.md#configuration-file).
- Backend may use **`API_BASE_PATH`** if the API is mounted under a prefix.
- **`ADN_CONFIG_PATH`**: Absolute path to **`adn-monitor.yaml`** for **`monitor.py`**.
- Project root **`.env`**: `VITE_API_BASE`, `VITE_DEFAULT_LANGUAGE` (frontend build); auto-loaded by `monitor.py` and `db_bootstrap.py`.
---

@ -1,129 +1,8 @@
# Hotspot proxy
# Hotspot proxy (moved)
The **hotspot proxy** is part of the **adn-monitor** repository. It is a **UDP relay** between **DMR hotspots** (Homebrew / HBP) and the **ADN DMR Peer Server** MASTER: each connected hotspot is mapped to a **dedicated destination port** on the peer server host so many hotspots can share one public IP without port clashes.
The **standalone** `adn-proxy` process and **`proxy/`** tree were **removed** from the **adn-monitor** repository.
Source layout: `proxy/proxy.py`, package `proxy/src/adn_proxy/` (clean architecture). **GPL v3** (derivative of Simon Adlem, G7RZU’s original proxy).
Use the **integrated hotspot proxy** in **adn-server**:
### Why it ships with the monitor (not inside the peer server)
There is no single mandatory layout for every deployment, but **today the proxy lives in the adn-monitor repo** on purpose:
- **Same deployment** as the dashboard stack: **`ADN_CONFIG_PATH`** / **`ADN_PROXY_CONFIG_PATH`**, **`adn-monitor.yaml`** + **`adn-proxy.yaml`**, and usually the same host as **PHP** and **MySQL**.
- **Self-service** ( **`Clients`**, RPTO, **`modified`**) is built around that ecosystem; the peer server binary does not own that database or the **`PROXY`** block.
- **Role split:** the **ADN DMR Peer Server** is the **radio core** (HBP/OpenBridge, bridges, voice, TCP reports). The hotspot proxy is an **optional UDP front** toward a MASTER that already listens on a port **range** — useful when many hotspots share one public address.
**Bundling the proxy into the peer server** (one binary, one `adn-server.yaml`) is conceivable for packaging, but it implies **merging configuration**, **rethinking self-service wiring**, and extra maintenance — only worth it if you explicitly want a single deployable “all-in-one” server.
---
## Configuration file {#configuration-file}
The proxy does **not** use `adn-server.yaml`. It reads YAML that contains **`PROXY`**, **`SELF_SERVICE`**, and **`LOGGER`** (proxy log).
### Resolution order
| Priority | Source | Purpose |
|----------|--------|---------|
| 1 | **`python proxy/proxy.py --config /path/to/file.yaml`** | Overrides config path for this process only. |
| 2 | **`ADN_PROXY_CONFIG_PATH`** | Optional env: absolute path to **`adn-proxy.yaml`** (typical dedicated proxy config). |
| 3 | **`ADN_CONFIG_PATH`** | Legacy: absolute path to a **combined** file (e.g. **`adn-monitor.yaml`** with **PROXY** embedded — same as monitor/backend). |
| 4 | **Default** | **`proxy/adn-proxy.yaml`** next to `proxy/proxy.py` when neither env var is set. |
Copy **`proxy/adn-proxy.example.yaml`** to **`proxy/adn-proxy.yaml`** and edit. **`SELF_SERVICE`** must match **`monitor/adn-monitor.yaml`** (same DB credentials and PBKDF2 parameters).
Sections read from whichever file is chosen:
- **`PROXY`** — listen address, master host, destination port **range**, timeouts, debug, block lists.
- **`SELF_SERVICE`** — MySQL and **`USE_SELFSERVICE`** (for **`Clients`** table, RPTO / options).
- **`LOGGER`** — **`LOG_PATH`** and **`PROXY_LOG_FILE`** (separate from **`LOG_FILE`** in `adn-monitor.yaml` for `monitor.py`).
Optional **environment** overrides (see `proxy/README.md` in the repo): e.g. **`ADN_PROXY_DEBUG`**, **`ADN_PROXY_LISTENPORT`**.
---
## `PROXY` keys (in `adn-proxy.yaml`, or legacy combined YAML)
| Key | Role |
|-----|------|
| **MASTER** | IP or **hostname** of the **ADN DMR Peer Server** host. Resolved to an IPv4 address at startup (Twisted requires an IP for `write()`). |
| **LISTEN_PORT** | UDP port where **hotspots** connect **to the proxy** (the address users configure on the hotspot). |
| **LISTEN_IP** | Empty often means all interfaces; otherwise bind to this address. |
| **PORT** / **DESTPORT_START** | Base UDP port on **`MASTER`** (alias **DESTPORT_START**); must match **`SYSTEM.PORT`** in `adn-server`. |
| **GENERATOR** | Same integer as **`SYSTEM.GENERATOR`**; UDP ports **`PORT`…`PORT+GENERATOR-1`** on **`MASTER`** (one per proxied hotspot session). |
| **TIMEOUT** | Idle / session timeout (seconds). |
| **STATS** | Extra statistics logging. |
| **DEBUG** | Verbose packet logging (or use **`ADN_PROXY_DEBUG=1`**). |
| **CLIENT_INFO** | Per-client info in logs. |
| **BLACK_LIST** / **IP_BLACK_LIST** | Block radio IDs or source IPs. |
Internal config keys (after load) use mixed-case names (`Master`, `ListenPort`, …) — see `adn_proxy.infrastructure.config_loader`.
---
## Peer server (`adn-server.yaml`) must cover the port range
The proxy forwards traffic to **`MASTER:assigned_port`** for each client, where **assigned_port** is picked from **`PORT`…`PORT+GENERATOR-1`** (same **PORT**/**GENERATOR** semantics as [Server configuration](../server/user-guide/configuration.md)).
The **ADN DMR Peer Server** must **listen on UDP** on **that host** for **every port** in that range (usually via **`GENERATOR`** on one SYSTEM block).
- Align **`PROXY.PORT`** and **`PROXY.GENERATOR`** with **`SYSTEM.PORT`** and **`SYSTEM.GENERATOR`** in **`adn-server.yaml`**.
- Typical setup: one **`MODE: MASTER`** entry with **`GENERATOR`** expanding to `SYSTEM-0`…`SYSTEM-(N-1)` on consecutive UDP ports — see [Server configuration](../server/user-guide/configuration.md).
If the server only listens on e.g. **56400** but the proxy sends to **56401**, that client will not register.
---
## How the process starts
1. Resolve config path (`--config`, **`ADN_PROXY_CONFIG_PATH`**, **`ADN_CONFIG_PATH`**, or default **`proxy/adn-proxy.yaml`**).
2. **`load_config()`** parses YAML → **`PROXY`**, **`SELF_SERVICE`**, **`LOG`**.
3. Optional **MySQL** pool if self-service / DB features are enabled.
4. Twisted **reactor** runs UDP **ProxyProtocol** on **`LISTEN_IP:LISTEN_PORT`**, forwarding to **`MASTER:assigned_dest_port`**.
Run (from adn-monitor root):
```bash
# Dedicated proxy YAML (recommended)
export ADN_PROXY_CONFIG_PATH=/opt/adn-monitor/proxy/adn-proxy.yaml
python proxy/proxy.py
# Or rely on default proxy/adn-proxy.yaml after copying from adn-proxy.example.yaml
python proxy/proxy.py
# Legacy: single combined monitor YAML
export ADN_CONFIG_PATH=/opt/adn-monitor/monitor/adn-monitor.yaml
python proxy/proxy.py
# Or explicit path for one run
python proxy/proxy.py --config /opt/adn-monitor/proxy/adn-proxy.yaml
```
Use **systemd** or another supervisor to run alongside **`monitor.py`** and the **PHP** stack.
---
## RPTO, options, and self-service
The proxy **never** sends **RPTO** directly to the hotspot for self-service updates. It sends **RPTO to the MASTER** (peer server); the server updates bridge/options and the normal HBP path applies.
| Event | Proxy behaviour |
|-------|------------------|
| ~**10 s** after hotspot login (**RPTC**) | Read **`Clients.options`** from DB → **RPTO** → master `(MASTER, dport)`. |
| Every ~**10 s** | Rows with **`modified = 1`** → **RPTO** → master, then clear **`modified`**. |
| Hotspot sends **RPTO** | Forward to master; DB updates as implemented. |
Details: [Self-service](self-service.md) and the **adn-monitor** `proxy/README.md`.
---
## Monitor visibility
Hotspots appear on the dashboard only if the **peer server** sends **TCP reports** to the same host/port as **`ADN_CONNECTION`** in **`adn-monitor.yaml`**. Align **`REPORTS`** on the server with **`ADN_IP` / `ADN_PORT`**. See [Monitoring and reports](../server/user-guide/monitoring.md).
---
## See also
- [Monitor configuration](configuration.md) — **`adn-monitor.yaml`** (dashboard, reports, MySQL for backend/monitor); **`PROXY`** detail above.
- [Architecture](architecture.md) — where the proxy sits in the stack.
- [Self-service](self-service.md) — DB, **`modified`**, RPTO timing.
- [Hotspot proxy (integrated)](../server/user-guide/hotspot-proxy.md) — `PROXY` and `SELF_SERVICE` in **`adn-server.yaml`**
- [Monitor overview](index.md) — dashboard stack (`monitor.py` + React)

@ -1,6 +1,6 @@
# ADN Monitor (overview)
**ADN Monitor** is a separate project from the **ADN DMR Peer Server**, but the two are normally deployed **together**: the server sends **TCP reports** (config, bridges, call events) to the monitor; the monitor drives the **web dashboard** (React) and **WebSocket** live updates. Optional components include the **PHP API** (Slim), **MySQL** (self-service / device registry), and the **hotspot proxy** (UDP between hotspots and the peer server).
**ADN Monitor** is a separate project from the **ADN DMR Peer Server**, but the two are normally deployed **together**: the server sends **TCP reports** (or MQTT) to the monitor; the monitor drives the **web dashboard** (React) and **WebSocket** live updates. A single **`monitor.py`** process (FastAPI) serves **REST** (`/api/*`), **WebSocket** (`/ws`), and **report ingest**. Optional: **MySQL** (self-service / Last Heard) and **integrated hotspot proxy** in **`adn-server.py`**.
This chapter documents the **adn-monitor** stack at the same level of detail as the server guides. Source code lives in the **adn-monitor** repository, not in the **adn-server** repository (where this documentation is maintained).
@ -8,19 +8,17 @@ This chapter documents the **adn-monitor** stack at the same level of detail as
| Part | Role |
|------|------|
| **`monitor/monitor.py`** | Python (Twisted): connects to the peer server’s **report TCP** port, decodes netstring payloads (`CONFIG_SND`, `BRIDGE_SND`, `BRDG_EVENT`), maintains **CTABLE** / **BTABLE**, writes **Last Heard** / TG stats to **MySQL** when configured, serves **WebSocket** JSON to the dashboard. |
| **`backend/`** | PHP **Slim** app: `/api/config/dashboard`, auth, optional **self-service** APIs, alias proxies. Reads **`adn-monitor.yaml`** via **`ADN_CONFIG_PATH`**. |
| **`frontend/`** | React (Vite): dashboard UI; consumes backend API + WebSocket. |
| **`proxy/`** | Python (Twisted): UDP **hotspot proxy**; forwards Homebrew between hotspots and the peer server; reads **`Clients`** in MySQL for **RPTO** options (self-service). Loads **`adn-proxy.yaml`** by default (see [Hotspot proxy](hotspot-proxy.md)). |
| **`monitor/monitor.py`** | FastAPI: REST (`/api/*`), WebSocket (`/ws`), TCP or MQTT report ingest, **CTABLE** / Last Heard, self-service MySQL. |
| **`frontend/`** | React (Vite): dashboard UI; same-origin `/api` + `/ws`. |
## Configuration files
| File | Used by | Typical env |
|------|---------|-------------|
| **`monitor/adn-monitor.yaml`** | **`monitor.py`**, **PHP backend** | **`ADN_CONFIG_PATH`** |
| **`proxy/adn-proxy.yaml`** | **`proxy/proxy.py`** | **`ADN_PROXY_CONFIG_PATH`** (optional; defaults and legacy fallback in [Hotspot proxy](hotspot-proxy.md#configuration-file)) |
| **`adn-server.yaml`** | **`adn-server.py`** (integrated **`PROXY`** / **`SELF_SERVICE`**) | `-c` / default path next to binary |
| **`monitor/adn-monitor.yaml`** | **`monitor.py`** | **`ADN_CONFIG_PATH`** |
**`SELF_SERVICE`** (MySQL / PBKDF2) must **match** between both YAML files when you split config. **`ADN_CONNECTION`**, dashboard, WebSocket, and aliases live in **`adn-monitor.yaml`**; **`PROXY`** listen/master/range settings live in **`adn-proxy.yaml`** unless you use a **legacy** single file via **`ADN_CONFIG_PATH`** for the proxy.
**`SELF_SERVICE`** (MySQL / PBKDF2) must **match** between **`adn-server.yaml`** and **`adn-monitor.yaml`**. **`ADN_CONNECTION`**, dashboard, WebSocket, and aliases live in **`adn-monitor.yaml`**; integrated **`PROXY`** lives in **`adn-server.yaml`** — see [Hotspot proxy (integrated)](../server/user-guide/hotspot-proxy.md).
## Link to the peer server
@ -33,7 +31,7 @@ See [Monitoring and reports](../server/user-guide/monitoring.md) for report opco
## See also
- [Hotspot proxy](hotspot-proxy.md) — `PROXY`, peer server port range, how the process loads config and runs
- [Hotspot proxy (integrated)](../server/user-guide/hotspot-proxy.md) — `PROXY` in `adn-server.yaml`
- [Architecture and deployment](architecture.md)
- [Configuration (`adn-monitor.yaml`)](configuration.md)
- [Self-service](self-service.md)

@ -2,14 +2,14 @@
**Self-service** lets a hotspot owner **log in** to the dashboard, **edit their device options** (static TG lists, default reflector, timer, language, etc.), and have those options **pushed to the ADN DMR Peer Server** without manually editing YAML on the server.
It involves **four** pieces: **MySQL** (`Clients` table), **PHP API** (session + REST), **React** (`/self-service` page), and the **hotspot proxy** (periodic **RPTO** to the master). The **peer server** is the component that ultimately applies **RPTO** / **OPTIONS** to the running bridge and hotspot behaviour.
It involves **four** pieces: **MySQL** (`Clients` table), **monitor API** (FastAPI session + REST), **React** (`/self-service` page), and the **integrated hotspot proxy** in **adn-server** (periodic **RPTO** to the master). The **peer server** is the component that ultimately applies **RPTO** / **OPTIONS** to the running bridge and hotspot behaviour.
---
## Prerequisites
1. **`SELF_SERVICE`** block in **`adn-monitor.yaml`** with valid **MySQL** credentials and **PBKDF2** parameters matching your password tooling (same salt/iterations as **`hotspot_proxy_self_service.py`** when used).
2. **`DASHBOARD.SELF_SERVICE: true`** so the UI shows the **Self-service** menu entry (and the backend exposes `/api/self-service/*` when DB connects).
2. **`DASHBOARD.SELF_SERVICE: true`** so the UI shows the **Self-service** menu entry (and `monitor.py` exposes `/api/self-service/*` when DB connects).
3. **`Clients`** table populated with rows: **`callsign`**, **`int_id`** (DMR ID), **`psswd`** (PBKDF2-SHA256 hex), **`options`** (semicolon-separated `KEY=value`), **`logged_in`**, **`host`**, **`modified`**, etc. (see adn-monitor DB schema / migration scripts in the repo).
4. Hotspot traffic should pass through the **proxy** if you rely on **`modified`** and **RPTO** push (see flow below).
@ -19,7 +19,7 @@ It involves **four** pieces: **MySQL** (`Clients` table), **PHP API** (session +
| Endpoint | Purpose |
|----------|---------|
| **`POST /api/auth/login`** | Body: `callsign`, `password`. Verifies **PBKDF2** hash against **`Clients.psswd`** for rows with **`logged_in = 1`**. On success: PHP session with **`user_id`**, **`int_ids`** (all DMR IDs for that callsign). |
| **`POST /api/auth/login`** | Body: `callsign`, `password`. Verifies **PBKDF2** hash against **`Clients.psswd`** for rows with **`logged_in = 1`**. On success: session cookie with **`user_id`**, **`int_ids`** (all DMR IDs for that callsign). |
| **`GET /api/auth/login-by-ip`** | Optional: single user match for **`Clients.host`** = client IP (same session shape). |
| **`POST /api/auth/logout`** | Clears session. |
| **`GET /api/auth/me`** | Returns `{ callsign, int_ids, selected_int_id }` for the React app. |
@ -41,7 +41,7 @@ Session lifetime is extended on activity (**SelfServiceController** uses a long
## End-to-end flow (why options reach the hotspot)
1. User saves options in the web UI → **PHP** writes **`Clients.options`** and **`modified = 1`**.
1. User saves options in the web UI → **monitor API** writes **`Clients.options`** and **`modified = 1`**.
2. The **hotspot proxy** runs **`send_opts`** on a loop (~every **10 s**). For rows with **`modified = 1`**, it reads options from the DB, sends **RPTO** (options) **to the peer server** at **`(MASTER, assigned_dest_port)`**, then clears **`modified`** in the DB.
3. The **ADN DMR Peer Server** receives **RPTO** on the MASTER leg and updates its **OPTIONS** / bridge state (same path as a normal hotspot registration refresh).
4. The server pushes the appropriate signalling to the **hotspot** so static TG / reflector / timer settings take effect **without** a full hotspot restart (exact behaviour matches the HBP **OPTIONS** flow in the server).

@ -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,14 +12,26 @@
`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/` |
## Configuration as shared state

@ -19,7 +19,7 @@ The following intervals are part of the current runtime behavior:
| `rule_timer` | **52s** | Bridge timeout/on-off state progression. |
| `stream_trimmer` | **5s** | Stream cleanup, timeout handling, end-of-call state trimming. |
| `bridge_reset` | **6s** | Bridge reset flag cleanup and pending reset completion. |
| `options_config_loop` | **26s** | Refresh static TG / reflector options from peer OPTIONS payloads. |
| OPTIONS refresh | **event-driven** | Static TG / reflector from **RPTO**, **startup/reload** (`apply_startup_bridges`), **dmrd** no-source fallback. No periodic 26s loop (**D-28**). |
| `statTrimmer` | **303s** | Trim stale STAT bridges and transient status entries. |
If you change one of these intervals, document the operational impact for monitoring, loop behavior, and troubleshooting.

@ -0,0 +1,86 @@
# Testing
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).
## Install dev dependencies
```bash
python3 -m pip install -e ".[dev]"
```
Use the project interpreter, e.g. `/opt/.pyenv/versions/3.11.8/bin/python3`.
## Run tests
Full suite:
```bash
python3 -m pytest tests/ -q
```
**By domain** (while working on one area):
```bash
python3 -m pytest tests/routing/ -q
python3 -m pytest tests/hbp/ -q
python3 -m pytest tests/echo/ -q
```
**By file** (recommended for point checks):
```bash
python3 -m pytest tests/routing/test_unit_data_routing.py -q
python3 -m pytest tests/echo/test_rekey_playback.py -q
```
**Single test**:
```bash
python3 -m pytest tests/routing/test_startup_subscriptions.py::test_startup_bridge_routes_voice_after_apply -q
```
Collect only:
```bash
python3 -m pytest tests/ --collect-only -q
```
## Markers
Registered in `pyproject.toml`:
| Marker | Use |
|--------|-----|
| `@pytest.mark.behavior` | Integration-style regression (P0/P1) |
| `@pytest.mark.smoke` | Quick routing smoke checks |
## Harness overview
| Component | Role |
|-----------|------|
| `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`) |
| `tests/harness/assertions.py` | Reusable asserts: `assert_forwarded`, `assert_report_event`, … |
Full audit (scores, red-test table, inventory): internal `docs-priv/en/test-audit.md` in maintainer checkouts.
## Writing a regression test
1. Docstring: **Regression:** if X breaks, this test fails because Y.
2. Prefer a **dedicated file** (or the smallest existing file for the same topic).
3. Enter through a public path (`inject_*`, use-case API, or `@pytest.mark.unit` for pure domain).
4. Assert an **observable** outcome (capture, `master.sent`, report strings), not only private flags.
5. Add a negative path where behaviour differs.
6. **Red-test:** break the production condition → test must fail → revert.
7. Mark `@pytest.mark.behavior` or `@pytest.mark.unit`.
Do not chase coverage percentage; chase regressions operators would notice on RF.
## v2 policy
Each v2 feature branch should include or extend regression tests in the same PR. Baseline task **V2-TST-001** tracks bringing `tests/` into the repo on `develop`.

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

@ -0,0 +1,322 @@
# Report protocol v2 (JSON)
**Status:** schema draft. Wire encoding and server emission are in progress; monitor v2 consumer is a separate deliverable.
## Goals
Replace monitor snapshots that today use **pickle** (`CONFIG_SND`, `BRIDGE_SND`) and **CSV strings** (`BRDG_EVENT`) with **typed JSON** that any client can decode.
**Release policy:** **adn-server 1.0.x** + **adn-monitor 1.0.x** = report v1 (frozen tag pair). **2.x** server sends **HELLO** with `report_protocol: 2` and **v2 JSON only** (`TOPOLOGY_SND`, `ROUTING_TABLE_SND`, `VOICE_EVENT_SND`, `DELTA_SND`) — no duplicate pickle/CSV frames. **adn-monitor 2.x** is the polyglot consumer: it decodes v1 wire from legacy peers and v2 JSON from **adn-server 2.x**, mapping both into the same dashboard state for the UI. **adn-monitor 1.0.0** cannot decode v2 opcodes — upgrade the monitor line for **adn-server 2.x**.
## Transport (unchanged)
- TCP **netstring** frames (Twisted `NetstringReceiver`), same port as v1 (`REPORTS.REPORT_PORT`).
- Each frame: **1-byte opcode** + **UTF-8 JSON payload** (v2) or pickle/CSV (v1).
### Opcodes
| Opcode | Hex | v1 payload | v2 payload |
|--------|-----|------------|------------|
| `HELLO` | `0xFF` | JSON hello (`protocol`: 1) | JSON hello (`report_protocol`: 2) |
| `CONFIG_SND` | `0x01` | pickle SYSTEMS | — (use `TOPOLOGY_SND`) |
| `BRIDGE_SND` | `0x03` | pickle BRIDGES | — (use `ROUTING_TABLE_SND`) |
| `BRDG_EVENT` | `0x07` | CSV text | — (use `VOICE_EVENT_SND`) |
| `TOPOLOGY_SND` | `0x10` | — | JSON `topology` |
| `ROUTING_TABLE_SND` | `0x11` | — | JSON `routing_table` |
| `VOICE_EVENT_SND` | `0x12` | — | JSON `voice_event` |
| `DELTA_SND` | `0x13` | — | JSON `delta` |
Proposed opcodes `0x10`–`0x13` are reserved in the schema phase; exact values may change before P1-002 ships.
## Handshake (`hello`)
On connect the server sends **`HELLO` (`0xFF`)** first (same as today). v2 clients inspect `report_protocol`:
```json
{
"type": "hello",
"server": "adn-server",
"version": "2.0.0-alpha.1",
"report_protocol": 2,
"features": ["INGRESS", "END_TX_FORWARD", "PUSH_ON_CONNECT", "REPORT_V2", "TOPOLOGY_JSON", "ROUTING_TABLE_JSON", "VOICE_EVENT_JSON", "DELTA_UPDATES"],
"systems": ["MASTER-A", "OBP-CL"]
}
```
| Field | Notes |
|-------|--------|
| `report_protocol` | **2** for this schema. Distinct from legacy field `protocol: 1`. |
| `features` | v1 tokens unchanged; v2 adds `REPORT_V2` and payload capabilities. |
Monitor **1.0.x** does not speak this wire; use the **1.0.x** server tag for that pair.
## Message types
| `type` | Replaces | Purpose |
|--------|----------|---------|
| `topology` | `CONFIG_SND` | Systems, peers, OpenBridge legs (no secrets). |
| `routing_table` | `BRIDGE_SND` | Active bridge legs per talkgroup / reflector key. |
| `voice_event` | `BRDG_EVENT` | Structured call start/end/ingress. |
| `delta` | — | Incremental `topology` or `routing_table` patch since `since_seq`. |
## Reference payloads
Each frame payload is one JSON object with a required `type`. Examples below use anonymized IDs.
### `topology`
```json
{
"type": "topology",
"seq": 1,
"ts": 1717555200.0,
"systems": [
{
"name": "MASTER-A",
"mode": "MASTER",
"enabled": true,
"ip": "10.0.0.1",
"port": 62030,
"repeat": true,
"peers": [
{ "id": 3120001, "connected": true, "ip": "10.0.0.50", "port": 62031 }
]
},
{
"name": "OBP-CL",
"mode": "OPENBRIDGE",
"enabled": true,
"ip": "10.0.0.2",
"port": 62044,
"enhanced_obp": true,
"peers": []
}
]
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `seq`, `ts` | int, float | yes | Monotonic sequence and epoch time. |
| `systems[].name` | string | yes | System key (matches config). |
| `systems[].mode` | string | yes | `MASTER`, `PEER`, or `OPENBRIDGE`. |
| `systems[].enabled` | bool | yes | Config enabled flag. |
| `systems[].ip`, `port` | string, int | no | Listen/connect endpoint. |
| `systems[].repeat` | bool | no | Master repeat flag. |
| `systems[].enhanced_obp` | bool | no | OpenBridge enhanced mode. |
| `systems[].peers[]` | array | no | `{id, connected, ip?, port?}` per peer radio. |
No passwords or encryption material are included (unlike legacy pickle).
### `routing_table`
```json
{
"type": "routing_table",
"seq": 42,
"ts": 1717555260.5,
"routes": [
{
"bridge_key": "52090",
"legs": [
{
"system": "MASTER-A",
"ts": 2,
"tgid": 52090,
"active": true,
"to_type": "ON",
"timer_expires_at": 1717555320.0
},
{
"system": "MASTER-B",
"ts": 2,
"tgid": 52090,
"active": true,
"to_type": "ON"
}
]
},
{
"bridge_key": "#310",
"legs": [
{
"system": "MASTER-A",
"ts": 2,
"tgid": 310,
"active": false,
"to_type": "NONE"
}
]
}
]
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `routes[].bridge_key` | string | yes | Talkgroup id or reflector key (`#nnn`). |
| `legs[].system` | string | yes | Target system name. |
| `legs[].ts` | 1 \| 2 | yes | Timeslot (legacy `TS`). |
| `legs[].tgid` | int | yes | Talkgroup (1–16777215). |
| `legs[].active` | bool | yes | Leg active in BRIDGES table. |
| `legs[].to_type` | string | yes | `ON`, `OFF`, `STAT`, or `NONE`. |
| `legs[].timer_expires_at` | float | no | `rule_timer` expiry (legacy `TIMER`). |
### `voice_event`
Legacy CSV:
```text
GROUP VOICE,START,RX,MASTER-A,2155905152,1001,3120001,2,52090
```
v2 equivalent:
```json
{
"type": "voice_event",
"ts": 1717555201.234,
"call_family": "GROUP",
"phase": "START",
"direction": "RX",
"system": "MASTER-A",
"stream_id": 2155905152,
"peer_id": 1001,
"src_id": 3120001,
"slot": 2,
"dst_id": 52090,
"duration_s": null
}
```
| Field | Type | Required | Values / notes |
|-------|------|----------|----------------|
| `call_family` | string | yes | `GROUP`, `PRIVATE`, `UNIT`, `VCSSBK`. |
| `phase` | string | yes | `INGRESS`, `START`, `END`. |
| `direction` | string | yes | `RX`, `TX`. |
| `stream_id` | int | yes | 32-bit HBP stream id. |
| `peer_id`, `src_id`, `dst_id` | int | yes | DMR ids (1–16777215). |
| `slot` | int | yes | `1` or `2`. |
| `duration_s` | float \| null | no | Set on `END`. |
OpenBridge **INGRESS** vs **START** semantics match [Monitoring and reports](../user-guide/monitoring.md#openbridge-monitor-semantics): use `phase: "INGRESS"` for first sight, `phase: "START"` after loop control.
### `delta`
```json
{
"type": "delta",
"seq": 43,
"ts": 1717555261.0,
"since_seq": 42,
"patch": {
"type": "routing_table",
"seq": 43,
"ts": 1717555261.0,
"routes": [
{
"bridge_key": "52090",
"legs": [
{
"system": "MASTER-A",
"ts": 2,
"tgid": 52090,
"active": false,
"to_type": "ON"
}
]
}
]
}
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `since_seq` | int | yes | Last `seq` the client applied. |
| `patch` | object | yes | Partial `topology` or `routing_table` (same shape). |
## Sequencing
- `topology` and `routing_table` messages carry monotonic `seq` (uint) and `ts` (float epoch).
- Clients track last applied `seq`; `delta` messages set `since_seq` to the client watermark.
- Full snapshots may still be sent on connect and on `REPORTS.REPORT_INTERVAL` (same triggers as v1 CONFIG/BRIDGE).
## Configuration
```yaml
REPORTS:
REPORT: true
REPORT_PORT: 4321
```
No `PROTOCOL` switch on **2.x** — wire is always JSON (`infrastructure/twisted_adapters/report/wire.py`). Payload mapping: **`application/report/`**.
### Optional MQTT mirror
By default the server sends reports **only** over TCP netstring (adn-monitor and other TCP clients). MQTT is **disabled** unless you explicitly enable it.
**Enable** only when both are set:
1. `REPORTS.MQTT.ENABLED: true` (boolean `true`, not merely present)
2. `REPORTS.MQTT.URL` — broker URL (`mqtt://host:1883` or `mqtts://host:8883`)
```yaml
REPORTS:
REPORT: true
REPORT_PORT: 4321
MQTT:
ENABLED: true
URL: mqtt://127.0.0.1:1883
TOPIC_PREFIX: adn/73010 # optional; default adn/{GLOBAL.SERVER_ID}
USERNAME: my-mqtt-user # optional; overrides user in URL
PASSWORD: my-mqtt-secret # optional; overrides password in URL
CAFILE: /path/to/ca.pem # optional; broker TLS trust store (mqtts://)
QOS: 0 # optional, 0–2
```
**Client ID:** auto-generated at startup as `adn-server-{GLOBAL.SERVER_ID}-{random}` (not configurable; random suffix avoids broker session collisions on restart).
**Authentication:** username/password via `USERNAME` and `PASSWORD`, or embedded in the URL (`mqtt://user:pass@host:1883`). YAML credentials override URL userinfo. Password may be empty if the broker allows it. With `mqtts://`, set `CAFILE` when the broker uses a private CA.
Requires optional dependency: `pip install 'adn-server[mqtt]'` (paho-mqtt). If MQTT is enabled but the library is missing, the server logs an error and continues with TCP only.
**MQTT wire (fixed, not configurable):** only **`voice_event`** (telemetry) and **`state`** (snapshot). TCP-only types (`topology`, `routing_table`, `delta`, `hello`) are **not** published on MQTT.
**Topic convention** (shared under `{prefix}`):
| Topic | Direction | JSON `type` | Retain |
|-------|-----------|-------------|--------|
| `voice_event` | server → broker | `voice_event` | no |
| `state` | server → broker | `dashboard_state` | yes |
`state` carries masters with connected peers, homebrew peers, and openbridges (monitor WebSocket `conf,lnksys` + `conf,opb` intent). It is **retained** so new subscribers receive the last snapshot without requesting it. **Topology-driven refreshes** republish `{prefix}/state` when the dashboard changes (dedup).
**Triggers:** live `voice_event`; retained `{prefix}/state` on topology changes and after MQTT connect (dedup).
**Example** (`SERVER_ID` 7302):
```bash
mosquitto_sub -h BROKER -p 1883 -u USER -P PASS -t 'adn/7302/state' -v
mosquitto_sub -h BROKER -p 1883 -u USER -P PASS -t 'adn/7302/voice_event' -v
```
**Broker ACL:** consumers need **subscribe** on `adn/7302/state` and `adn/7302/voice_event`; the server `client_id` needs **publish** on those two topics only (no server-side subscribe).
**Reload (`systemctl reload` / SIGHUP):** when `REPORTS.MQTT` changes (enable/disable, URL, credentials, TLS, `TOPIC_PREFIX`, `QOS`), the server disconnects the old MQTT client and connects with the new settings, or stays offline if `ENABLED` becomes false.
**QoS:** configurable via `MQTT.QOS` (default `0`).
**`rule_timer`**, **`stat_trimmer`**, and **`bridgeDebug`** use **routing deltas** when only part of `BRIDGES` changed; connect, `REPORT_INTERVAL`, reload, and client `CONFIG_REQ` / `BRIDGE_REQ` send **full** snapshots.
## Version pairing (supported combinations)
| Server | Monitor | Report wire | Notes |
|--------|---------|-------------|--------|
| **1.0.x** | **1.0.x** | v1 (pickle/CSV) | Frozen pair; no cross-upgrade of report protocol |
| **2.0.0-alpha.\*** | **2.x** (dev) | v2 only | Current `develop` line |
| **2.0.0** | **2.0.x** | v2 only | GA pair; monitor 1.0.x **not** supported |
Do **not** run monitor 1.0.x against server 2.0.0 or monitor 2.x against server 1.0.x for production.
See also [Monitoring and reports](../user-guide/monitoring.md).

@ -27,15 +27,11 @@ After editing the main config you can reload **without restarting** the process
kill -HUP $(pidof adn-server.py) # or: systemctl reload adn-server
```
With **systemd**, add to your unit file:
Example unit: **`examples/systemd/adn-server.service`** (copy to `/etc/systemd/system/`; includes `ExecReload` for `systemctl reload`).
```ini
ExecReload=/bin/kill -HUP $MAINPID
```
**Reload applies:** `GLOBAL`, `REPORTS`, `ALIASES`, **`LOGGER.LOG_LEVEL`** (without process restart), per-system settings, **new/removed SYSTEMS** (including `GENERATOR` expansion and new OpenBridge legs), and updated bind addresses (listener restart for that system only).
**Reload applies:** `GLOBAL`, `REPORTS`, `ALIASES`, **`LOGGER.LOG_LEVEL`** (without process restart), **`PROXY`** (timeouts, debug, block lists — not bind or target), **`SELF_SERVICE`** (merged; enabling/disabling DB loops needs restart), per-system settings, **new/removed SYSTEMS** (including `GENERATOR` expansion/collapse and new OpenBridge legs), and updated bind addresses (listener restart for that system only).
**Not reloaded:** `adn-voice.yaml` (separate 15 s loop), Python code, subscriber alias files (separate periodic reload). **BRIDGES** table is not rebuilt on reload — restart if bridge rules changed in a way that requires a full reset.
**Not reloaded:** `adn-voice.yaml` (separate 15 s loop), Python code, subscriber alias files (separate periodic reload). **BRIDGES** table is not rebuilt on reload — restart if bridge rules changed in a way that requires a full reset. **`PROXY.LISTEN_PORT`**, **`LISTEN_IP`**, and **`TARGET_SYSTEM`** require a **full restart** to take effect.
**Secrets:** Never commit real passphrases, security URLs, or `user_passwords.json` / `encryption_key.secret`. Use placeholders in templates and keep production files local.
@ -50,12 +46,12 @@ Three **modes** exist:
| Mode | Typical use | Listens | Connects upstream |
|------|-------------|---------|-------------------|
| **MASTER** | Conference server for one or more hotspots/repeaters | **Yes** — `IP` / `PORT`, peers register with passphrase | No (peers connect to you) |
| **PEER** | Hotspot/repeater or service (e.g. parrot) behaving as a **client** of a MASTER | **Yes** — local `IP` / `PORT` | **Yes** — `MASTER_IP` / `MASTER_PORT` must point at a MASTER |
| **PEER** | Hotspot/repeater or service (e.g. echo) behaving as a **client** of a MASTER | **Yes** — local `IP` / `PORT` | **Yes** — `MASTER_IP` / `MASTER_PORT` must point at a MASTER |
| **OPENBRIDGE** | Link to another **server** over OpenBridge (DMRD v1 / DMRE) | **Yes** — `IP` / `PORT` | **Yes** — `TARGET_IP` / `TARGET_PORT` (peer server) |
**MASTER** holds the **`PEERS`** table at runtime (hotspots that authenticated). **PEER** maintains **STATS** (connection, pings). **OPENBRIDGE** uses **NETWORK_ID**, **PASSPHRASE**, **TARGET_***, **PROTO_VER** / **VER**, and optional **ENHANCED_OBP**, **RELAX_CHECKS**, **TGID_ACL**.
A single process can run **several** systems at once (e.g. one MASTER for users, one ECHO for parrot, one OBP to a partner network).
A single process can run **several** systems at once (e.g. one MASTER for users, one ECHO for playback, one OBP to a partner network).
---
@ -115,16 +111,16 @@ These appear mainly on **MASTER** (and often on **PEER**). OpenBridge uses a dif
| Key | Meaning |
|-----|---------|
| **REPEAT** | If true, received traffic can be **repeated** to other connected peers on the MASTER (typical conference behaviour). |
| **MAX_PEERS** | Maximum connected hotspots. |
| **MAX_PEERS** | Maximum connected hotspots. On the **proxy target** MASTER, caps concurrent fan-in sessions. |
| **EXPORT_AMBE** | Feature flag for AMBE export (if enabled in build). |
| **SINGLE_MODE** | Affects OPTIONS / generator expansion (single-user style). |
| **VOICE_IDENT** | Enables periodic **voice ident** when conditions are met (see `IdentUseCases`). |
| **TS1_STATIC** / **TS2_STATIC** | Comma-separated static TG lists pushed via OPTIONS handling (see `options_config`). |
| **DEFAULT_REFLECTOR** | Default **reflector** number for `#` dial bridges (0 = none). |
| **OVERRIDE_IDENT_TG** | Optional TG for voice ident instead of all-call. |
| **GENERATOR** | If **> 1**, this MASTER is expanded into **`NAME-0`**, **`NAME-1`**, … with consecutive ports (see `expand_generator` in code). |
| **GENERATOR** | If **> 1**, this MASTER is expanded into **`NAME-0`**, **`NAME-1`**, … with consecutive ports (see `expand_generator` in code). Legacy standalone **`adn-proxy`** used the same range; the **integrated** proxy instead uses **inject-only** **`PROXY.TARGET_SYSTEM`** (no per-hotspot UDP ports on the server). |
**MASTER** listens for PEER connections; each authenticated peer is stored under **`PEERS`** at runtime.
**MASTER** listens for PEER connections (unless it is the **inject-only** proxy target — see [Hotspot proxy](hotspot-proxy.md)); each authenticated peer is stored under **`PEERS`** at runtime.
---
@ -140,7 +136,7 @@ A **PEER** connects **outbound** to a **MASTER** and listens locally for the rad
| **OPTIONS** | Byte string / options line (e.g. `TS2=9990;`) for static TG / behaviour. |
| **LOOSE** | Relaxed handling flag where applicable. |
The **parrot** example (`adn-parrot.example.yaml`) is a PEER that attaches to the **ECHO** MASTER: same **PASSPHRASE**, **MASTER_PORT** = ECHO’s **PORT**. See [Parrot](parrot.md).
The **echo** example (`adn-echo.example.yaml`) is a PEER that attaches to the **ECHO** MASTER: same **PASSPHRASE**, **MASTER_PORT** = ECHO’s **PORT**. See [Echo](echo.md).
---
@ -186,6 +182,28 @@ Details: [Monitoring and reports](monitoring.md).
---
## `PROXY` (integrated hotspot proxy)
Always started when a **`PROXY`** block is present (see `adn-server.example.yaml`). Hotspots connect to **`LISTEN_PORT`**; traffic is injected into **`TARGET_SYSTEM`**. Full guide: [Hotspot proxy](hotspot-proxy.md).
| Key | Meaning |
|-----|---------|
| **LISTEN_PORT** / **LISTEN_IP** | UDP bind for hotspot connections. |
| **TARGET_SYSTEM** | **MASTER** system name receiving injected HBP. That system becomes **inject-only** (`IP` / `PORT` removed at load). |
| **TIMEOUT** | Session idle timeout (seconds). |
| **DEBUG** / **CLIENT_INFO** | Logging verbosity. |
| **BLACK_LIST** / **IP_BLACK_LIST** | Block radio IDs or client IPs. |
Do **not** run standalone **`adn-proxy`** on the same **`LISTEN_PORT`** when the integrated proxy is enabled.
---
## `SELF_SERVICE` (MySQL / dashboard options)
Optional; requires `pip install -e ".[selfservice]"` when **`USE_SELFSERVICE: true`**. Uses the same **`Clients`** table and PBKDF2 parameters as **adn-monitor**. Keys match the monitor docs — see [Self-service](../../monitor/self-service.md) and [Hotspot proxy](hotspot-proxy.md#self_service-keys).
---
## `LOGGER`
Implemented in `infrastructure/logging_config.py` (`setup_logging`). Values are read from the **`LOGGER`** block (or overridden by `--logging` for **LOG_LEVEL** only).
@ -246,4 +264,5 @@ Use the project interpreter (see workspace rules), e.g. `python3.11` from pyenv,
- [Introduction](introduction.md) — role of the server.
- [Bridges and talkgroups](bridges-and-talkgroups.md) — `BRIDGES` semantics.
- [Special numbers](special-numbers.md) — reserved TGs and server IDs.
- [Parrot](parrot.md) — PEER example (parrot process).
- [Echo](echo.md) — PEER example (echo process).
- [Hotspot proxy](hotspot-proxy.md) — integrated **`PROXY`** / **`SELF_SERVICE`**.

@ -0,0 +1,42 @@
# Echo (playback)
## What it is
**Echo** records incoming **group** voice and plays it back on TG **9990**. It runs as a **PEER** process that connects to the **ECHO** master on the main peer server (TG 9990 bridge).
The playback runtime is part of **`adn-server`**; run **`adn-server.py --echo`** with minimal **`adn-echo.yaml`**.
## Configuration
Use a **small separate YAML** — only what the PEER needs to attach to **ECHO** in `adn-server.yaml`:
| Field | Role |
|-------|------|
| `GLOBAL.SERVER_ID` | Echo network identity (often `9990`) |
| `LOGGER` | Log file (optional but recommended) |
| `SYSTEMS.ECHO` | `MODE: PEER`, local `IP`/`PORT`, `MASTER_IP`/`MASTER_PORT`, `PASSPHRASE`, `RADIO_ID`, `CALLSIGN`, `OPTIONS` |
No `PROXY`, `ALIASES`, or `REPORTS` required. **`MASTER_PORT`** and **`PASSPHRASE`** must match **`ECHO`** on the main server.
- Copy **`adn-echo.example.yaml`** → **`adn-echo.yaml`** (not committed).
- Run:
```bash
python adn-server.py --echo -c adn-echo.yaml
```
Typical production: separate **systemd** unit (see `examples/systemd/adn-echo.service` in the repo), same binary:
```bash
sudo cp examples/systemd/adn-echo.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now adn-echo
```
## Relation to TG 9990
The main server exposes an **ECHO** master on **TG 9990** for the echo bridge. The standalone **echo** service is a separate process with its own config that connects to that master.
## Documentation
This page is the summary shipped with the repository; extend your deployment notes locally as needed.

@ -0,0 +1,112 @@
# Hotspot proxy (integrated)
**ADN DMR Peer Server** includes an **integrated hotspot proxy**: one process (`adn-server.py`) accepts Homebrew (HBP) from many hotspots on a single UDP port and **injects** traffic into a configured **MASTER** system. You do **not** need a separate **`adn-proxy`** process when this mode is enabled.
Configuration lives in **`adn-server.yaml`** under **`PROXY`** and optional **`SELF_SERVICE`** (same MySQL **`Clients`** table as **adn-monitor**).
---
## When to use it
| Deployment | What to run |
|------------|-------------|
| **Typical ADN stack** (monitor + dashboard + many Pi-Star hotspots) | **`adn-server.py`** with **`PROXY`** + **`SELF_SERVICE`**. |
The integrated proxy uses **fan-in**: hotspots only need **`PROXY.LISTEN_PORT`** (e.g. **62031**). The target **MASTER** is **inject-only** — it does **not** bind its own UDP port for that system (no per-hotspot port range on the server host).
---
## Optional dependency (self-service)
MySQL self-service requires **`mysqlclient`**:
```bash
pip install -e ".[selfservice]"
```
If **`USE_SELFSERVICE: true`** but **`mysqlclient`** is missing, startup fails with a clear error. Set **`USE_SELFSERVICE: false`** to run the proxy without DB (no dashboard-driven **RPTO** updates).
---
## `PROXY` keys
| Key | Role |
|-----|------|
| **LISTEN_PORT** | UDP port where **hotspots** connect (the address users configure on the hotspot). |
| **LISTEN_IP** | Bind address; empty = all interfaces. |
| **TARGET_SYSTEM** | Name of the **`SYSTEMS`** **MASTER** entry that receives injected HBP (must exist and be **ENABLED**). |
| **TIMEOUT** | Idle session timeout (seconds); expired sessions are torn down on the MASTER. |
| **DEBUG** | Verbose packet logging. |
| **CLIENT_INFO** | Log connect/disconnect per radio ID. |
| **BLACK_LIST** | Block listed radio IDs. |
| **IP_BLACK_LIST** | Block source IPs (with optional expiry). |
There is **no** **`MASTER`**, **`PORT`**, or **`GENERATOR`** in integrated **`PROXY`** — those belong to the legacy standalone proxy. The target MASTER uses **`MAX_PEERS`** (not a UDP port range) to cap concurrent hotspot sessions.
Example (from `adn-server.example.yaml`):
```yaml
PROXY:
LISTEN_PORT: 62031
LISTEN_IP: ""
TARGET_SYSTEM: SYSTEM
TIMEOUT: 30
DEBUG: false
CLIENT_INFO: true
BLACK_LIST: []
IP_BLACK_LIST: {}
```
### Inject-only target MASTER
When **`PROXY.TARGET_SYSTEM`** points at a system (e.g. **`SYSTEM`**), startup **removes** **`IP`** / **`PORT`** from that MASTER block. Hotspots never connect directly to the conference port; all HBP enters via **`LISTEN_PORT`**.
Set **`MAX_PEERS`** on the target MASTER to the maximum concurrent proxied hotspots (e.g. **102**). Other MASTER systems (e.g. **ECHO**, **D-APRS**) keep normal **`IP`** / **`PORT`** binds if they are not the proxy target.
---
## `SELF_SERVICE` keys
Same semantics as **`adn-monitor.yaml`** — shared **`Clients`** table, **`modified`** flag, **RPTO** toward the MASTER.
| Key | Role |
|-----|------|
| **USE_SELFSERVICE** | Enable MySQL-backed options sync (`true` / `false`). |
| **DB_SERVER**, **DB_USERNAME**, **DB_PASSWORD**, **DB_NAME**, **DB_PORT** | MySQL connection. |
| **PBKDF2_SALT**, **PBKDF2_ITERATIONS** | Must **match** monitor/backend for password hashing. |
On startup the server logs **`(SELF_SERVICE) Database connection test: OK`** and **`(SELF_SERVICE) Enabled`** when the pool connects. Self-service runs **asynchronously**; voice forwarding is not blocked on DB latency.
Details of the dashboard flow: [Self-service](../../monitor/self-service.md).
---
## Multi-hotspot behaviour
- Each authenticated hotspot is a **peer** on the inject-only MASTER with its own **OPTIONS** (static TGs). **Repeat** and monitor fan-out respect **per-peer OPTIONS** — traffic for a TG is not sent to peers that did not select it.
- **Parrot / echo** talkgroups **9990–9999** bypass the OPTIONS filter and return to the **calling** hotspot (see [Special numbers](special-numbers.md)).
---
## Hot reload (`SIGHUP`)
**Applied without restart** (active proxy sessions stay up):
- **`PROXY`:** **TIMEOUT**, **DEBUG**, **CLIENT_INFO**, **BLACK_LIST**, **IP_BLACK_LIST**
- **`SELF_SERVICE`:** merged into config (credential changes take effect on new DB operations; loops are not restarted on reload)
**Requires full process restart:**
- **`PROXY.LISTEN_PORT`** / **`LISTEN_IP`** (bind change is logged and ignored at reload)
- **`PROXY.TARGET_SYSTEM`**
- Enabling or disabling **`USE_SELFSERVICE`** after startup (start/stop MySQL loops)
See [Configuration — hot reload](configuration.md#hot-reload-adn-serveryaml).
---
## See also
- [Configuration](configuration.md) — full **`adn-server.yaml`** reference.
- [Monitoring and reports](monitoring.md) — TCP reports, dashboard, log rotation.
- [Self-service](../../monitor/self-service.md) — **`Clients`**, **RPTO** timing.

@ -21,16 +21,19 @@ Routing, timers, OpenBridge loop control, and protocol handling are implemented
| **HBP protocol** | Authentication, DMRD ingress/egress, repeat to peers, TG filters. |
| **OpenBridge** | DMRE ingress, hop limit, loop control (`min(1ST)`), BCSQ/BCKA when enabled. |
| **Voice** | AMBE files, scheduled announcements, TTS pipeline, on-demand playback (TG 9991–9999). |
| **Reporting** | TCP netstring channel to **adn-monitor** (and compatible dashboards): config, bridge state, `BRDG_EVENT` call events. |
| **Reporting** | TCP netstring channel to **adn-monitor** (and compatible dashboards): config, bridge state, call events (report v2 JSON). |
| **Hotspot proxy** | Optional integrated UDP fan-in (`PROXY` in `adn-server.yaml`) plus MySQL **self-service** (`SELF_SERVICE`) for dashboard-driven hotspot options. |
## Related programs
- **Parrot / playback** — separate entrypoint (`adn-parrot.py`) for record-and-playback; see [Parrot](parrot.md).
- **Echo / playback** — `adn-server.py --echo` with minimal `adn-echo.yaml`; see [Echo](echo.md).
- **Integrated hotspot proxy** — `PROXY` in **`adn-server.yaml`**; see [Hotspot proxy](hotspot-proxy.md).
## Next steps
- [Configuration](configuration.md) — files, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACLs, reports, aliases, voice merge.
- [Configuration](configuration.md) — files, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACLs, reports, **`PROXY`**, **`SELF_SERVICE`**, aliases, voice merge.
- [Bridges and talkgroups](bridges-and-talkgroups.md) — how `BRIDGES` works.
- [Special numbers](special-numbers.md) — TG 4000, information services, echo.
- [ADN Monitor](../../monitor/index.md) — dashboard, `adn-monitor.yaml`, self-service (separate repo, deployed with the server).
- [Hotspot proxy](hotspot-proxy.md) — integrated **`PROXY`** / **`SELF_SERVICE`** in `adn-server.yaml`.
- [ADN Monitor](../../monitor/index.md) — dashboard, `adn-monitor.yaml`, self-service UI (separate repo, deployed with the server).
- [Credits & license](attribution.md) — ADN → FreeDMR → hblink3, license.

@ -5,14 +5,18 @@
When **`REPORTS`** is enabled in the server config, the **ADN DMR Peer Server** listens on TCP and **report clients** (typically **adn-monitor**) connect and receive:
- **HELLO** (opcode **`0xFF`**) — JSON sent **first** on each new TCP connection by **ADN DMR Server** (`adn-server`): `server` name, package **`version`**, **`protocol`** number, and **`features`** (e.g. `INGRESS`, `END_TX_FORWARD`, `PUSH_ON_CONNECT`). Lets the monitor tag the session as **v2** before any pickled payloads.
- **CONFIG_SND** / **BRIDGE_SND** — pickled snapshots of systems and bridges (sent immediately after HELLO on connect, on **`CONFIG_REQ`** / **`BRIDGE_REQ`**, on **SIGHUP** config reload, when a **MASTER** hotspot **registers or disconnects**, and on the periodic **`REPORT_INTERVAL`** loop).
- **BRDG_EVENT** — text events for calls (`GROUP VOICE`, `PRIVATE VOICE`, etc.).
- **Report v1 (1.0.x pair):** **CONFIG_SND** / **BRIDGE_SND** (pickle), **BRDG_EVENT** (CSV).
- **Report v2 (2.x pair):** **TOPOLOGY_SND** / **ROUTING_TABLE_SND** (JSON), **VOICE_EVENT_SND**, optional **DELTA_SND** — same triggers (connect, **`CONFIG_REQ`** / **`BRIDGE_REQ`**, reload, peer changes, **`REPORT_INTERVAL`**).
**Report v2:** typed JSON (`topology`, `routing_table`, `voice_event`, `delta`) replaces pickle/CSV on the **2.x** server+monitor pair. Schema: [Report protocol v2 (JSON)](../protocols/report-v2.md).
**Version pairing:** **server 1.0.x + monitor 1.0.x** = report v1 (frozen tags). **server 2.x** emits **report v2 only** — requires **monitor 2.x** on the same line. No `dual` wire; monitor 1.0.x will not decode this server.
Older stacks (**legacy** `adn-dmr-server`-style) may **omit** HELLO. **adn-monitor** waits up to **`ADN_CONNECTION.HELLO_TIMEOUT_MS`** (see [Monitor configuration](../../monitor/configuration.md#adn_connection)); if no HELLO arrives, it assumes **legacy** reporting.
The **monitor** decodes these messages, updates its **CTABLE** / **BTABLE**, and (when MySQL is configured) persists Last Heard / statistics.
**Full stack:** [ADN Monitor overview](../../monitor/index.md) (Python monitor, WebSocket, PHP API, optional proxy and self-service).
**Full stack:** [ADN Monitor overview](../../monitor/index.md) (FastAPI monitor, WebSocket, self-service).
### Report channel log lines (`adn-monitor` logger)
@ -46,8 +50,7 @@ These processes handle **`SIGUSR2`** by reopening **`logging.FileHandler`** stre
| Process | Typical config keys |
|---------|---------------------|
| **`adn-server`** / **`adn-parrot`** | **`LOGGER.LOG_FILE`** (see `adn-server.example.yaml`) |
| **`adn-proxy`** | **`LOG.PATH`** + **`LOG.LOG_FILE`** in `adn-proxy.yaml` |
| **`adn-server`** / **`adn-echo`** | **`LOGGER.LOG_FILE`** (integrated proxy logs appear in the same file) |
| **`adn-monitor`** | **`LOG.PATH`** + **`LOG.LOG_FILE`** in `adn-monitor.yaml` |
Example **`/etc/logrotate.d/adn`** fragment (adjust paths and service names):
@ -67,7 +70,7 @@ Example **`/etc/logrotate.d/adn`** fragment (adjust paths and service names):
}
```
Repeat **`postrotate`** with **`kill -USR2`** for **`adn-parrot`**, **`adn-proxy`**, and **`adn-monitor`** units if those logs are rotated on the same host. Use the correct **PID** (systemd **`MainPID`**, a pidfile, or **`kill`** targeting the process you manage).
Repeat **`postrotate`** with **`kill -USR2`** for **`adn-echo`** and **`adn-monitor`** units if those logs are rotated on the same host. Use the correct **PID** (systemd **`MainPID`**, a pidfile, or **`kill`** targeting the process you manage).
## Requirements
@ -76,4 +79,6 @@ Repeat **`postrotate`** with **`kill -USR2`** for **`adn-parrot`**, **`adn-proxy
## Self-service and hotspots
Operators editing **device options** from the dashboard use the **self-service** flow (MySQL **`Clients`**, proxy **RPTO**). That is documented under [Self-service](../../monitor/self-service.md); it is **not** part of the peer server binary alone. For **hotspot proxy** configuration (`PROXY` in **`adn-proxy.yaml`** by default), how it binds to the peer server **UDP port range**, and how the process starts, see [Hotspot proxy](../../monitor/hotspot-proxy.md).
Operators editing **device options** from the dashboard use the **self-service** flow (MySQL **`Clients`**, **RPTO** toward the conference MASTER). In current **ADN DMR Peer Server** deployments this runs **inside `adn-server.py`**: configure **`SELF_SERVICE`** and **`PROXY`** in **`adn-server.yaml`** (see [Hotspot proxy](hotspot-proxy.md)). Dashboard semantics: [Self-service](../../monitor/self-service.md).
Hotspot proxy logs are part of **`adn-server`** when **`PROXY`** is enabled — see [Hotspot proxy (integrated)](hotspot-proxy.md).

@ -1,22 +0,0 @@
# Parrot (playback)
## What it is
**Parrot** is a **separate entrypoint** (`adn-parrot.py` / `parrot_main`) that records incoming **group** voice and plays it back (echo / parrot), independent of the main bridge process.
## Configuration
- Copy **`adn-parrot.example.yaml`** → **`adn-parrot.yaml`** (not committed).
- Run:
```bash
python adn-parrot.py -c adn-parrot.yaml
```
## Relation to TG 9990 / ECHO
The main server may expose an **ECHO** bridge on **TG 9990** for in-band echo. **Parrot** is a **standalone** service with its own config — use one or the other according to your deployment.
## Documentation
This page is the summary shipped with the repository; extend your deployment notes locally as needed.

@ -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`).

@ -89,11 +89,11 @@ Operationally: if users report “bridges drop too easily” after OPTIONS updat
The **audio** is sent with **source ID 5000** and **destination TG 9** in the generated stream. File layout: [Voice, announcements, and TTS](voice-and-tts.md).
## TG 9990 — echo / parrot (in-band)
## TG 9990 — echo (in-band)
**Purpose:** Bridge rows for **echo** often use **9990** with the **ECHO** system (see `BRIDGES` and options in your YAML).
**Note:** A **standalone parrot** is also available as a separate process — [Parrot](parrot.md).
**Note:** A **standalone echo** is also available as a separate process — [Echo](echo.md).
## Private call to ID 4000

@ -41,7 +41,7 @@ Under **`GLOBAL`** (optional per-system override with the same keys):
GLOBAL:
TALKER_ALIAS: false
TALKER_ALIAS_MODE: both
TALKER_ALIAS_FORMAT: "{callsign} {fname}"
TALKER_ALIAS_FORMAT: "TA {callsign} {fname}"
TALKER_ALIAS_TEXT_FORMAT: "utf8,iso8"
```

@ -52,5 +52,5 @@ Parallel **broadcasts** on **different** TGs may run concurrently; **same-TG** b
## See also
- [Configuration](configuration.md) — voice file paths.
- [Parrot](parrot.md) — separate playback service.
- [Echo](echo.md) — separate playback service.
- [Special numbers](special-numbers.md) — 5000, 999x, recording-related behaviour.

@ -42,11 +42,13 @@ Más: [Introducción](server/user-guide/introduction.md).
## ADN Monitor
Panel, **WebSocket** en vivo, **API PHP** opcional, **MySQL** self-service y **proxy hotspot** — ver [Descripción general del monitor](monitor/index.md).
Panel, **WebSocket** en vivo, API FastAPI, **MySQL** self-service — ver [Descripción general del monitor](monitor/index.md). El **proxy hotspot** está integrado en **adn-server**.
| Quiero… | Empieza aquí |
|---------|----------------|
| `adn-monitor.yaml`, `adn-proxy.yaml`, despliegue | [Configuración del monitor](monitor/configuration.md), [Proxy hotspot](monitor/hotspot-proxy.md) |
| `adn-server.yaml` — `PROXY` / `SELF_SERVICE` integrados | [Proxy hotspot (integrado)](server/user-guide/hotspot-proxy.md) |
| `adn-monitor.yaml`, despliegue | [Configuración del monitor](monitor/configuration.md) |
| Proxy hotspot integrado | [Proxy hotspot](server/user-guide/hotspot-proxy.md) |
| Proxy hotspot (UDP, `PROXY`, rango de puertos) | [Proxy hotspot](monitor/hotspot-proxy.md) |
| Self-service | [Self-service](monitor/self-service.md) |
| Cómo encaja con el servidor | [Monitor e informes](server/user-guide/monitoring.md) |

@ -4,57 +4,50 @@
Bajo `monitor/src/adn_monitor/`:
- **Dominio** — objetos de valor, errores, tipos de opcode.
- **Aplicación** — `MonitorState`, `process_message` en `monitor_controller.py`, servicio de alias, casos de uso Last Heard / conteo TG, formato de hora.
- **Infraestructura** — `load_config` YAML, cliente Twisted **TCP** (`ReportClientFactory`) al peer server, fábrica **WebSocket** para el panel, repositorios MySQL, decodificadores pickle/json para `CONFIG_SND` / `BRIDGE_SND`.
- **Dominio** — entidades, errores, `Result`, opcodes, `UserSession`.
- **Aplicación** — `MonitorState`, casos de uso (auth, self-service, informes, dashboard), puertos (`AuthRepository`, `HttpFetcherPort`, …).
- **Infraestructura** — YAML, FastAPI (REST + `/ws`), ingest TCP (Twisted en hilo) o MQTT, MySQL, decodificadores de informes.
El monitor **sale hacia** **`ADN_CONNECTION.ADN_IP:ADN_PORT`** y recibe mensajes con prefijo de longitud (estilo netstring). Actualiza **CTABLE** (masters/peers/OpenBridge) y **BTABLE** (bridges) en memoria, y persiste resultados de **BRDG_EVENT** cuando MySQL está configurado.
El **composition root** está en `infrastructure/fastapi/composition.py` (`build_monitor_api`).
## Protocolo de informes (desde el peer server)
Los mismos opcodes que en la documentación del servidor: **CONFIG_SND**, **BRIDGE_SND**, **BRDG_EVENT**, etc. El monitor los decodifica y aplica en `process_message` — ver [Monitor e informes](../server/user-guide/monitoring.md).
## Proceso unificado (`monitor.py`)
## WebSocket
Un solo proceso uvicorn/FastAPI:
`monitor.py` ejecuta un **WebSocket** Twisted en **`WEBSOCKET_SERVER.WEBSOCKET_PORT`**, enviando instantáneas JSON a **`FREQUENCY`** para que la app React actualice sin polling del estado principal.
| Ruta / rol | Contenido |
|------------|-----------|
| `/api/*` | Config dashboard, auth, self-service, proxy alias/status |
| `/ws` | Protocolo `conf,<grupo>` — CTABLE, Last Heard, voz |
| Ingest (fondo) | `MONITOR_APP.INGEST`: `tcp` (cliente a `ADN_CONNECTION`) **o** `mqtt` (tópicos `state` + `voice_event`) |
| Alias (fondo) | Descarga → `FILES.PATH`; import con **staging + RENAME** (commits cada 10k en staging; swap breve); merge con commits cada 2k; **PK en `id`** |
## Backend PHP
## Protocolo de informes (desde el peer server)
- **Slim 4** front controller: `backend/public/index.php`.
- Carga **`adn-monitor.yaml`** vía **`ADN_CONFIG_PATH`** (igual que el monitor).
- **`/api/config/dashboard`** — título, idioma, flags (`selfService`, `showConsole`, …) desde **`DASHBOARD`**.
- **`/api/auth/*`** — sesión por cookie cuando hay BD **SELF_SERVICE**.
- **`/api/self-service/*`** — opciones de dispositivo (ver [Self-service](self-service.md)).
- **`/api/aliases/*`** — proxy opcional a URLs de listas TG/bridge desde **ALIASES**.
Modos de ingest según el peer: legacy (pickle), v1 HELLO, v2 slim (`dashboard_state` + `voice_event`). Ver [Monitor e informes](../server/user-guide/monitoring.md).
## Frontend
- **Vite + React** bajo `frontend/`; el build genera estáticos servidos por nginx/Apache o similar.
- Usa **`API_BASE`** (build) para la API PHP y **URL del WebSocket** para datos en vivo.
- **Vite + React** en `frontend/`; build → `frontend/dist/`.
- Desarrollo: Vite hace proxy de `/api` y `/ws` a `MONITOR_APP.LISTEN_PORT` (p. ej. 8080).
- Producción: Nginx sirve estáticos y proxifica `/api` + `/ws` al mismo puerto FastAPI.
## Proxy hotspot
- Entrada: `proxy/proxy.py`; paquete `src/adn_proxy/` (dominio / aplicación / infraestructura).
- Lee **`PROXY`** y **`SELF_SERVICE`** desde **`adn-proxy.yaml`** por defecto (o desde el mismo fichero que el monitor si se usa solo **`ADN_CONFIG_PATH`** sin **`ADN_PROXY_CONFIG_PATH`** — ver [Proxy hotspot](hotspot-proxy.md#configuration-file)).
- Por cada cliente hotspot, asigna un puerto UDP en **`PORT`…`PORT+GENERATOR-1`** (**`PORT`** + **`GENERATOR`** en YAML, alineados con **`adn-server`**) y reenvía a **`MASTER`**.
- Cuando **self-service** actualiza **`Clients.options`** y pone **`modified=1`**, el proxy envía **RPTO** al **master** en un temporizador (~10 s). El **peer server** aplica entonces las opciones al camino del hotspot (ver [Self-service](self-service.md)).
**Por qué no forma parte del binario del peer server:** comparte implantación, **self-service** MySQL y empaquetado con la pila del monitor — ver [Por qué va con el monitor](hotspot-proxy.md#why-it-ships-with-the-monitor-not-inside-the-peer-server).
**Integrado (predeterminado):** `adn-server.py` con `PROXY` + `SELF_SERVICE` en `adn-server.yaml`. Ver [Proxy hotspot integrado](../server/user-guide/hotspot-proxy.md).
**Detalle:** [Proxy hotspot](hotspot-proxy.md) (claves de config, rango de puertos del peer, arranque).
El proceso **`adn-proxy`** independiente se eliminó del repositorio **adn-monitor**; usa solo **`PROXY`** integrado.
## Topología típica de despliegue
## Topología típica
```text
[Hotspots] --UDP--> [Proxy :LISTEN_PORT] --UDP--> [Peer server :PORT..PORT+GENERATOR-1]
[Hotspots] --UDP--> [adn-server PROXY] --UDP--> [peer MASTER]
|
v
MySQL (Clients)
[Peer server :REPORT_PORT] <--- TCP --- [monitor.py : cliente que conecta]
[Peer :REPORT_PORT] <--- TCP o MQTT --- [monitor.py ingest]
[Navegador] --HTTPS--> [API PHP + frontend estático]
[Navegador] --WS----> [WebSocket del monitor :9000]
[Navegador] --HTTPS--> [Nginx: frontend/dist + proxy /api,/ws --> :8080]
```
---

@ -1,8 +1,8 @@
# Configuración (`adn-monitor.yaml`)
Este documento describe **`adn-monitor.yaml`**, usado por el **monitor Python** (`monitor/monitor.py`) y el **backend PHP** (`backend/public/index.php`). La ruta por defecto suele ser **`monitor/adn-monitor.yaml`** (sobrescribible con **`ADN_CONFIG_PATH`**).
Este documento describe **`adn-monitor.yaml`**, usado por **`monitor.py`**. La ruta por defecto suele ser **`monitor/adn-monitor.yaml`** (sobrescribible con **`ADN_CONFIG_PATH`**).
El **proxy hotspot** carga por defecto **otro** fichero — **`proxy/adn-proxy.yaml`** — ver [Proxy hotspot](hotspot-proxy.md). La sección **`SELF_SERVICE`** (MySQL / PBKDF2) debe ser **idéntica** en ambos YAML cuando se usan los dos.
**Proxy hotspot** se configura en **`adn-server.yaml`** (`PROXY` + `SELF_SERVICE`) — ver [Proxy hotspot integrado](../server/user-guide/hotspot-proxy.md). **`SELF_SERVICE`** (MySQL / PBKDF2) debe ser **idéntica** entre **`adn-server.yaml`** y **`adn-monitor.yaml`**.
El ejemplo del repositorio **adn-monitor** (`monitor/adn-monitor.yaml.example`) es la plantilla del monitor; las claves siguientes coinciden con ese fichero y `monitor/src/adn_monitor/infrastructure/config_loader.py` (los nombres internos pueden diferir).
@ -19,7 +19,7 @@ El ejemplo del repositorio **adn-monitor** (`monitor/adn-monitor.yaml.example`)
| **EMPTY_MASTERS** | Mostrar masters sin peers. |
| **TGCOUNT_INC** | Activar página / estadísticas de conteo de TG. |
| **TGCOUNT_ROWS** | Filas para el conteo de TG. |
| **TIMEZONE** | Zona horaria IANA (p. ej. `America/Santiago`) para mostrar; vacío usa la hora local del servidor. |
| **TIMEZONE** | Zona horaria IANA (p. ej. `America/Santiago`): fechas Last Heard en pantalla; **TG Count** usa el **día calendario en esa zona** (medianoche local = nuevo día de estadísticas). Vacío → día UTC. |
---
@ -37,31 +37,13 @@ Debe coincidir con la configuración de informes del **ADN DMR Peer Server**.
## `SELF_SERVICE`
Credenciales MySQL y parámetros PBKDF2 para **login** y acceso a la tabla **`Clients`**. **PBKDF2_SALT** y **PBKDF2_ITERATIONS** deben coincidir con **`hotspot_proxy_self_service.py`** (o tu herramienta de registro de contraseñas) para que los hashes verifiquen en PHP y Python.
Credenciales MySQL y parámetros PBKDF2 para **login**, **self-service** y acceso a la tabla **`Clients`**. **PBKDF2_SALT** y **PBKDF2_ITERATIONS** deben coincidir con **`hotspot_proxy_self_service.py`** (o tu herramienta de registro de contraseñas) para que los hashes verifiquen en la API del monitor y en el proxy integrado de **adn-server**.
| Clave | Significado |
|-------|-------------|
| **USE_SELFSERVICE** | Lo usa el cargador de config del **proxy** para rutas con BD / self-service (ver README del proxy). |
| **DB_SERVER**, **DB_USERNAME**, **DB_PASSWORD**, **DB_NAME**, **DB_PORT** | Conexión MySQL para **`Clients`** (y tablas relacionadas). |
Si el backend PHP no puede conectar, las rutas de **auth** y **self-service** no se registran (ver `backend/public/index.php`).
---
## `PROXY`
**Proxy UDP hotspot** — guía completa: [Proxy hotspot](hotspot-proxy.md). En los despliegues actuales, estas claves están en **`proxy/adn-proxy.yaml`**, no en `adn-monitor.yaml`. **Legado:** un único fichero puede seguir incluyendo **PROXY** si el proxy se arranca con **`ADN_CONFIG_PATH`** apuntando a ese fichero (ver orden de resolución en [Proxy hotspot](hotspot-proxy.md#configuration-file)).
Resumen: **`PORT`** + **`GENERATOR`** deben coincidir con **`SYSTEM.PORT`** + **`SYSTEM.GENERATOR`** en `adn-server`; cada cliente se reenvía a **`MASTER`** en un puerto UDP dentro de **`PORT`…`PORT+GENERATOR-1`** (ver también [Arquitectura](architecture.md)).
| Clave | Significado |
|-------|-------------|
| **MASTER** | Host del peer server (IP o DNS; resuelto al arrancar el proxy). |
| **LISTEN_PORT** / **LISTEN_IP** | Dónde el proxy acepta UDP del hotspot (IP vacía suele significar todas las interfaces). |
| **PORT** / **DESTPORT_START** | Puerto UDP base en **`MASTER`** (el mismo que **PORT** del SYSTEM en el servidor). |
| **GENERATOR** | Cantidad de puertos UDP consecutivos en **`MASTER`** (el mismo entero que **GENERATOR** del SYSTEM en el servidor). |
| **TIMEOUT**, **STATS**, **DEBUG**, **CLIENT_INFO** | Comportamiento y registro. |
| **BLACK_LIST** / **IP_BLACK_LIST** | Listas de bloqueo opcionales. |
Si MySQL no está disponible, las rutas de **auth** y **self-service** no se registran.
---
@ -85,18 +67,25 @@ Misma idea que en el peer server: descargar JSON de **peer / subscriber / TGID**
| **LOG_FILE** | Nombre del log del monitor (p. ej. `adn-monitor.log`). |
| **LOG_LEVEL** | p. ej. `INFO`, `DEBUG`. |
El nombre del fichero de log del **proxy hotspot** se define en **`proxy/adn-proxy.yaml`** dentro de **LOGGER** como **`PROXY_LOG_FILE`** (ver [Proxy hotspot](hotspot-proxy.md)).
---
## `WEBSOCKET_SERVER`
## `MONITOR_APP`
Proceso unificado **`monitor.py`**: REST y WebSocket en el **mismo** host/puerto.
| Clave | Significado |
|-------|-------------|
| **WEBSOCKET_PORT** | Puerto del WebSocket Twisted que envía estado JSON a los navegadores. |
| **FREQUENCY** | Intervalo de envío (segundos). |
| **LISTEN_HOST** | Interfaz de escucha (`""` = todas las IPv4). |
| **LISTEN_PORT** | Puerto HTTP (p. ej. `8080`): `/api/*` y `/ws`. |
| **INGEST** | `tcp` (cliente a `ADN_CONNECTION`) o `mqtt` (tópicos del broker). |
| **MQTT** | Obligatorio si `INGEST: mqtt` (`URL`, `TOPIC_PREFIX`, `QOS`). |
| **FREQUENCY** | Resync periódico en segundo plano (segundos); las actualizaciones en vivo son por eventos. |
| **CLIENT_TIMEOUT** | Cerrar clientes WS inactivos tras N segundos (`0` = desactivado). |
| **USE_SSL**, **SSL_PATH**, **SSL_CERTIFICATE**, **SSL_PRIVATEKEY** | WSS opcional. |
| **CORS_ORIGINS** | Orígenes permitidos para desarrollo (opcional). |
Nginx en producción proxifica `/api` y `/ws` a **LISTEN_PORT**. No hace falta un puerto WebSocket aparte.
La sección obsoleta **`WEBSOCKET_SERVER`** en YAML (Twisted en puerto distinto) se ignora; usa **`MONITOR_APP`**.
---
@ -107,18 +96,38 @@ El nombre del fichero de log del **proxy hotspot** se define en **`proxy/adn-pro
| **DASHTITLE** | Título de cabecera. |
| **BACKGROUND** | Usar fondo `bk.jpg` si es `true`. |
| **LANGUAGE** | Idioma por defecto de la UI (`en`, `es`, …). |
| **SELF_SERVICE** | Si es `true`, la UI puede mostrar la entrada **Self-service** (el backend debe exponer API + BD). |
| **SELF_SERVICE** | Si es `true`, la UI puede mostrar la entrada **Self-service** (API del monitor + MySQL). |
| **SHOW_CONSOLE** | Mostrar página consola (mensajes inicio/fin de llamada). |
| **MIN_DURATION** | Duración mínima de llamada (segundos) para la tabla Last Heard del **panel** (la página Last Heard puede seguir mostrando más cortas). |
| **nav_links**, **footer**, **news** | Enlaces estructurados / marquee opcionales. |
---
## Esquema MySQL y migraciones
No hay Alembic: el monitor usa **`schema_migrations`** y comprobaciones en **`information_schema`**.
| Comando | Cuándo |
|---------|--------|
| `python db_bootstrap.py --config adn-monitor.yaml --create` | BD nueva |
| `python db_bootstrap.py --config adn-monitor.yaml --update` | BD existente (mismas operaciones, idempotente) |
**`monitor.py`** ejecuta **`ensure_schema`** al arrancar si hay **`SELF_SERVICE`**: `CREATE TABLE IF NOT EXISTS`, migraciones pendientes y limpieza de tablas staging huérfanas (`*_import`, `*_old`). **No borra datos** de `subscriber_ids` ni de `Clients`.
**Import masivo de alias (sin bloquear lecturas):**
- Tablas con **PK en `id`** únicamente (lookups puntuales).
- **Replace:** carga en `{tabla}_import` con **commit cada 10 000 filas** (la tabla live sigue legible); swap atómico `RENAME TABLE` (bloqueo metadata breve).
- **Merge** (ficheros locales): `INSERT IGNORE` con **commit cada 2 000 filas**.
Migraciones: `001_clients_callsign`, `002_clients_options_width`, `003_alias_pk_only`.
---
## Entorno
- **`ADN_CONFIG_PATH`**: ruta absoluta a **`adn-monitor.yaml`** para el **monitor** y el **backend PHP**.
- **`ADN_PROXY_CONFIG_PATH`** (opcional): ruta absoluta a **`adn-proxy.yaml`** para el proxy hotspot. Si no está definida, el proxy usa **`ADN_CONFIG_PATH`** (fichero combinado legado), luego **`proxy/adn-proxy.yaml`** por defecto — detalles en [Proxy hotspot](hotspot-proxy.md#configuration-file).
- El backend puede usar **`API_BASE_PATH`** si la API va bajo un prefijo.
- **`ADN_CONFIG_PATH`**: ruta absoluta a **`adn-monitor.yaml`** para **`monitor.py`**.
- **`.env`** en la raíz del repo: `VITE_API_BASE`, `VITE_DEFAULT_LANGUAGE` (build del frontend); se carga automáticamente en `monitor.py` y `db_bootstrap.py`.
---

@ -1,129 +1,8 @@
# Proxy hotspot
# Proxy hotspot (trasladado)
El **proxy hotspot** forma parte del repositorio **adn-monitor**. Es un **relé UDP** entre **hotspots DMR** (Homebrew / HBP) y el **MASTER** del **ADN DMR Peer Server**: cada hotspot conectado se asigna a un **puerto de destino** dedicado en el host del peer server, de modo que muchos hotspots puedan compartir una IP pública sin choques de puertos.
El proceso **`adn-proxy`** independiente y el árbol **`proxy/`** se **eliminaron** del repositorio **adn-monitor**.
Estructura: `proxy/proxy.py`, paquete `proxy/src/adn_proxy/` (arquitectura limpia). **GPL v3** (derivado del proxy original de Simon Adlem, G7RZU).
Usa el **proxy hotspot integrado** en **adn-server**:
### Por qué va con el monitor (y no dentro del peer server) {#why-it-ships-with-the-monitor-not-inside-the-peer-server}
No hay un despliegue obligatorio único, pero **hoy el proxy vive en el repo adn-monitor** a propósito:
- **Mismo despliegue** que el panel: **`ADN_CONFIG_PATH`** / **`ADN_PROXY_CONFIG_PATH`**, **`adn-monitor.yaml`** + **`adn-proxy.yaml`**, y normalmente el mismo host que **PHP** y **MySQL**.
- El **self-service** (**`Clients`**, RPTO, **`modified`**) está montado sobre ese ecosistema; el binario del peer server no posee esa base de datos ni el bloque **`PROXY`**.
- **División de roles:** el **ADN DMR Peer Server** es el **núcleo de radio** (HBP/OpenBridge, bridges, voz, informes TCP). El proxy hotspot es un **frente UDP opcional** hacia un MASTER que ya escucha en un **rango** de puertos — útil cuando muchos hotspots comparten una dirección pública.
**Integrar el proxy en el peer server** (un binario, un `adn-server.yaml`) es imaginable para empaquetado, pero implica **unificar configuración**, **replantear el cableado de self-service** y más mantenimiento — solo compensa si quieres explícitamente un servidor “todo en uno” desplegable.
---
## Fichero de configuración {#configuration-file}
El proxy **no** usa `adn-server.yaml`. Lee un YAML que incluye **`PROXY`**, **`SELF_SERVICE`** y **`LOGGER`** (log del proxy).
### Orden de resolución
| Prioridad | Origen | Uso |
|-----------|--------|-----|
| 1 | **`python proxy/proxy.py --config /ruta/fichero.yaml`** | Sobrescribe la ruta solo para este proceso. |
| 2 | **`ADN_PROXY_CONFIG_PATH`** | Variable opcional: ruta absoluta a **`adn-proxy.yaml`** (config dedicada del proxy). |
| 3 | **`ADN_CONFIG_PATH`** | Legado: ruta a un **fichero combinado** (p. ej. **`adn-monitor.yaml`** con **PROXY** incrustado — igual que monitor/backend). |
| 4 | **Por defecto** | **`proxy/adn-proxy.yaml`** junto a `proxy/proxy.py` si no hay variables de entorno. |
Copia **`proxy/adn-proxy.example.yaml`** a **`proxy/adn-proxy.yaml`** y edita. **`SELF_SERVICE`** debe coincidir con **`monitor/adn-monitor.yaml`** (mismas credenciales MySQL y PBKDF2).
Secciones leídas del fichero elegido:
- **`PROXY`** — dirección de escucha, host master, **rango** de puertos de destino, timeouts, debug, listas negras.
- **`SELF_SERVICE`** — MySQL y **`USE_SELFSERVICE`** (tabla **`Clients`**, RPTO / opciones).
- **`LOGGER`** — **`LOG_PATH`** y **`PROXY_LOG_FILE`** (separado del **`LOG_FILE`** de `adn-monitor.yaml` para `monitor.py`).
**Entorno** opcional (ver `proxy/README.md` en el repo): p. ej. **`ADN_PROXY_DEBUG`**, **`ADN_PROXY_LISTENPORT`**.
---
## Claves `PROXY` (en `adn-proxy.yaml`, o YAML combinado legado)
| Clave | Rol |
|-------|-----|
| **MASTER** | IP o **nombre de host** del **ADN DMR Peer Server**. Se resuelve a IPv4 al arrancar (Twisted necesita IP para `write()`). |
| **LISTEN_PORT** | Puerto UDP donde los **hotspots** se conectan **al proxy** (lo que configuran en el hotspot). |
| **LISTEN_IP** | Vacío suele significar todas las interfaces; si no, enlazar a esa dirección. |
| **PORT** / **DESTPORT_START** | Puerto UDP base en **`MASTER`** (alias **DESTPORT_START**); debe coincidir con **`SYSTEM.PORT`** en `adn-server`. |
| **GENERATOR** | El mismo entero que **`SYSTEM.GENERATOR`**; puertos UDP **`PORT`…`PORT+GENERATOR-1`** en **`MASTER`** (uno por sesión de hotspot proxy). |
| **TIMEOUT** | Tiempo de inactividad / sesión (segundos). |
| **STATS** | Registro extra de estadísticas. |
| **DEBUG** | Log detallado de paquetes (o **`ADN_PROXY_DEBUG=1`**). |
| **CLIENT_INFO** | Información por cliente en logs. |
| **BLACK_LIST** / **IP_BLACK_LIST** | Bloquear IDs de radio o IPs de origen. |
Las claves internas tras la carga usan nombres mixtos (`Master`, `ListenPort`, …) — ver `adn_proxy.infrastructure.config_loader`.
---
## El peer server (`adn-server.yaml`) debe cubrir el rango de puertos
El proxy reenvía tráfico a **`MASTER:puerto_asignado`** por cliente; **puerto_asignado** se elige en **`PORT`…`PORT+GENERATOR-1`** (la misma semántica de **PORT**/**GENERATOR** que en [Configuración del servidor](../server/user-guide/configuration.md)).
El **ADN DMR Peer Server** debe **escuchar UDP** en **ese host** en **cada puerto** de ese rango (normalmente mediante **`GENERATOR`** en un bloque SYSTEM).
- Alinea **`PROXY.PORT`** y **`PROXY.GENERATOR`** con **`SYSTEM.PORT`** y **`SYSTEM.GENERATOR`** en **`adn-server.yaml`**.
- Configuración típica: un **`MODE: MASTER`** con **`GENERATOR`** que expande a `SYSTEM-0`…`SYSTEM-(N-1)` en puertos UDP consecutivos — ver [Configuración del servidor](../server/user-guide/configuration.md).
Si el servidor solo escucha p. ej. en **56400** pero el proxy envía a **56401**, ese cliente no se registrará.
---
## Cómo arranca el proceso
1. Resolver ruta de config (`--config`, **`ADN_PROXY_CONFIG_PATH`**, **`ADN_CONFIG_PATH`**, o por defecto **`proxy/adn-proxy.yaml`**).
2. **`load_config()`** parsea YAML → **`PROXY`**, **`SELF_SERVICE`**, **`LOG`**.
3. Pool **MySQL** opcional si self-service / funciones de BD están activas.
4. El **reactor** Twisted ejecuta UDP **ProxyProtocol** en **`LISTEN_IP:LISTEN_PORT`**, reenviando a **`MASTER:puerto_destino_asignado`**.
Ejecución (desde la raíz de adn-monitor):
```bash
# YAML dedicado del proxy (recomendado)
export ADN_PROXY_CONFIG_PATH=/opt/adn-monitor/proxy/adn-proxy.yaml
python proxy/proxy.py
# O usar por defecto proxy/adn-proxy.yaml tras copiar desde adn-proxy.example.yaml
python proxy/proxy.py
# Legado: un solo YAML combinado con el monitor
export ADN_CONFIG_PATH=/opt/adn-monitor/monitor/adn-monitor.yaml
python proxy/proxy.py
# O ruta explícita para una ejecución
python proxy/proxy.py --config /opt/adn-monitor/proxy/adn-proxy.yaml
```
Usar **systemd** u otro supervisor junto a **`monitor.py`** y la pila **PHP**.
---
## RPTO, opciones y self-service
El proxy **nunca** envía **RPTO** directamente al hotspot para actualizaciones self-service. Envía **RPTO al MASTER** (peer server); el servidor actualiza bridges/opciones y aplica el flujo HBP normal.
| Evento | Comportamiento del proxy |
|--------|---------------------------|
| ~**10 s** tras login del hotspot (**RPTC**) | Leer **`Clients.options`** de la BD → **RPTO** → master `(MASTER, dport)`. |
| Cada ~**10 s** | Filas con **`modified = 1`** → **RPTO** → master, luego limpiar **`modified`**. |
| El hotspot envía **RPTO** | Reenvío al master; actualizaciones de BD según implementación. |
Detalle: [Self-service](self-service.md) y **`proxy/README.md`** en adn-monitor.
---
## Visibilidad en el monitor
Los hotspots aparecen en el panel solo si el **peer server** envía **informes TCP** al mismo host/puerto que **`ADN_CONNECTION`** en **`adn-monitor.yaml`**. Alinea **`REPORTS`** en el servidor con **`ADN_IP` / `ADN_PORT`**. Ver [Monitor e informes](../server/user-guide/monitoring.md).
---
## Ver también
- [Configuración del monitor](configuration.md) — **`adn-monitor.yaml`** (panel, informes, MySQL para backend/monitor); detalle **`PROXY`** arriba.
- [Arquitectura](architecture.md) — dónde encaja el proxy en la pila.
- [Self-service](self-service.md) — BD, **`modified`**, temporización RPTO.
- [Proxy hotspot integrado](../server/user-guide/hotspot-proxy.md) — `PROXY` y `SELF_SERVICE` en **`adn-server.yaml`**
- [Descripción general del monitor](index.md) — pila del panel (`monitor.py` + React)

@ -1,6 +1,6 @@
# ADN Monitor (descripción general)
**ADN Monitor** es un proyecto distinto del **ADN DMR Peer Server**, pero ambos suelen desplegarse **juntos**: el servidor envía **informes TCP** (config, bridges, eventos de llamada) al monitor; el monitor alimenta el **panel web** (React) y las actualizaciones **WebSocket**. Los componentes opcionales incluyen la **API PHP** (Slim), **MySQL** (self-service / registro de dispositivos) y el **proxy hotspot** (UDP entre hotspots y el peer server).
**ADN Monitor** es un proyecto distinto del **ADN DMR Peer Server**, pero ambos suelen desplegarse **juntos**: el servidor envía **informes TCP** (o MQTT) al monitor; el monitor alimenta el **panel web** (React) y las actualizaciones **WebSocket**. Un único proceso **`monitor.py`** (FastAPI) sirve **REST** (`/api/*`), **WebSocket** (`/ws`) e **ingest** de informes. Opcional: **MySQL** (self-service / Last Heard) y **proxy hotspot** integrado en **`adn-server.py`**.
Este capítulo documenta la pila **adn-monitor** con el mismo nivel de detalle que las guías del servidor. El código fuente está en el repositorio **adn-monitor**, no en el repositorio **adn-server** (donde se mantiene esta documentación).
@ -8,19 +8,17 @@ Este capítulo documenta la pila **adn-monitor** con el mismo nivel de detalle q
| Parte | Rol |
|-------|-----|
| **`monitor/monitor.py`** | Python (Twisted): se conecta al **puerto TCP de informes** del peer server, decodifica cargas netstring (`CONFIG_SND`, `BRIDGE_SND`, `BRDG_EVENT`), mantiene **CTABLE** / **BTABLE**, escribe **Last Heard** / estadísticas de TG en **MySQL** si está configurado, sirve JSON **WebSocket** al panel. |
| **`backend/`** | PHP **Slim**: `/api/config/dashboard`, auth, APIs **self-service** opcionales, proxy de alias. Lee **`adn-monitor.yaml`** vía **`ADN_CONFIG_PATH`**. |
| **`frontend/`** | React (Vite): UI del panel; consume API del backend + WebSocket. |
| **`proxy/`** | Python (Twisted): **proxy hotspot** UDP; reenvía Homebrew entre hotspots y el peer server; lee **`Clients`** en MySQL para opciones **RPTO** (self-service). Carga **`adn-proxy.yaml`** por defecto (ver [Proxy hotspot](hotspot-proxy.md)). |
| **`monitor/monitor.py`** | FastAPI: REST (`/api/*`), WebSocket (`/ws`), ingest TCP o MQTT, estado **CTABLE** / Last Heard, self-service MySQL. |
| **`frontend/`** | React (Vite): UI del panel; mismo origen `/api` + `/ws`. |
## Ficheros de configuración
| Fichero | Quién lo usa | Variable típica |
|---------|----------------|-----------------|
| **`monitor/adn-monitor.yaml`** | **`monitor.py`**, **backend PHP** | **`ADN_CONFIG_PATH`** |
| **`proxy/adn-proxy.yaml`** | **`proxy/proxy.py`** | **`ADN_PROXY_CONFIG_PATH`** (opcional; valores por defecto y legado en [Proxy hotspot](hotspot-proxy.md#configuration-file)) |
| **`adn-server.yaml`** | **`adn-server.py`** (**`PROXY`** / **`SELF_SERVICE`** integrados) | `-c` / ruta por defecto junto al binario |
| **`monitor/adn-monitor.yaml`** | **`monitor.py`** | **`ADN_CONFIG_PATH`** |
**`SELF_SERVICE`** (MySQL / PBKDF2) debe **coincidir** entre ambos YAML cuando separas la config. **`ADN_CONNECTION`**, panel, WebSocket y alias van en **`adn-monitor.yaml`**; los ajustes **`PROXY`** de escucha/master/rango van en **`adn-proxy.yaml`**, salvo que uses un **fichero único legado** vía **`ADN_CONFIG_PATH`** para el proxy.
**`SELF_SERVICE`** (MySQL / PBKDF2) debe **coincidir** entre **`adn-server.yaml`** y **`adn-monitor.yaml`**. **`ADN_CONNECTION`**, panel, WebSocket y alias van en **`adn-monitor.yaml`**; **`PROXY`** integrado va en **`adn-server.yaml`** — ver [Proxy hotspot integrado](../server/user-guide/hotspot-proxy.md).
## Enlace con el peer server
@ -33,7 +31,7 @@ Ver [Monitor e informes](../server/user-guide/monitoring.md) para los opcodes de
## Ver también
- [Proxy hotspot](hotspot-proxy.md) — `PROXY`, rango de puertos del peer, carga de config y arranque
- [Proxy hotspot (integrado)](../server/user-guide/hotspot-proxy.md) — `PROXY` en `adn-server.yaml`
- [Arquitectura e implantación](architecture.md)
- [Configuración (`adn-monitor.yaml`)](configuration.md)
- [Self-service](self-service.md)

@ -2,14 +2,14 @@
**Self-service** permite al dueño de un hotspot **iniciar sesión** en el panel, **editar las opciones de su dispositivo** (listas estáticas de TG, reflector por defecto, temporizador, idioma, etc.) y que esas opciones se **envíen al ADN DMR Peer Server** sin editar YAML a mano en el servidor.
Intervienen **cuatro** piezas: **MySQL** (tabla `Clients`), **API PHP** (sesión + REST), **React** (página `/self-service`) y el **proxy hotspot** (**RPTO** periódico al master). El **peer server** es quien aplica al final **RPTO** / **OPTIONS** al bridge en ejecución y al comportamiento del hotspot.
Intervienen **cuatro** piezas: **MySQL** (tabla `Clients`), **API del monitor** (FastAPI, sesión + REST), **React** (página `/self-service`) y el **proxy hotspot integrado** en **adn-server** (**RPTO** periódico al master). El **peer server** es quien aplica al final **RPTO** / **OPTIONS** al bridge en ejecución y al comportamiento del hotspot.
---
## Requisitos previos
1. Bloque **`SELF_SERVICE`** en **`adn-monitor.yaml`** con credenciales **MySQL** válidas y parámetros **PBKDF2** alineados con tu herramienta de contraseñas (misma sal/iteraciones que **`hotspot_proxy_self_service.py`** cuando se use).
2. **`DASHBOARD.SELF_SERVICE: true`** para que la UI muestre la entrada **Self-service** (y el backend exponga `/api/self-service/*` cuando la BD conecta).
2. **`DASHBOARD.SELF_SERVICE: true`** para que la UI muestre la entrada **Self-service** (y `monitor.py` exponga `/api/self-service/*` cuando la BD conecta).
3. Tabla **`Clients`** con filas: **`callsign`**, **`int_id`** (ID DMR), **`psswd`** (hex PBKDF2-SHA256), **`options`** (línea `KEY=value` separada por `;`), **`logged_in`**, **`host`**, **`modified`**, etc. (ver esquema / migraciones en el repo adn-monitor).
4. El tráfico del hotspot debe pasar por el **proxy** si dependes de **`modified`** y del envío **RPTO** (ver flujo abajo).
@ -19,7 +19,7 @@ Intervienen **cuatro** piezas: **MySQL** (tabla `Clients`), **API PHP** (sesión
| Endpoint | Uso |
|----------|-----|
| **`POST /api/auth/login`** | Cuerpo: `callsign`, `password`. Verifica hash **PBKDF2** contra **`Clients.psswd`** en filas con **`logged_in = 1`**. Éxito: sesión PHP con **`user_id`**, **`int_ids`** (todos los DMR ID de ese indicativo). |
| **`POST /api/auth/login`** | Cuerpo: `callsign`, `password`. Verifica hash **PBKDF2** contra **`Clients.psswd`** en filas con **`logged_in = 1`**. Éxito: cookie de sesión con **`user_id`**, **`int_ids`** (todos los DMR ID de ese indicativo). |
| **`GET /api/auth/login-by-ip`** | Opcional: una coincidencia de usuario por **`Clients.host`** = IP del cliente (misma forma de sesión). |
| **`POST /api/auth/logout`** | Cierra sesión. |
| **`GET /api/auth/me`** | Devuelve `{ callsign, int_ids, selected_int_id }` para React. |
@ -41,7 +41,7 @@ La sesión se prolonga con actividad (**SelfServiceController** usa un timeout l
## Flujo extremo a extremo (cómo llegan las opciones al hotspot)
1. El usuario guarda opciones en la web → **PHP** escribe **`Clients.options`** y **`modified = 1`**.
1. El usuario guarda opciones en la web → la **API del monitor** escribe **`Clients.options`** y **`modified = 1`**.
2. El **proxy hotspot** ejecuta **`send_opts`** en bucle (~cada **10 s**). Para filas con **`modified = 1`**, lee opciones de la BD, envía **RPTO** **al peer server** en **`(MASTER, puerto_destino_asignado)`**, luego limpia **`modified`** en la BD.
3. El **ADN DMR Peer Server** recibe **RPTO** en la pata MASTER y actualiza su estado **OPTIONS** / bridge (mismo camino que un refresco normal de registro del hotspot).
4. El servidor envía la señalización adecuada al **hotspot** para que TG estáticos / reflector / temporizador surtan efecto **sin** reinicio completo del hotspot (el comportamiento coincide con el flujo HBP **OPTIONS** del servidor).

@ -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,14 +12,26 @@
`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/` |
## Configuración como estado compartido

@ -19,7 +19,7 @@ Los siguientes intervalos forman parte del comportamiento actual en ejecución:
| `rule_timer` | **52s** | Progresión de timeout y estado on/off de bridges. |
| `stream_trimmer` | **5s** | Limpieza de streams, manejo de timeout y cierre de estado de llamada. |
| `bridge_reset` | **6s** | Limpieza de flags de reset y cierre de resets pendientes. |
| `options_config_loop` | **26s** | Refresco de TG estáticas / reflector desde payload OPTIONS de peers. |
| OPTIONS refresh | **por evento** | TG estáticas / reflector vía **RPTO**, **startup/reload** (`apply_startup_bridges`), fallback **dmrd** sin source. Sin loop periódico de 26s (**D-28**). |
| `statTrimmer` | **303s** | Limpieza de bridges STAT obsoletos y estados transitorios. |
Si cambias uno de estos intervalos, documenta el impacto operativo en monitorización, comportamiento de bucles y troubleshooting.

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

@ -0,0 +1,319 @@
# Protocolo de informes v2 (JSON)
**Estado:** borrador de esquema. La codificación en wire y la emisión en el servidor están en progreso; el consumidor monitor v2 es un entregable aparte.
## Objetivos
Sustituir instantáneas al monitor que hoy usan **pickle** (`CONFIG_SND`, `BRIDGE_SND`) y **CSV** (`BRDG_EVENT`) por **JSON tipado**.
**Política de releases:** **adn-server 1.0.x** + **adn-monitor 1.0.x** = report v1 (tags congelados). El servidor **2.x** emite **solo report v2** (sin shim pickle ni `dual`); requiere **adn-monitor 2.x** en la misma línea.
## Transporte (sin cambios)
- TCP **netstring** (Twisted `NetstringReceiver`), mismo puerto que v1 (`REPORTS.REPORT_PORT`).
- Cada trama: **1 byte de opcode** + payload **JSON UTF-8** (v2) o pickle/CSV (v1).
### Opcodes
| Opcode | Hex | Payload v1 | Payload v2 |
|--------|-----|------------|------------|
| `HELLO` | `0xFF` | JSON hello (`protocol`: 1) | JSON hello (`report_protocol`: 2) |
| `CONFIG_SND` | `0x01` | pickle SYSTEMS | — (`TOPOLOGY_SND`) |
| `BRIDGE_SND` | `0x03` | pickle BRIDGES | — (`ROUTING_TABLE_SND`) |
| `BRDG_EVENT` | `0x07` | texto CSV | — (`VOICE_EVENT_SND`) |
| `TOPOLOGY_SND` | `0x10` | — | JSON `topology` |
| `ROUTING_TABLE_SND` | `0x11` | — | JSON `routing_table` |
| `VOICE_EVENT_SND` | `0x12` | — | JSON `voice_event` |
| `DELTA_SND` | `0x13` | — | JSON `delta` |
Los opcodes `0x10`–`0x13` están reservados en esta fase; pueden ajustarse antes de P1-002.
## Handshake (`hello`)
Al conectar, el servidor envía **`HELLO` (`0xFF`)** primero. Clientes v2 miran `report_protocol`:
```json
{
"type": "hello",
"server": "adn-server",
"version": "2.0.0-alpha.1",
"report_protocol": 2,
"features": ["INGRESS", "END_TX_FORWARD", "PUSH_ON_CONNECT", "REPORT_V2", "TOPOLOGY_JSON", "ROUTING_TABLE_JSON", "VOICE_EVENT_JSON", "DELTA_UPDATES"],
"systems": ["MASTER-A", "OBP-CL"]
}
```
| Campo | Notas |
|-------|--------|
| `report_protocol` | **2** para este esquema. Distinto del campo legacy `protocol: 1`. |
| `features` | Tokens v1 sin cambios; v2 añade `REPORT_V2` y capacidades de payload. |
## Tipos de mensaje
| `type` | Sustituye | Uso |
|--------|-----------|-----|
| `topology` | `CONFIG_SND` | Sistemas, peers, piernas OBP (sin secretos). |
| `routing_table` | `BRIDGE_SND` | Piernas de bridge por TG / reflector. |
| `voice_event` | `BRDG_EVENT` | Inicio/fin/ingress de llamadas. |
| `delta` | — | Parche incremental desde `since_seq`. |
## Payloads de referencia
Cada trama lleva un único objeto JSON con `type` obligatorio. Los ejemplos usan IDs anonimizados.
### `topology`
```json
{
"type": "topology",
"seq": 1,
"ts": 1717555200.0,
"systems": [
{
"name": "MASTER-A",
"mode": "MASTER",
"enabled": true,
"ip": "10.0.0.1",
"port": 62030,
"repeat": true,
"peers": [
{ "id": 3120001, "connected": true, "ip": "10.0.0.50", "port": 62031 }
]
},
{
"name": "OBP-CL",
"mode": "OPENBRIDGE",
"enabled": true,
"ip": "10.0.0.2",
"port": 62044,
"enhanced_obp": true,
"peers": []
}
]
}
```
| Campo | Tipo | Obligatorio | Notas |
|-------|------|-------------|-------|
| `seq`, `ts` | int, float | sí | Secuencia monótona y epoch. |
| `systems[].name` | string | sí | Clave de sistema (config). |
| `systems[].mode` | string | sí | `MASTER`, `PEER` u `OPENBRIDGE`. |
| `systems[].enabled` | bool | sí | Flag enabled de config. |
| `systems[].ip`, `port` | string, int | no | Endpoint de escucha/conexión. |
| `systems[].repeat` | bool | no | Repeat en master. |
| `systems[].enhanced_obp` | bool | no | OBP enhanced. |
| `systems[].peers[]` | array | no | `{id, connected, ip?, port?}` por radio peer. |
Sin contraseñas ni material de cifrado (a diferencia del pickle legacy).
### `routing_table`
```json
{
"type": "routing_table",
"seq": 42,
"ts": 1717555260.5,
"routes": [
{
"bridge_key": "52090",
"legs": [
{
"system": "MASTER-A",
"ts": 2,
"tgid": 52090,
"active": true,
"to_type": "ON",
"timer_expires_at": 1717555320.0
},
{
"system": "MASTER-B",
"ts": 2,
"tgid": 52090,
"active": true,
"to_type": "ON"
}
]
},
{
"bridge_key": "#310",
"legs": [
{
"system": "MASTER-A",
"ts": 2,
"tgid": 310,
"active": false,
"to_type": "NONE"
}
]
}
]
}
```
| Campo | Tipo | Obligatorio | Notas |
|-------|------|-------------|-------|
| `routes[].bridge_key` | string | sí | TG o clave reflector (`#nnn`). |
| `legs[].system` | string | sí | Nombre del sistema destino. |
| `legs[].ts` | 1 \| 2 | sí | Timeslot (legacy `TS`). |
| `legs[].tgid` | int | sí | Talkgroup (1–16777215). |
| `legs[].active` | bool | sí | Pierna activa en BRIDGES. |
| `legs[].to_type` | string | sí | `ON`, `OFF`, `STAT` o `NONE`. |
| `legs[].timer_expires_at` | float | no | Expiración `rule_timer` (legacy `TIMER`). |
### `voice_event`
CSV legacy:
```text
GROUP VOICE,START,RX,MASTER-A,2155905152,1001,3120001,2,52090
```
Equivalente v2:
```json
{
"type": "voice_event",
"ts": 1717555201.234,
"call_family": "GROUP",
"phase": "START",
"direction": "RX",
"system": "MASTER-A",
"stream_id": 2155905152,
"peer_id": 1001,
"src_id": 3120001,
"slot": 2,
"dst_id": 52090,
"duration_s": null
}
```
| Campo | Tipo | Obligatorio | Valores / notas |
|-------|------|-------------|-----------------|
| `call_family` | string | sí | `GROUP`, `PRIVATE`, `UNIT`, `VCSSBK`. |
| `phase` | string | sí | `INGRESS`, `START`, `END`. |
| `direction` | string | sí | `RX`, `TX`. |
| `stream_id` | int | sí | Stream HBP 32 bits. |
| `peer_id`, `src_id`, `dst_id` | int | sí | IDs DMR (1–16777215). |
| `slot` | int | sí | `1` o `2`. |
| `duration_s` | float \| null | no | En eventos `END`. |
Semántica OpenBridge INGRESS/START: ver [Monitor e informes](../user-guide/monitoring.md).
### `delta`
```json
{
"type": "delta",
"seq": 43,
"ts": 1717555261.0,
"since_seq": 42,
"patch": {
"type": "routing_table",
"seq": 43,
"ts": 1717555261.0,
"routes": [
{
"bridge_key": "52090",
"legs": [
{
"system": "MASTER-A",
"ts": 2,
"tgid": 52090,
"active": false,
"to_type": "ON"
}
]
}
]
}
}
```
| Campo | Tipo | Obligatorio | Notas |
|-------|------|-------------|-------|
| `since_seq` | int | sí | Último `seq` aplicado por el cliente. |
| `patch` | object | sí | Parcial `topology` o `routing_table` (misma forma). |
## Secuenciación
- `topology` y `routing_table` llevan `seq` (uint) y `ts` (epoch float).
- `delta` indica `since_seq` respecto al último `seq` aplicado por el cliente.
## Configuración
```yaml
REPORTS:
REPORT: true
REPORT_PORT: 4321
```
Sin `PROTOCOL` en **2.x** — wire siempre JSON (`wire.py`). Mapeo: **`application/report/`**.
### Espejo MQTT opcional
Por defecto los informes salen **solo** por TCP netstring (adn-monitor y otros clientes TCP). MQTT queda **deshabilitado** salvo activación explícita.
**Activar** solo cuando se cumplen ambas condiciones:
1. `REPORTS.MQTT.ENABLED: true` (booleano `true`, no basta con existir la clave)
2. `REPORTS.MQTT.URL` — URL del broker (`mqtt://host:1883` o `mqtts://host:8883`)
```yaml
REPORTS:
REPORT: true
REPORT_PORT: 4321
MQTT:
ENABLED: true
URL: mqtt://127.0.0.1:1883
TOPIC_PREFIX: adn/73010 # opcional; por defecto adn/{GLOBAL.SERVER_ID}
USERNAME: my-mqtt-user # opcional; sustituye al usuario en la URL
PASSWORD: my-mqtt-secret # opcional; sustituye a la contraseña en la URL
CAFILE: /path/to/ca.pem # opcional; CA del broker con mqtts://
QOS: 0 # opcional, 0–2
```
**Client ID:** generado al arrancar como `adn-server-{GLOBAL.SERVER_ID}-{random}` (no configurable; el sufijo aleatorio evita colisiones de sesión en el broker al reiniciar).
**Autenticación:** usuario/contraseña con `USERNAME` y `PASSWORD`, o embebidos en la URL (`mqtt://user:pass@host:1883`). Las credenciales YAML tienen prioridad sobre la URL. La contraseña puede ir vacía si el broker lo permite. Con `mqtts://`, indique `CAFILE` si el broker usa una CA propia.
Dependencia opcional: `pip install 'adn-server[mqtt]'` (paho-mqtt). Si MQTT está habilitado pero falta la librería, el servidor registra error y sigue solo con TCP.
**Wire MQTT (fijo, no configurable):** solo **`voice_event`** (telemetría) y **`state`** (snapshot). Los tipos solo-TCP (`topology`, `routing_table`, `delta`, `hello`) **no** se publican por MQTT.
**Convención de topics** (compartidos bajo `{prefix}`):
| Topic | Dirección | `type` JSON | Retain |
|-------|-----------|-------------|--------|
| `voice_event` | servidor → broker | `voice_event` | no |
| `state` | servidor → broker | `dashboard_state` | sí |
`state` incluye masters con peers conectados, peers sueltos y openbridges (equivalente WebSocket `conf,lnksys` + `conf,opb`). Va con **retain** para que nuevos suscriptores reciban el último snapshot sin pedirlo. Los **refrescos por topología** republican `{prefix}/state` cuando cambia el dashboard (dedup).
**Disparadores:** `voice_event` en vivo; `{prefix}/state` retenido al cambiar topología y tras conectar MQTT (dedup).
**Ejemplo** (`SERVER_ID` 7302):
```bash
mosquitto_sub -h BROKER -p 1883 -u USER -P PASS -t 'adn/7302/state' -v
mosquitto_sub -h BROKER -p 1883 -u USER -P PASS -t 'adn/7302/voice_event' -v
```
**ACL TBMQ:** consumidores con **subscribe** en `adn/7302/state` y `adn/7302/voice_event`; el `client_id` del servidor solo necesita **publish** en esos dos topics (sin subscribe en el servidor).
**Recarga (`systemctl reload` / SIGHUP):** si cambia `REPORTS.MQTT` (activar/desactivar, URL, credenciales, TLS, `TOPIC_PREFIX`, `QOS`), el servidor desconecta el cliente MQTT anterior y conecta con la nueva config, o se queda sin MQTT si `ENABLED` pasa a false.
**QoS:** configurable con `MQTT.QOS` (por defecto `0`).
Los timers usan **deltas** cuando solo cambia parte de `BRIDGES`; conexión, `REPORT_INTERVAL`, reload y `CONFIG_REQ` / `BRIDGE_REQ` envían instantáneas **completas**.
## Acoplamiento de versiones (combinaciones soportadas)
| Servidor | Monitor | Wire de informes | Notas |
|----------|---------|------------------|--------|
| **1.0.x** | **1.0.x** | v1 (pickle/CSV) | Par congelado |
| **2.0.0-alpha.\*** | **2.x** (dev) | solo v2 | Línea `develop` actual |
| **2.0.0** | **2.0.x** | solo v2 | Par GA; monitor 1.0.x **no** soportado |
No usar monitor 1.0.x con servidor 2.0.0 ni monitor 2.x con servidor 1.0.x en producción.
Ver también [Monitor e informes](../user-guide/monitoring.md).

@ -27,15 +27,11 @@ Tras editar la config principal puedes recargar **sin reiniciar** el proceso (se
kill -HUP $(pidof adn-server.py) # o: systemctl reload adn-server
```
Con **systemd**, en la unidad:
Unidad de ejemplo: **`examples/systemd/adn-server.service`** (copiar a `/etc/systemd/system/`; incluye `ExecReload` para `systemctl reload`).
```ini
ExecReload=/bin/kill -HUP $MAINPID
```
**Se recarga:** `GLOBAL`, `REPORTS`, `ALIASES`, **`LOGGER.LOG_LEVEL`** (sin reiniciar el proceso), parámetros por system, **systems nuevos/eliminados** (incluida expansión `GENERATOR` y OBP nuevos), y cambios de IP/puerto (solo reinicia el listener de ese system).
**Se recarga:** `GLOBAL`, `REPORTS`, `ALIASES`, **`LOGGER.LOG_LEVEL`** (sin reiniciar el proceso), **`PROXY`** (timeouts, debug, listas de bloqueo — no bind ni destino), **`SELF_SERVICE`** (fusionado; activar/desactivar bucles BD requiere reinicio), parámetros por system, **systems nuevos/eliminados** (incluida expansión/colapso `GENERATOR` y OBP nuevos), y cambios de IP/puerto (solo reinicia el listener de ese system).
**No se recarga:** `adn-voice.yaml` (loop aparte cada 15 s), código Python, ficheros de alias (recarga periódica). La tabla **BRIDGES** no se reconstruye — reinicia si cambiaste reglas de bridge que exijan reset completo.
**No se recarga:** `adn-voice.yaml` (loop aparte cada 15 s), código Python, ficheros de alias (recarga periódica). La tabla **BRIDGES** no se reconstruye — reinicia si cambiaste reglas de bridge que exijan reset completo. **`PROXY.LISTEN_PORT`**, **`LISTEN_IP`** y **`TARGET_SYSTEM`** requieren **reinicio completo** para aplicarse.
**Secretos:** no versionar passphrases reales, URLs de seguridad ni `user_passwords.json` / `encryption_key.secret`. Usa placeholders en plantillas y mantén producción en local.
@ -50,12 +46,12 @@ Existen tres **modos**:
| Modo | Uso típico | Escucha | Conecta aguas arriba |
|------|------------|---------|----------------------|
| **MASTER** | Servidor de conferencia para uno o más hotspots/repetidores | **Sí** — `IP` / `PORT`, los peers se registran con passphrase | No (los peers se conectan a ti) |
| **PEER** | Hotspot/repetidor o servicio (p. ej. parrot) como **cliente** de un MASTER | **Sí** — `IP` / `PORT` local | **Sí** — `MASTER_IP` / `MASTER_PORT` deben apuntar al MASTER |
| **PEER** | Hotspot/repetidor o servicio (p. ej. echo) como **cliente** de un MASTER | **Sí** — `IP` / `PORT` local | **Sí** — `MASTER_IP` / `MASTER_PORT` deben apuntar al MASTER |
| **OPENBRIDGE** | Enlace a otro **servidor** por OpenBridge (DMRD v1 / DMRE) | **Sí** — `IP` / `PORT` | **Sí** — `TARGET_IP` / `TARGET_PORT` (servidor par) |
**MASTER** mantiene la tabla **`PEERS`** en tiempo de ejecución (hotspots autenticados). **PEER** mantiene **STATS** (conexión, pings). **OPENBRIDGE** usa **NETWORK_ID**, **PASSPHRASE**, **TARGET_***, **PROTO_VER** / **VER**, y opcionalmente **ENHANCED_OBP**, **RELAX_CHECKS**, **TGID_ACL**.
Un solo proceso puede ejecutar **varios** sistemas a la vez (p. ej. un MASTER para usuarios, un ECHO para parrot, un OBP hacia una red asociada).
Un solo proceso puede ejecutar **varios** sistemas a la vez (p. ej. un MASTER para usuarios, un ECHO para playback, un OBP hacia una red asociada).
---
@ -115,16 +111,16 @@ Aparecen principalmente en **MASTER** (y a menudo en **PEER**). OpenBridge usa u
| Clave | Significado |
|-------|-------------|
| **REPEAT** | Si es true, el tráfico recibido puede **repetirse** a otros peers conectados al MASTER (comportamiento típico de conferencia). |
| **MAX_PEERS** | Máximo de hotspots conectados. |
| **MAX_PEERS** | Máximo de hotspots conectados. En el MASTER **destino del proxy**, limita sesiones fan-in simultáneas. |
| **EXPORT_AMBE** | Flag de exportación AMBE (si está habilitado en el build). |
| **SINGLE_MODE** | Afecta a OPTIONS / expansión del generador (estilo un solo usuario). |
| **VOICE_IDENT** | Habilita **identificación por voz** periódica cuando se cumplen condiciones (ver `IdentUseCases`). |
| **TS1_STATIC** / **TS2_STATIC** | Listas estáticas de TG separadas por comas, enviadas vía manejo OPTIONS (ver `options_config`). |
| **DEFAULT_REFLECTOR** | Número de **reflector** por defecto para bridges de marcado `#` (0 = ninguno). |
| **OVERRIDE_IDENT_TG** | TG opcional para ident por voz en lugar de all-call. |
| **GENERATOR** | Si es **> 1**, este MASTER se expande en **`NAME-0`**, **`NAME-1`**, … con puertos consecutivos (ver `expand_generator` en código). |
| **GENERATOR** | Si es **> 1**, este MASTER se expande en **`NAME-0`**, **`NAME-1`**, … con puertos consecutivos (ver `expand_generator` en código). El **`adn-proxy`** independiente legado usaba el mismo rango; el proxy **integrado** usa **`PROXY.TARGET_SYSTEM`** solo inyección (sin puertos UDP por hotspot en el servidor). |
**MASTER** escucha conexiones PEER; cada peer autenticado se guarda en **`PEERS`** en tiempo de ejecución.
**MASTER** escucha conexiones PEER (salvo que sea el destino **solo inyección** del proxy — ver [Proxy hotspot](hotspot-proxy.md)); cada peer autenticado se guarda en **`PEERS`** en tiempo de ejecución.
---
@ -140,7 +136,7 @@ Un **PEER** conecta **saliente** hacia un **MASTER** y escucha localmente para l
| **OPTIONS** | Cadena / línea de opciones (p. ej. `TS2=9990;`) para TG estáticas / comportamiento. |
| **LOOSE** | Flag de manejo relajado donde aplique. |
El ejemplo **parrot** (`adn-parrot.example.yaml`) es un PEER que se une al MASTER **ECHO**: mismo **PASSPHRASE**, **MASTER_PORT** = **PORT** del ECHO. Ver [Parrot](parrot.md).
El ejemplo **echo** (`adn-echo.example.yaml`) es un PEER que se une al MASTER **ECHO**: mismo **PASSPHRASE**, **MASTER_PORT** = **PORT** del ECHO. Ver [Echo](echo.md).
---
@ -186,6 +182,28 @@ Detalle: [Monitor e informes](monitoring.md).
---
## `PROXY` (proxy hotspot integrado)
Se arranca siempre que exista un bloque **`PROXY`** (ver `adn-server.example.yaml`). Los hotspots se conectan a **`LISTEN_PORT`**; el tráfico se inyecta en **`TARGET_SYSTEM`**. Guía completa: [Proxy hotspot](hotspot-proxy.md).
| Clave | Significado |
|-------|-------------|
| **LISTEN_PORT** / **LISTEN_IP** | Bind UDP para conexiones de hotspots. |
| **TARGET_SYSTEM** | Nombre del **MASTER** que recibe HBP inyectado. Ese system pasa a **solo inyección** (`IP` / `PORT` eliminados al cargar). |
| **TIMEOUT** | Timeout de sesión inactiva (segundos). |
| **DEBUG** / **CLIENT_INFO** | Verbosidad de logs. |
| **BLACK_LIST** / **IP_BLACK_LIST** | Bloqueo de IDs de radio o IPs de cliente. |
**No** ejecutes **`adn-proxy`** independiente en el mismo **`LISTEN_PORT`** si el proxy integrado está activo.
---
## `SELF_SERVICE` (MySQL / opciones del panel)
Opcional; requiere `pip install -e ".[selfservice]"` con **`USE_SELFSERVICE: true`**. Usa la misma tabla **`Clients`** y parámetros PBKDF2 que **adn-monitor**. Las claves coinciden con la documentación del monitor — ver [Self-service](../../monitor/self-service.md) y [Proxy hotspot](hotspot-proxy.md#claves-self_service).
---
## `LOGGER`
Implementado en `infrastructure/logging_config.py` (`setup_logging`). Los valores se leen del bloque **`LOGGER`** (o `--logging` solo para **LOG_LEVEL**).
@ -246,4 +264,5 @@ Usa el intérprete del proyecto (ver reglas del workspace), p. ej. `python3.11`
- [Introducción](introduction.md) — rol del servidor.
- [Bridges y talkgroups](bridges-and-talkgroups.md) — semántica de `BRIDGES`.
- [Números especiales](special-numbers.md) — TG e IDs reservados.
- [Parrot](parrot.md) — ejemplo PEER (proceso parrot).
- [Echo](echo.md) — ejemplo PEER (proceso echo).
- [Proxy hotspot](hotspot-proxy.md) — **`PROXY`** / **`SELF_SERVICE`** integrados.

@ -0,0 +1,42 @@
# Echo (reproducción)
## Qué es
**Echo** graba voz de **grupo** entrante y la reproduce en el TG **9990**. Corre como **PEER** conectado al master **ECHO** del peer server principal (bridge TG 9990).
El runtime de playback forma parte de **`adn-server`**; ejecútalo con **`adn-server.py --echo`** y un **`adn-echo.yaml`** mínimo.
## Configuración
Usa un **YAML aparte y mínimo** — solo lo que el PEER necesita para unirse a **ECHO** en `adn-server.yaml`:
| Campo | Rol |
|-------|------|
| `GLOBAL.SERVER_ID` | Identidad de red del echo (suele ser `9990`) |
| `LOGGER` | Archivo de log (opcional pero recomendado) |
| `SYSTEMS.ECHO` | `MODE: PEER`, `IP`/`PORT` local, `MASTER_IP`/`MASTER_PORT`, `PASSPHRASE`, `RADIO_ID`, `CALLSIGN`, `OPTIONS` |
No hace falta `PROXY`, `ALIASES` ni `REPORTS`. **`MASTER_PORT`** y **`PASSPHRASE`** deben coincidir con **`ECHO`** en el servidor principal.
- Copia **`adn-echo.example.yaml`** → **`adn-echo.yaml`** (no se commitea).
- Ejecuta:
```bash
python adn-server.py --echo -c adn-echo.yaml
```
En producción suele ir en una unidad **systemd** aparte (ver `examples/systemd/adn-echo.service` en el repo), mismo binario:
```bash
sudo cp examples/systemd/adn-echo.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now adn-echo
```
## Relación con TG 9990
El servidor principal expone un master **ECHO** en **TG 9990** para el bridge de eco. El servicio **echo** independiente es un proceso aparte con su propia config que se conecta a ese master.
## Documentación
Esta página es el resumen incluido en el repositorio; amplía las notas de despliegue localmente según necesites.

@ -0,0 +1,112 @@
# Proxy hotspot (integrado)
**ADN DMR Peer Server** incluye un **proxy hotspot integrado**: un solo proceso (`adn-server.py`) acepta Homebrew (HBP) de muchos hotspots en un único puerto UDP e **inyecta** el tráfico en un **MASTER** configurado. **No** hace falta un proceso **`adn-proxy`** aparte cuando este modo está activo.
La configuración está en **`adn-server.yaml`**, bloques **`PROXY`** y opcional **`SELF_SERVICE`** (misma tabla MySQL **`Clients`** que **adn-monitor**).
---
## Cuándo usarlo
| Despliegue | Qué ejecutar |
|------------|--------------|
| **Stack ADN habitual** (monitor + panel + muchos hotspots Pi-Star) | **`adn-server.py`** con **`PROXY`** + **`SELF_SERVICE`**. |
El proxy integrado usa **fan-in**: los hotspots solo necesitan **`PROXY.LISTEN_PORT`** (p. ej. **62031**). El **MASTER** destino es **solo inyección** — **no** abre su propio puerto UDP para ese system (sin rango de puertos por hotspot en el host del servidor).
---
## Dependencia opcional (self-service)
El self-service con MySQL requiere **`mysqlclient`**:
```bash
pip install -e ".[selfservice]"
```
Si **`USE_SELFSERVICE: true`** pero falta **`mysqlclient`**, el arranque falla con un error claro. Pon **`USE_SELFSERVICE: false`** para usar el proxy sin BD (sin actualizaciones **RPTO** desde el panel).
---
## Claves `PROXY`
| Clave | Rol |
|-------|-----|
| **LISTEN_PORT** | Puerto UDP al que se conectan los **hotspots** (el que configuran en el dispositivo). |
| **LISTEN_IP** | Dirección de bind; vacío = todas las interfaces. |
| **TARGET_SYSTEM** | Nombre del **MASTER** en **`SYSTEMS`** que recibe el HBP inyectado (debe existir y estar **ENABLED**). |
| **TIMEOUT** | Timeout de sesión inactiva (segundos); las sesiones caducadas se eliminan en el MASTER. |
| **DEBUG** | Log detallado de paquetes. |
| **CLIENT_INFO** | Log de conexión/desconexión por ID de radio. |
| **BLACK_LIST** | Bloquea IDs de radio listados. |
| **IP_BLACK_LIST** | Bloquea IPs origen (con caducidad opcional). |
En **`PROXY`** integrado **no** hay **`MASTER`**, **`PORT`** ni **`GENERATOR`** — eso corresponde al proxy independiente legado. El MASTER destino usa **`MAX_PEERS`** (no un rango UDP) para limitar hotspots simultáneos.
Ejemplo (de `adn-server.example.yaml`):
```yaml
PROXY:
LISTEN_PORT: 62031
LISTEN_IP: ""
TARGET_SYSTEM: SYSTEM
TIMEOUT: 30
DEBUG: false
CLIENT_INFO: true
BLACK_LIST: []
IP_BLACK_LIST: {}
```
### MASTER destino solo inyección
Cuando **`PROXY.TARGET_SYSTEM`** apunta a un system (p. ej. **`SYSTEM`**), al arrancar se **eliminan** **`IP`** / **`PORT`** de ese bloque MASTER. Los hotspots nunca se conectan al puerto de conferencia; todo el HBP entra por **`LISTEN_PORT`**.
Define **`MAX_PEERS`** en el MASTER destino como máximo de hotspots simultáneos (p. ej. **102**). Otros MASTER (**ECHO**, **D-APRS**, etc.) mantienen **`IP`** / **`PORT`** normales si no son el destino del proxy.
---
## Claves `SELF_SERVICE`
Misma semántica que **`adn-monitor.yaml`** — tabla **`Clients`** compartida, flag **`modified`**, **RPTO** hacia el MASTER.
| Clave | Rol |
|-------|-----|
| **USE_SELFSERVICE** | Activa sincronización de opciones con MySQL (`true` / `false`). |
| **DB_SERVER**, **DB_USERNAME**, **DB_PASSWORD**, **DB_NAME**, **DB_PORT** | Conexión MySQL. |
| **PBKDF2_SALT**, **PBKDF2_ITERATIONS** | Deben **coincidir** con **`adn-monitor.yaml`** para el hash de contraseñas. |
Al arrancar el servidor registra **`(SELF_SERVICE) Database connection test: OK`** y **`(SELF_SERVICE) Enabled`** si el pool conecta. El self-service es **asíncrono**; el reenvío de voz no se bloquea por latencia de BD.
Detalle del flujo en el panel: [Self-service](../../monitor/self-service.md).
---
## Comportamiento con varios hotspots
- Cada hotspot autenticado es un **peer** en el MASTER de inyección con sus **OPTIONS** (TG estáticas). **Repeat** y el fan-out del monitor respetan **OPTIONS por peer** — el tráfico de un TG no se envía a peers que no lo tienen seleccionado.
- Los talkgroups **eco 9990–9999** omiten el filtro OPTIONS y vuelven al hotspot **llamante** (ver [Números especiales](special-numbers.md)).
---
## Recarga en caliente (`SIGHUP`)
**Se aplica sin reiniciar** (las sesiones activas del proxy se mantienen):
- **`PROXY`:** **TIMEOUT**, **DEBUG**, **CLIENT_INFO**, **BLACK_LIST**, **IP_BLACK_LIST**
- **`SELF_SERVICE`:** se fusiona en config (cambios de credenciales en nuevas operaciones BD; los bucles no se reinician en reload)
**Requiere reinicio completo del proceso:**
- **`PROXY.LISTEN_PORT`** / **`LISTEN_IP`** (el cambio de bind se registra y se ignora en reload)
- **`PROXY.TARGET_SYSTEM`**
- Activar o desactivar **`USE_SELFSERVICE`** tras el arranque
Ver [Configuración — recarga en caliente](configuration.md#recarga-en-caliente-adn-serveryaml).
---
## Ver también
- [Configuración](configuration.md) — referencia completa de **`adn-server.yaml`**.
- [Monitorización e informes](monitoring.md) — informes TCP, panel, rotación de logs.
- [Self-service](../../monitor/self-service.md) — **`Clients`**, temporización **RPTO**.

@ -21,16 +21,19 @@ Enrutado, temporizadores, control de bucle OpenBridge y manejo de protocolo est
| **Protocolo HBP** | Autenticación, ingreso/salida DMRD, repetición a peers, filtros TG. |
| **OpenBridge** | Ingreso DMRE, límite de saltos, control de bucle (`min(1ST)`), BCSQ/BCKA si están habilitados. |
| **Voz** | Ficheros AMBE, anuncios programados, tubería TTS, reproducción bajo demanda (TG 9991–9999). |
| **Informes** | Canal TCP netstring hacia **adn-monitor** (y paneles compatibles): config, estado de bridges, eventos de llamada `BRDG_EVENT`. |
| **Informes** | Canal TCP netstring hacia **adn-monitor** (y paneles compatibles): config, estado de bridges, eventos de llamada (informe v2 JSON). |
| **Proxy hotspot** | Fan-in UDP integrado opcional (`PROXY` en `adn-server.yaml`) y **self-service** MySQL (`SELF_SERVICE`) para opciones de hotspot desde el panel. |
## Programas relacionados
- **Parrot / reproducción** — punto de entrada aparte (`adn-parrot.py`) para grabar y reproducir; ver [Parrot](parrot.md).
- **Echo / playback** — `adn-server.py --echo` con `adn-echo.yaml` mínimo; ver [Echo](echo.md).
- **Proxy hotspot integrado** — `PROXY` en **`adn-server.yaml`**; ver [Proxy hotspot](hotspot-proxy.md).
## Siguientes pasos
- [Configuración](configuration.md) — ficheros, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACL, informes, alias, fusión de voz.
- [Configuración](configuration.md) — ficheros, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACL, informes, **`PROXY`**, **`SELF_SERVICE`**, alias, fusión de voz.
- [Bridges y talkgroups](bridges-and-talkgroups.md) — cómo funciona `BRIDGES`.
- [Números especiales](special-numbers.md) — TG 4000, servicios de información, eco.
- [ADN Monitor](../../monitor/index.md) — panel, `adn-monitor.yaml`, self-service (repo aparte, desplegado con el servidor).
- [Proxy hotspot](hotspot-proxy.md) — **`PROXY`** / **`SELF_SERVICE`** integrados en `adn-server.yaml`.
- [ADN Monitor](../../monitor/index.md) — panel, `adn-monitor.yaml`, UI self-service (repo aparte, desplegado con el servidor).
- [Créditos y licencia](attribution.md) — ADN → FreeDMR → hblink3, licencia.

@ -5,14 +5,18 @@
Cuando **`REPORTS`** está habilitado en la config del servidor, el **ADN DMR Peer Server** escucha en TCP y los **clientes de informes** (típicamente **adn-monitor**) se conectan y reciben:
- **HELLO** (opcode **`0xFF`**) — JSON enviado **el primero** en cada conexión TCP por **ADN DMR Server** (`adn-server`): nombre **`server`**, **`version`** del paquete, número de **`protocol`** y lista **`features`** (p. ej. `INGRESS`, `END_TX_FORWARD`, `PUSH_ON_CONNECT`). Permite al monitor marcar la sesión como **v2** antes de las cargas pickle.
- **CONFIG_SND** / **BRIDGE_SND** — instantáneas pickle de sistemas y bridges (tras HELLO al conectar, en **`CONFIG_REQ`** / **`BRIDGE_REQ`**, en **reload** de config (**SIGHUP**), cuando un hotspot **MASTER** **registra o desconecta**, y en el bucle periódico **`REPORT_INTERVAL`**).
- **BRDG_EVENT** — eventos de texto para llamadas (`GROUP VOICE`, `PRIVATE VOICE`, etc.).
- **Report v1 (par 1.0.x):** **CONFIG_SND** / **BRIDGE_SND** (pickle), **BRDG_EVENT** (CSV).
- **Report v2 (par 2.x):** **TOPOLOGY_SND** / **ROUTING_TABLE_SND** (JSON), **VOICE_EVENT_SND**, **DELTA_SND** opcional — mismos disparadores (conexión, **`CONFIG_REQ`** / **`BRIDGE_REQ`**, reload, peers, **`REPORT_INTERVAL`**).
**Informes v2:** JSON tipado (`topology`, `routing_table`, `voice_event`, `delta`) sustituye pickle/CSV en el par **servidor 2.x + monitor 2.x**. Esquema: [Protocolo de informes v2 (JSON)](../protocols/report-v2.md).
**Acoplamiento de versiones:** **servidor 1.0.x + monitor 1.0.x** = report v1 (tags). **servidor 2.x** emite **solo report v2** — requiere **monitor 2.x**. Sin wire `dual`; monitor 1.0.x no decodifica este servidor.
Las pilas antiguas (**legado** estilo `adn-dmr-server`) pueden **omitir** HELLO. **adn-monitor** espera hasta **`ADN_CONNECTION.HELLO_TIMEOUT_MS`** (ver [Configuración del monitor](../../monitor/configuration.md#adn_connection)); si no llega HELLO, asume informes **legacy**.
El **monitor** decodifica estos mensajes, actualiza **CTABLE** / **BTABLE** y (con MySQL configurado) persiste Last Heard / estadísticas.
**Pila completa:** [Descripción general del ADN Monitor](../../monitor/index.md) (monitor Python, WebSocket, API PHP, proxy y self-service opcionales).
**Pila completa:** [Descripción general del ADN Monitor](../../monitor/index.md) (monitor FastAPI, WebSocket, self-service).
### Líneas de log del canal de informes (logger `adn-monitor`)
@ -46,8 +50,7 @@ Estos procesos tratan **`SIGUSR2`** solo para **reabrir** los ficheros de log (`
| Proceso | Claves típicas de configuración |
|---------|-----------------------------------|
| **`adn-server`** / **`adn-parrot`** | **`LOGGER.LOG_FILE`** (ver `adn-server.example.yaml`) |
| **`adn-proxy`** | **`LOG.PATH`** + **`LOG.LOG_FILE`** en `adn-proxy.yaml` |
| **`adn-server`** / **`adn-echo`** | **`LOGGER.LOG_FILE`** (los logs del proxy integrado van al mismo fichero) |
| **`adn-monitor`** | **`LOG.PATH`** + **`LOG.LOG_FILE`** en `adn-monitor.yaml` |
Ejemplo de fragmento en **`/etc/logrotate.d/adn`** (adaptar rutas y nombres de unidad):
@ -67,7 +70,7 @@ Ejemplo de fragmento en **`/etc/logrotate.d/adn`** (adaptar rutas y nombres de u
}
```
Repite **`postrotate`** con **`kill -USR2`** para las unidades **`adn-parrot`**, **`adn-proxy`** y **`adn-monitor`** si rotas sus logs en el mismo host. Usa el **PID** correcto (**`MainPID`** de systemd, pidfile, o el proceso que gestiones).
Repite **`postrotate`** con **`kill -USR2`** para **`adn-echo`** y **`adn-monitor`** si rotas sus logs en el mismo host. Usa el **PID** correcto (**`MainPID`** de systemd, pidfile, o el proceso que gestiones).
## Requisitos
@ -76,4 +79,6 @@ Repite **`postrotate`** con **`kill -USR2`** para las unidades **`adn-parrot`**,
## Self-service y hotspots
Los operadores que editan **opciones de dispositivo** desde el panel usan el flujo **self-service** (MySQL **`Clients`**, proxy **RPTO**). Está documentado en [Self-service](../../monitor/self-service.md); **no** forma parte solo del binario del peer server. Para la configuración del **proxy hotspot** (`PROXY` en **`adn-proxy.yaml`** por defecto), enlace al rango **UDP** del peer server y arranque del proceso, ver [Proxy hotspot](../../monitor/hotspot-proxy.md).
Los operadores que editan **opciones de dispositivo** desde el panel usan el flujo **self-service** (MySQL **`Clients`**, **RPTO** hacia el MASTER de conferencia). En despliegues actuales de **ADN DMR Peer Server** esto corre **dentro de `adn-server.py`**: configura **`SELF_SERVICE`** y **`PROXY`** en **`adn-server.yaml`** (ver [Proxy hotspot](hotspot-proxy.md)). Semántica del panel: [Self-service](../../monitor/self-service.md).
Los logs del proxy hotspot forman parte de **`adn-server`** cuando **`PROXY`** está activo — ver [Proxy hotspot integrado](hotspot-proxy.md).

@ -1,22 +0,0 @@
# Parrot (reproducción)
## Qué es
**Parrot** es un **punto de entrada separado** (`adn-parrot.py` / `parrot_main`) que graba voz de **grupo** entrante y la reproduce (eco / parrot), independiente del proceso principal del bridge.
## Configuración
- Copiar **`adn-parrot.example.yaml`** → **`adn-parrot.yaml`** (no versionada).
- Ejecutar:
```bash
python adn-parrot.py -c adn-parrot.yaml
```
## Relación con TG 9990 / ECHO
El servidor principal puede exponer un bridge **ECHO** en **TG 9990** para eco en banda. **Parrot** es un **servicio independiente** con su propia config — usa uno u otro según el despliegue.
## Documentación
Esta página es el resumen incluido en el repositorio; amplía las notas de despliegue localmente si hace falta.

@ -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`).

@ -89,11 +89,11 @@ A nivel operativo: si usuarios reportan «bridges que se caen demasiado fácil»
El **audio** se envía con **ID de fuente 5000** y **TG de destino 9** en el flujo generado. Estructura de ficheros: [Voz, anuncios y TTS](voice-and-tts.md).
## TG 9990 — eco / parrot (en banda)
## TG 9990 — eco (en banda)
**Propósito:** las filas de bridge para **eco** suelen usar **9990** con el sistema **ECHO** (ver `BRIDGES` y opciones en tu YAML).
**Nota:** Un **parrot independiente** también está disponible como proceso aparte — [Parrot](parrot.md).
**Nota:** Un **echo independiente** también está disponible como proceso aparte — [Echo](echo.md).
## Llamada privada al ID 4000

@ -53,5 +53,5 @@ Las **emisiones** en paralelo en **TG distintas** pueden ejecutarse a la vez; la
## Ver también
- [Configuración](configuration.md) — rutas de ficheros de voz.
- [Parrot](parrot.md) — servicio de reproducción separado.
- [Echo](echo.md) — servicio de reproducción separado.
- [Números especiales](special-numbers.md) — 5000, 999x, comportamiento relacionado con grabación.

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ADN Systems documentation</title>
<script>
(function () {
function detectLang() {
var list = [];
if (typeof navigator.languages !== "undefined" && navigator.languages.length) {
list = Array.prototype.slice.call(navigator.languages);
} else {
var one = navigator.language || navigator.userLanguage || "";
if (one) list = [one];
}
for (var i = 0; i < list.length; i++) {
var tag = String(list[i] || "").toLowerCase();
if (tag === "es" || tag.indexOf("es-") === 0) return "es";
}
return "en";
}
window.location.replace("/docs/" + detectLang() + "/");
})();
</script>
<noscript>
<meta http-equiv="refresh" content="0; url=/docs/en/">
</noscript>
</head>
<body>
<p>Redirecting… <a href="/docs/es/">Español</a> · <a href="/docs/en/">English</a></p>
</body>
</html>

@ -0,0 +1,32 @@
# ADN DMR Echo (playback) – systemd unit (PEER → ECHO master on adn-server)
#
# Copy:
# sudo cp /opt/adn-server/examples/systemd/adn-echo.service /etc/systemd/system/
# Edit User / paths if needed, then:
# sudo systemctl daemon-reload
# sudo systemctl enable --now adn-echo
#
# Minimal adn-echo.yaml — MASTER_PORT / PASSPHRASE must match ECHO in adn-server.yaml.
# Logrotate: SIGUSR2 reopens log files (not SIGHUP). postrotate example:
# kill -USR2 $(systemctl show adn-echo -p MainPID --value)
# Or: systemctl reload adn-echo (ExecReload below sends USR2)
[Unit]
Description=ADN DMR Echo (playback)
After=network-online.target adn-server.service
Wants=network-online.target
[Service]
User=adn
Type=simple
Restart=always
RestartSec=3
SyslogIdentifier=adn-echo
WorkingDirectory=/opt/adn-server
ExecStart=/opt/.pyenv/versions/3.11.8/bin/python3 /opt/adn-server/adn-server.py --echo -c /opt/adn-server/adn-echo.yaml
ExecReload=/bin/kill -USR2 $MAINPID
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target

@ -0,0 +1,31 @@
# ADN DMR Peer Server – systemd unit (bridge, integrated PROXY, reports)
#
# Copy:
# sudo cp /opt/adn-server/examples/systemd/adn-server.service /etc/systemd/system/
# Edit User / paths if needed, then:
# sudo systemctl daemon-reload
# sudo systemctl enable --now adn-server
#
# Config: adn-server.yaml (copy from adn-server.example.yaml; not committed).
# Reload YAML: systemctl reload adn-server (SIGHUP via ExecReload — not logrotate)
# Logrotate: kill -USR2 $(systemctl show adn-server -p MainPID --value)
[Unit]
Description=ADN DMR Peer Server
After=network-online.target
Wants=network-online.target
[Service]
User=adn
Type=simple
Restart=always
RestartSec=3
SyslogIdentifier=adn-dmr-server
WorkingDirectory=/opt/adn-server
ExecStart=/opt/.pyenv/versions/3.11.8/bin/python3 /opt/adn-server/adn-server.py -c /opt/adn-server/adn-server.yaml
ExecReload=/bin/kill -HUP $MAINPID
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target

@ -56,12 +56,14 @@ nav:
- Llamadas privadas: server/user-guide/private-calls.md
- Voz, anuncios y TTS: server/user-guide/voice-and-tts.md
- Monitor e informes: server/user-guide/monitoring.md
- Parrot (reproducción): server/user-guide/parrot.md
- Proxy hotspot (integrado): server/user-guide/hotspot-proxy.md
- Echo (reproducción): server/user-guide/echo.md
- Créditos y licencia: server/user-guide/attribution.md
- Protocolos:
- HBP (DMRD): server/protocols/hbp.md
- OpenBridge (DMRE): server/protocols/openbridge.md
- Trama DMRE v5: server/protocols/dmre-v5.md
- Protocolo de informes v2 (JSON): server/protocols/report-v2.md
- Desarrollo:
- Arquitectura: server/development/architecture.md
- Comportamiento y temporizadores: server/development/behaviour-and-timers.md

@ -56,15 +56,18 @@ nav:
- Private calls: server/user-guide/private-calls.md
- Voice, announcements, and TTS: server/user-guide/voice-and-tts.md
- Monitoring and reports: server/user-guide/monitoring.md
- Parrot (playback): server/user-guide/parrot.md
- Hotspot proxy (integrated): server/user-guide/hotspot-proxy.md
- Echo (playback): server/user-guide/echo.md
- Credits & license: server/user-guide/attribution.md
- Protocols:
- HBP (DMRD): server/protocols/hbp.md
- OpenBridge (DMRE): server/protocols/openbridge.md
- DMRE v5 frame layout: server/protocols/dmre-v5.md
- Report protocol v2 (JSON): server/protocols/report-v2.md
- Development:
- Architecture: server/development/architecture.md
- Behaviour and timers: server/development/behaviour-and-timers.md
- Testing: server/development/testing.md
- Contributing:
- Translations: server/contributing/translations.md
- Monitor:

@ -7,14 +7,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "adn-server"
version = "1.0.0"
version = "2.0.0-rc.1"
description = "ADN DMR Peer Server"
readme = "README.md"
license = { text = "GPL-3.0-or-later" }
requires-python = ">=3.10"
dependencies = [
"Twisted>=22.0",
"dmr_utils3>=0.1.19",
"pyyaml>=6.0",
"bitarray>=2.0",
"cryptography>=41.0",
@ -22,15 +21,27 @@ dependencies = [
]
[project.optional-dependencies]
mqtt = ["paho-mqtt>=2.0"]
selfservice = ["mysqlclient>=2.0"]
dev = ["pytest>=7.0", "jsonschema>=4.0", "paho-mqtt>=2.0"]
docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "pymdown-extensions>=10.3"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
markers = [
"behavior: integration-style behavior tests (P0/P1)",
"integration: real HBP/proxy stack (not DeterministicScenario inject)",
"mqtt: requires MQTT broker or heavy mqtt mocks",
"smoke: quick routing smoke tests",
]
[tool.ruff.lint.per-file-ignores]
# Entrypoints adjust sys.path before importing the package (intentional E402).
"src/adn_server/main.py" = ["E402"]
"src/adn_server/parrot_main.py" = ["E402"]
[project.scripts]
adn-server = "adn_server.main:main"

@ -2,7 +2,6 @@
# Python 3.10+
Twisted>=22.0.0
dmr_utils3>=0.1.19
pyyaml>=6.0
bitarray>=2.0.0
cryptography>=41.0.0

@ -0,0 +1,25 @@
{
"type": "delta",
"seq": 43,
"ts": 1717555261.0,
"since_seq": 42,
"patch": {
"type": "routing_table",
"seq": 43,
"ts": 1717555261.0,
"routes": [
{
"relay_table_key": "52090",
"legs": [
{
"system": "MASTER-A",
"ts": 2,
"tgid": 52090,
"active": false,
"to_type": "ON"
}
]
}
]
}
}

@ -0,0 +1,17 @@
{
"type": "hello",
"server": "adn-server",
"version": "2.0.0-alpha.1",
"report_protocol": 2,
"features": [
"INGRESS",
"END_TX_FORWARD",
"PUSH_ON_CONNECT",
"REPORT_V2",
"TOPOLOGY_JSON",
"ROUTING_TABLE_JSON",
"VOICE_EVENT_JSON",
"DELTA_UPDATES"
],
"systems": ["MASTER-A", "MASTER-B", "OBP-CL"]
}

@ -0,0 +1,39 @@
{
"type": "routing_table",
"seq": 42,
"ts": 1717555260.5,
"routes": [
{
"relay_table_key": "52090",
"legs": [
{
"system": "MASTER-A",
"ts": 2,
"tgid": 52090,
"active": true,
"to_type": "ON",
"timer_expires_at": 1717555320.0
},
{
"system": "MASTER-B",
"ts": 2,
"tgid": 52090,
"active": true,
"to_type": "ON"
}
]
},
{
"relay_table_key": "#310",
"legs": [
{
"system": "MASTER-A",
"ts": 2,
"tgid": 310,
"active": false,
"to_type": "NONE"
}
]
}
]
}

@ -0,0 +1,35 @@
{
"type": "topology",
"seq": 1,
"ts": 1717555200.0,
"systems": [
{
"name": "MASTER-A",
"mode": "MASTER",
"enabled": true,
"ip": "10.0.0.1",
"port": 62030,
"repeat": true,
"peers": [
{
"id": 3120001,
"connected": true,
"ip": "10.0.0.50",
"port": 62031,
"single_mode": false,
"ua_timer_min": 10,
"ua_sessions": {}
}
]
},
{
"name": "OBP-CL",
"mode": "OPENBRIDGE",
"enabled": true,
"ip": "10.0.0.2",
"port": 62044,
"enhanced_obp": true,
"peers": []
}
]
}

@ -0,0 +1,14 @@
{
"type": "voice_event",
"ts": 1717555201.234,
"call_family": "GROUP",
"phase": "START",
"direction": "RX",
"system": "MASTER-A",
"stream_id": 2155905152,
"peer_id": 1001,
"src_id": 3120001,
"slot": 2,
"dst_id": 52090,
"duration_s": null
}

@ -0,0 +1,279 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://adn.systems/schemas/report-v2.json",
"title": "ADN Monitor Report Protocol v2",
"description": "JSON payloads for the TCP report channel (netstring-framed). Legacy v1 uses pickle CONFIG_SND/BRIDGE_SND and CSV BRDG_EVENT; v2 replaces those with typed JSON. Wire opcodes are defined in docs/en/server/protocols/report-v2.md.",
"type": "object",
"required": ["type"],
"oneOf": [
{ "$ref": "#/$defs/hello" },
{ "$ref": "#/$defs/topology" },
{ "$ref": "#/$defs/routing_table" },
{ "$ref": "#/$defs/voice_event" },
{ "$ref": "#/$defs/delta" }
],
"$defs": {
"dmr_id": {
"type": "integer",
"minimum": 1,
"maximum": 16777215,
"description": "DMR radio ID or talkgroup (24-bit)."
},
"stream_id": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295,
"description": "HBP stream identifier (32-bit, unsigned)."
},
"slot": {
"type": "integer",
"enum": [1, 2]
},
"system_mode": {
"type": "string",
"enum": ["MASTER", "PEER", "OPENBRIDGE"]
},
"to_type": {
"type": "string",
"enum": ["ON", "OFF", "STAT", "NONE"],
"description": "Legacy BRIDGES TO_TYPE semantics until subscription model (Phase 2)."
},
"hello": {
"type": "object",
"additionalProperties": false,
"required": ["type", "server", "version", "report_protocol", "features"],
"properties": {
"type": { "const": "hello" },
"server": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1, "description": "adn-server package version." },
"report_protocol": {
"type": "integer",
"const": 2,
"description": "Report payload schema version (distinct from legacy HELLO protocol field value 1)."
},
"features": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true,
"description": "Capability tokens. v1: INGRESS, END_TX_FORWARD, PUSH_ON_CONNECT. v2 adds REPORT_V2, TOPOLOGY_JSON, ROUTING_TABLE_JSON, VOICE_EVENT_JSON, DELTA_UPDATES."
},
"systems": {
"type": "array",
"items": { "type": "string" },
"description": "Optional hint: enabled system names with at least one connected peer or upstream link. Omitted when none. Full topology (all systems) follows in the topology message."
}
}
},
"topology_system": {
"type": "object",
"additionalProperties": false,
"required": ["name", "mode", "enabled"],
"properties": {
"name": { "type": "string" },
"mode": { "$ref": "#/$defs/system_mode" },
"enabled": { "type": "boolean" },
"ip": { "type": "string" },
"port": { "type": "integer", "minimum": 0, "maximum": 65535 },
"repeat": { "type": "boolean" },
"enhanced_obp": { "type": "boolean" },
"network_id": {
"$ref": "#/$defs/dmr_id",
"description": "OPENBRIDGE NETWORK_ID (legacy CONFIG bytes_4 value as integer)."
},
"ts1_static": {
"type": "array",
"items": { "type": "string" },
"description": "MASTER slot-1 static talkgroups (from YAML and peer RPTO OPTIONS)."
},
"ts2_static": {
"type": "array",
"items": { "type": "string" },
"description": "MASTER slot-2 static talkgroups (from YAML and peer RPTO OPTIONS)."
},
"peers": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "connected"],
"properties": {
"id": { "$ref": "#/$defs/dmr_id" },
"connected": { "type": "boolean" },
"ip": { "type": "string" },
"port": { "type": "integer", "minimum": 0, "maximum": 65535 },
"callsign": { "type": "string" },
"rx_freq": { "type": "string", "description": "RX frequency digits or formatted string from HBP config." },
"tx_freq": { "type": "string" },
"location": { "type": "string" },
"description": { "type": "string" },
"url": { "type": "string" },
"slots": { "type": "string" },
"package_id": { "type": "string" },
"software_id": { "type": "string" },
"colorcode": { "type": "string" },
"tx_power": { "type": "string" },
"ts1_static": {
"type": "array",
"items": { "type": "string" },
"description": "Slot-1 static TGs from this peer's RPTO OPTIONS."
},
"ts2_static": {
"type": "array",
"items": { "type": "string" },
"description": "Slot-2 static TGs from this peer's RPTO OPTIONS."
},
"single_mode": {
"type": "boolean",
"description": "Resolved SINGLE=1 from peer OPTIONS, else YAML SINGLE_MODE."
},
"ua_timer_min": {
"type": "number",
"minimum": 0,
"description": "Resolved TIMER from peer OPTIONS (minutes), else YAML DEFAULT_UA_TIMER."
},
"ua_sessions": {
"type": "object",
"description": "Active SINGLE sessions per slot from server runtime (empty when none).",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["tgid", "expires_at"],
"properties": {
"tgid": { "$ref": "#/$defs/dmr_id" },
"expires_at": { "type": "number" }
}
}
}
}
}
}
}
},
"topology": {
"type": "object",
"additionalProperties": false,
"required": ["type", "seq", "ts", "systems"],
"properties": {
"type": { "const": "topology" },
"seq": { "type": "integer", "minimum": 0 },
"ts": { "type": "number", "description": "Unix epoch seconds (fractional allowed)." },
"systems": {
"type": "array",
"items": { "$ref": "#/$defs/topology_system" }
}
}
},
"routing_leg": {
"type": "object",
"additionalProperties": false,
"required": ["system", "ts", "tgid", "active", "to_type"],
"properties": {
"system": { "type": "string" },
"ts": { "$ref": "#/$defs/slot" },
"tgid": { "$ref": "#/$defs/dmr_id" },
"active": { "type": "boolean" },
"to_type": { "$ref": "#/$defs/to_type" },
"timer_expires_at": {
"type": "number",
"description": "Unix time when rule_timer deactivates leg (legacy TIMER field)."
}
}
},
"routing_table": {
"type": "object",
"additionalProperties": false,
"required": ["type", "seq", "ts", "routes"],
"properties": {
"type": { "const": "routing_table" },
"seq": { "type": "integer", "minimum": 0 },
"ts": { "type": "number" },
"routes": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["relay_table_key", "legs"],
"properties": {
"relay_table_key": {
"type": "string",
"description": "Talkgroup id string or reflector key (#xxx)."
},
"legs": {
"type": "array",
"items": { "$ref": "#/$defs/routing_leg" }
}
}
}
}
}
},
"voice_event": {
"type": "object",
"additionalProperties": false,
"required": [
"type",
"ts",
"call_family",
"phase",
"direction",
"system",
"stream_id",
"peer_id",
"src_id",
"slot",
"dst_id"
],
"properties": {
"type": { "const": "voice_event" },
"ts": { "type": "number" },
"call_family": {
"type": "string",
"enum": ["GROUP", "PRIVATE", "UNIT", "VCSSBK"],
"description": "Replaces BRDG_EVENT prefix (e.g. GROUP VOICE)."
},
"phase": {
"type": "string",
"enum": ["INGRESS", "START", "END", "DATA"],
"description": "DATA is used for UNIT DATA events (legacy BRDG_EVENT CSV)."
},
"direction": {
"type": "string",
"enum": ["RX", "TX"]
},
"system": { "type": "string" },
"stream_id": { "$ref": "#/$defs/stream_id" },
"peer_id": { "$ref": "#/$defs/dmr_id" },
"src_id": { "$ref": "#/$defs/dmr_id" },
"slot": { "$ref": "#/$defs/slot" },
"dst_id": { "$ref": "#/$defs/dmr_id" },
"duration_s": {
"type": ["number", "null"],
"minimum": 0,
"description": "Present on END events."
}
}
},
"delta": {
"type": "object",
"additionalProperties": false,
"required": ["type", "seq", "ts", "since_seq", "patch"],
"properties": {
"type": { "const": "delta" },
"seq": { "type": "integer", "minimum": 1 },
"ts": { "type": "number" },
"since_seq": {
"type": "integer",
"minimum": 0,
"description": "Client last applied seq; server sends changes after this."
},
"patch": {
"description": "Partial topology or routing_table payload (same shape, may omit unchanged branches).",
"oneOf": [
{ "$ref": "#/$defs/topology" },
{ "$ref": "#/$defs/routing_table" }
]
}
}
}
}
}

@ -37,4 +37,4 @@
"""ADN DMR Peer Server — conference bridge (rewrite of bridge_master)."""
__version__ = "1.0.0"
__version__ = "2.0.0-rc.1"

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

File diff suppressed because it is too large Load Diff

@ -1,4 +1,4 @@
# ADN DMR Peer Server - playback (parrot) use case
# ADN DMR Peer Server - playback (echo) use case
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
@ -33,16 +33,16 @@ from typing import Any
from twisted.internet import reactor
from twisted.internet.base import DelayedCall
from ..domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, bytes_4, int_id
from ..domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, HBPF_VOICE, bytes_4, int_id
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
@ -390,9 +390,21 @@ class PlaybackUseCases:
self._playback_index = 0
self._send_next_packet(proto)
def _packet_is_voice_burst(self, data: bytes) -> bool:
if len(data) < 16:
return False
bits = data[15]
frame_type = (bits & 0x30) >> 4
vseq = bits & 0xF
return frame_type == HBPF_VOICE or (frame_type != HBPF_DATA_SYNC and vseq in (1, 2, 3, 4))
def _prepare_playback_packets(self, recorded: list[bytes]) -> list[bytes]:
"""Rewrite stream ID and drop mid-call VHEADs (re-key); keep source seq (legacy playback.py)."""
"""Rewrite stream ID, drop mid-call VHEADs; preserve seq; new stream segment on seq wrap."""
out: list[bytes] = []
vhead_template: bytes | None = None
segment_sid = self._playback_stream_id
last_voice_seq: int | None = None
for i, pkt in enumerate(recorded):
if len(pkt) < 20:
continue
@ -402,7 +414,29 @@ class PlaybackUseCases:
self._system,
)
continue
out.append(pkt[:16] + self._playback_stream_id + pkt[20:])
if self._packet_is_vhead(pkt) and vhead_template is None:
vhead_template = pkt
if self._packet_is_voice_burst(pkt) and last_voice_seq is not None:
curr_seq = pkt[4]
if curr_seq < last_voice_seq and last_voice_seq >= 200:
segment_sid = bytes_4(randint(0x00, 0xFFFFFFFF))
logger.info(
"(%s) Playback seq wrap (%s -> %s); new stream %s at recorded index %d",
self._system,
last_voice_seq,
curr_seq,
int_id(segment_sid),
i,
)
if vhead_template is not None:
vhead = bytearray(vhead_template[:16] + segment_sid + vhead_template[20:])
out.append(bytes(vhead))
out.append(pkt[:16] + segment_sid + pkt[20:])
if self._packet_is_voice_burst(pkt):
last_voice_seq = pkt[4]
return out
def _send_next_packet(self, proto: Any) -> None:

@ -27,7 +27,10 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import Any, Protocol
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from adn_server.domain.subscription import AudioChannel, Subscription, SubscriptionId, SubscriptionPhase, SystemId
class ConfigLoader(ABC):
@ -88,38 +91,91 @@ class KeysStore(ABC):
...
class ReportSender(ABC):
"""Send config and bridge state to report TCP clients (CONFIG_SND, BRIDGE_SND, BRDG_EVENT)."""
class ReportWireEncoder(ABC):
"""Outbound port: encode one report protocol variant into zero or more TCP frames."""
@abstractmethod
def send_config(self, systems: dict[str, Any]) -> None:
"""Send CONFIG_SND (pickle systems)."""
def hello_frames(self, systems: dict[str, Any]) -> tuple[bytes, ...]:
"""HELLO (0xFF) frame(s) for this variant."""
...
@abstractmethod
def send_bridge(self, bridges: dict[str, Any]) -> None:
"""Send BRIDGE_SND (pickle bridges)."""
def config_frames(self, systems: dict[str, Any], *, full_snapshot: bool) -> tuple[bytes, ...]:
"""CONFIG_SND / TOPOLOGY_SND / delta frames (empty if nothing to send)."""
...
@abstractmethod
def send_bridge_event(self, event: str) -> None:
"""Send BRDG_EVENT (opcode + event string)."""
def bridge_frames(self, bridges: dict[str, Any], *, full_snapshot: bool) -> tuple[bytes, ...]:
"""BRIDGE_SND / ROUTING_TABLE_SND / delta frames."""
...
@abstractmethod
def bridge_event_frames(self, event: str) -> tuple[bytes, ...]:
"""BRDG_EVENT / VOICE_EVENT_SND frames."""
...
class BridgeRouter(ABC):
"""Query and update BRIDGES (conference bridge state). Used by rule_timer, make_single_bridge, etc."""
class ReportMqttPublisher(ABC):
"""Optional second sink: publish the same report v2 JSON payloads to an MQTT broker."""
@abstractmethod
def get_bridges(self) -> dict[str, list[dict[str, Any]]]:
"""Return current BRIDGES dict (key = TGID or #reflector)."""
def start(
self,
wire: ReportWireEncoder,
get_systems: Any,
routing_table_for_report: Any,
) -> None:
"""Connect to broker and publish bootstrap snapshots (hello + full topology + routing)."""
...
@abstractmethod
def set_bridges(self, bridges: dict[str, list[dict[str, Any]]]) -> None:
"""Replace BRIDGES (e.g. after rule_timer or make_single_bridge)."""
def publish_frames(self, frames: tuple[bytes, ...]) -> None:
"""Publish zero or more wire frames (opcode + JSON) to MQTT topics."""
...
@abstractmethod
def publish_dashboard(self, systems: dict[str, Any]) -> None:
"""Publish slim ``dashboard_state`` (linked systems only)."""
...
@abstractmethod
def stop(self) -> None:
"""Disconnect from broker."""
...
class ReportSender(ABC):
"""Send config and bridge state to report TCP clients (CONFIG_SND, BRIDGE_SND, BRDG_EVENT)."""
@abstractmethod
def set_systems(self, systems: dict[str, Any]) -> None:
"""Update cached SYSTEMS snapshot used by the wire encoder."""
...
@abstractmethod
def set_routing_table(self, bridges: dict[str, Any]) -> None:
"""Update cached BRIDGES snapshot used by the wire encoder."""
...
@abstractmethod
def send_config(self, systems: dict[str, Any], *, incremental: bool = False) -> None:
"""Send CONFIG_SND (pickle systems) or topology / delta JSON."""
...
@abstractmethod
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_routing_event(self, event: str) -> None:
"""Send BRDG_EVENT (opcode + event string)."""
...
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:
"""Check ID against ACL; return True if permitted."""
@ -183,3 +239,180 @@ class TalkerAliasEmblcEncoder(Protocol):
def encode_blocks(self, blocks: dict[int, bytes]) -> tuple[list[dict[int, Any]], int]:
...
class SubscriptionStore(ABC):
"""Authoritative in-memory subscription registry (Phase 2; replaces BRIDGES dict over time)."""
@abstractmethod
def get(self, sub_id: "SubscriptionId") -> "Subscription | None":
...
@abstractmethod
def upsert(self, subscription: "Subscription") -> None:
...
@abstractmethod
def remove(self, sub_id: "SubscriptionId") -> bool:
...
@abstractmethod
def clear(self) -> None:
...
@abstractmethod
def replace_all(self, subscriptions: Sequence["Subscription"]) -> None:
...
@abstractmethod
def snapshot(self) -> tuple["Subscription", ...]:
...
@abstractmethod
def list_by_channel(self, channel: "AudioChannel") -> tuple["Subscription", ...]:
...
@abstractmethod
def list_by_system(self, system: "SystemId") -> tuple["Subscription", ...]:
...
@abstractmethod
def list_active(self) -> tuple["Subscription", ...]:
...
@abstractmethod
def list_by_phase(self, phase: "SubscriptionPhase") -> tuple["Subscription", ...]:
...
class ProxySlotStore(ABC):
"""Hotspot session registry keyed by peer_id (Phase 3)."""
@abstractmethod
def bind(self, slot: "ClientSlot") -> None:
...
@abstractmethod
def update_client(self, peer_id: bytes, host: str, port: int) -> None:
...
@abstractmethod
def unbind(self, peer_id: bytes) -> "ClientSlot | None":
...
@abstractmethod
def get_by_peer(self, peer_id: bytes) -> "ClientSlot | None":
...
@abstractmethod
def list_slots(self) -> tuple["ClientSlot", ...]:
...
class PendingRptoQueue(ABC):
"""Pending RPTO payloads for self-service / login options push."""
@abstractmethod
def enqueue(self, peer_id: bytes, payload: bytes) -> None:
...
@abstractmethod
def dequeue(self) -> tuple[bytes, bytes] | None:
...
class ProxySelfServiceStore(ABC):
"""Self-service ``Clients`` table (legacy adn-proxy / hotspot_proxy_self_service)."""
@abstractmethod
def test_db(self) -> Any:
"""Verify DB connectivity. Returns Twisted Deferred."""
@abstractmethod
def ins_conf(
self,
int_id: int,
peer_id_bytes: bytes,
callsign: str,
host: str,
mode: str,
) -> None:
...
@abstractmethod
def updt_tbl(
self,
action: str,
peer_id_bytes: bytes,
*,
psswd: str | None = None,
) -> None:
...
@abstractmethod
def slct_opt(self, peer_id_bytes: bytes) -> Any:
"""Returns Deferred firing with row list, e.g. ``((options_str,),)``."""
@abstractmethod
def slct_db(self) -> Any:
"""Returns Deferred firing with ``(dmr_id, options)`` rows for ``modified=1``."""
@abstractmethod
def updt_lstseen(self, dmrid_list: list[tuple[bytes, ...]]) -> None:
...
@abstractmethod
def clean_tbl(self) -> Any:
"""Returns Deferred."""
class ProxyIpBlacklist(ABC):
"""Temporary IP blocks (legacy proxy ``ip_black_list`` / PRBL)."""
@abstractmethod
def block_until(self, host: str, expire_at: float) -> None:
...
@abstractmethod
def is_blocked(self, host: str, now: float) -> bool:
...
class ProxyMasterSink(Protocol):
"""Inject hotspot datagrams into the target MASTER (in-process)."""
def inject(self, data: bytes, client_addr: tuple[str, int]) -> None:
...
class ProxyClientSender(Protocol):
"""Send datagrams to hotspot clients via LISTEN_PORT."""
def send_to_client(self, data: bytes, client: "ClientEndpoint") -> None:
...
class MasterPeerRegistry(Protocol):
"""Drop MASTER peer state when proxy session ends."""
def remove_peer(self, peer_id: bytes) -> None:
...
class PeerTransport(Protocol):
"""Built-in mesh codec (dmre_v5, obp_v1) — decode datagrams to ``MeshIngress``, encode ``MeshEgress``."""
@property
def name(self) -> str:
...
def try_decode(self, datagram: bytes, config: "PeerMeshConfig") -> "MeshIngress | None":
...
def encode(self, egress: "MeshEgress", config: "PeerMeshConfig") -> bytes | None:
...
if TYPE_CHECKING:
from adn_server.domain.mesh_routing import MeshEgress, MeshIngress, PeerMeshConfig
from adn_server.domain.proxy import ClientEndpoint, ClientSlot

@ -0,0 +1,34 @@
# ADN DMR Peer Server - application proxy init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Proxy application layer (Phase 3)."""
from .deployment import is_proxy_inject_only, normalize_proxy_target, proxy_target_system
from .packet_helpers import peer_id_from_packet
from .use_cases import ProxySlotError, ProxyUseCases
__all__ = [
"ProxySlotError",
"ProxyUseCases",
"is_proxy_inject_only",
"normalize_proxy_target",
"peer_id_from_packet",
"proxy_target_system",
]

@ -0,0 +1,63 @@
# ADN DMR Peer Server - application proxy deployment
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Proxy deployment policy from config dict (no I/O; used at startup/reload)."""
from __future__ import annotations
from typing import Any
def proxy_target_system(config: dict[str, Any]) -> str | None:
proxy = config.get("PROXY", {})
target = proxy.get("TARGET_SYSTEM")
return str(target) if target else None
def config_has_enabled_master(config: dict[str, Any]) -> bool:
"""True when config defines at least one enabled MASTER (adn-server, not echo-only)."""
systems = config.get("SYSTEMS", {})
if not isinstance(systems, dict):
return False
return any(
isinstance(cfg, dict) and cfg.get("ENABLED", True) and cfg.get("MODE") == "MASTER"
for cfg in systems.values()
)
def is_proxy_inject_only(config: dict[str, Any], system_name: str) -> bool:
target = proxy_target_system(config)
return target is not None and target == system_name
def normalize_proxy_target(config: dict[str, Any]) -> None:
"""Strip direct UDP bind fields from inject-only proxy target (D-23)."""
target = proxy_target_system(config)
if not target:
return
sys_cfg = config.get("SYSTEMS", {}).get(target)
if not isinstance(sys_cfg, dict):
return
port = sys_cfg.pop("PORT", None)
sys_cfg.pop("IP", None)
if port is not None:
sys_cfg["_REPORT_BASE_PORT"] = int(port)
else:
sys_cfg.setdefault("_REPORT_BASE_PORT", 56400)

@ -0,0 +1,67 @@
# ADN DMR Peer Server - application proxy packet helpers
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Extract peer_id from Homebrew packets (legacy ``adn_proxy.application.packet_commands``)."""
from __future__ import annotations
# Homebrew command prefixes (wire vocabulary; no I/O)
_DMRD = b"DMRD"
_DMRA = b"DMRA"
_MSTC = b"MSTC"
_MSTN = b"MSTN"
_MSTP = b"MSTP"
_RPTA = b"RPTA"
_RPTCL = b"RPTCL"
_RPTK = b"RPTK"
_RPTL = b"RPTL"
_RPTC = b"RPTC"
_RPTO = b"RPTO"
_RPTP = b"RPTP"
def peer_id_from_packet(data: bytes, *, from_master: bool) -> bytes | None:
"""Return 4-byte peer_id from packet payload, or None if not applicable."""
if len(data) < 8:
return None
command = data[:4]
if from_master:
if command == _DMRD and len(data) >= 15:
return data[11:15]
if command == _RPTA and len(data) >= 10:
return data[6:10]
if command == _MSTN and len(data) >= 10:
return data[6:10]
if command == _MSTP and len(data) >= 11:
return data[7:11]
if command == _MSTC and len(data) >= 9:
return data[5:9]
return None
if command == _DMRD and len(data) >= 15:
return data[11:15]
if command in (_DMRA, _RPTL, _RPTK, _RPTO) and len(data) >= 8:
return data[4:8]
if command == _RPTC:
if len(data) >= 5 and data[:5] == _RPTCL:
return data[5:9] if len(data) >= 9 else None
return data[4:8] if len(data) >= 8 else None
if command == _RPTP and len(data) >= 11:
return data[7:11]
return None

@ -1,12 +1,8 @@
#!/usr/bin/env python3
# ADN DMR Parrot (playback) - launcher script
# ADN DMR Peer Server - application proxy session teardown
#
# 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
@ -22,28 +18,20 @@
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""
Run the ADN DMR Parrot (playback) from the project root.
"""Wire packets for proxy session teardown (legacy reaper parity)."""
python adn-parrot.py
python adn-parrot.py -c adn-parrot.yaml
python adn-parrot.py --logging DEBUG
from __future__ import annotations
Config default: adn-parrot.yaml in this directory.
"""
# Homebrew command prefixes (wire vocabulary; no I/O)
_MSTCL = b"MSTCL"
_RPTCL = b"RPTCL"
from __future__ import annotations
CLIENT_TEARDOWN_REPEAT = 3
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
if str(_ROOT / "src") not in sys.path:
sys.path.insert(0, str(_ROOT / "src"))
def master_teardown_packet(peer_id: bytes) -> bytes:
return _RPTCL + peer_id
from adn_server.parrot_main import main
if __name__ == "__main__":
main()
def client_teardown_packet() -> bytes:
return _MSTCL

@ -0,0 +1,172 @@
# ADN DMR Peer Server - application proxy use cases
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""ProxyService use cases: client registration, pending RPTO (Phase 3)."""
from __future__ import annotations
import time
from collections.abc import Sequence
from adn_server.application.ports import PendingRptoQueue, ProxyIpBlacklist, ProxySlotStore
from adn_server.domain.errors import DomainError
from adn_server.domain.proxy import ClientEndpoint, ClientSlot, PendingRpto, SessionTeardown
from adn_server.domain.result import Fail, Result, Success
from adn_server.domain.value_objects import int_id
class ProxySlotError(DomainError):
"""Proxy session allocation or lookup failure."""
class ProxyUseCases:
"""Register hotspot clients and queue RPTO for the master (in-process inject)."""
def __init__(
self,
slot_store: ProxySlotStore,
rpto_queue: PendingRptoQueue,
*,
max_peers: int = 1,
black_list: Sequence[int] = (),
ip_blacklist: ProxyIpBlacklist | None = None,
) -> None:
self._slots = slot_store
self._rpto_queue = rpto_queue
self._max_peers = max_peers
self._black_list = frozenset(black_list)
self._ip_blacklist = ip_blacklist
def _allocate_report_slot(self) -> int | None:
"""Lowest free upstream slot index (legacy adn-proxy ``connTrack`` port pool)."""
used = {
slot.report_slot
for slot in self._slots.list_slots()
if slot.report_slot is not None
}
for index in range(self._max_peers):
if index not in used:
return index
return None
def attach_client(
self,
peer_id: bytes,
host: str,
port: int,
) -> Result[ClientSlot, ProxySlotError]:
"""Bind or refresh a hotspot session (legacy ``peer_track`` on client packet)."""
if len(peer_id) != 4:
return Fail(ProxySlotError("peer_id must be 4 bytes"))
if self.is_ip_blocked(host):
return Fail(ProxySlotError("client IP is blacklisted"))
existing = self._slots.get_by_peer(peer_id)
if existing is not None:
updated = existing.with_client(host, port)
self._slots.update_client(peer_id, host, port)
return Success(updated)
if int_id(peer_id) in self._black_list:
return Fail(ProxySlotError("peer is blacklisted"))
if len(self._slots.list_slots()) >= self._max_peers:
return Fail(ProxySlotError("maximum peers exceeded"))
report_slot = self._allocate_report_slot()
slot = ClientSlot(
peer_id=peer_id,
client=ClientEndpoint(host=host, port=port),
report_slot=report_slot,
)
self._slots.bind(slot)
return Success(slot)
def detach_client(self, peer_id: bytes) -> ClientSlot | None:
"""Release session (legacy ``reaper`` slot drop without I/O)."""
return self._slots.unbind(peer_id)
def expire_session(self, peer_id: bytes) -> SessionTeardown | None:
"""End session and return teardown plan for infrastructure I/O (legacy ``reaper``)."""
slot = self.detach_client(peer_id)
if slot is None:
return None
return SessionTeardown(peer_id=slot.peer_id, client=slot.client)
def is_ip_blocked(self, host: str, now: float | None = None) -> bool:
if self._ip_blacklist is None:
return False
return self._ip_blacklist.is_blocked(host, now if now is not None else time.time())
def block_ip_until(self, host: str, expire_at: float) -> None:
if self._ip_blacklist is not None:
self._ip_blacklist.block_until(host, expire_at)
def block_ip_from_prbl(
self,
data: bytes,
host: str,
*,
default_ttl: float = 300,
now: float | None = None,
) -> float:
"""Parse PRBL expiry and block client IP (legacy ``proxy`` PRBL handler)."""
ts = now if now is not None else time.time()
expire = ts + default_ttl
if len(data) > 8:
try:
expire = float(data[8:].decode("utf-8"))
except (ValueError, UnicodeDecodeError):
pass
self.block_ip_until(host, expire)
return expire
def resolve_client(self, peer_id: bytes) -> ClientEndpoint | None:
"""Client endpoint for a connected peer."""
slot = self._slots.get_by_peer(peer_id)
return slot.client if slot else None
def schedule_rpto(self, peer_id: bytes, payload: bytes) -> bool:
"""Queue RPTO body for a connected peer (self-service / login options)."""
slot = self._slots.get_by_peer(peer_id)
if slot is None:
return False
self._rpto_queue.enqueue(peer_id, payload)
return True
def next_pending_rpto(self) -> PendingRpto | None:
"""Dequeue one pending RPTO with its client endpoint (for master inject loop)."""
item = self._rpto_queue.dequeue()
if item is None:
return None
peer_id, payload = item
slot = self._slots.get_by_peer(peer_id)
if slot is None:
return None
return PendingRpto(peer_id=peer_id, payload=payload, client=slot.client)
def list_slots(self) -> tuple[ClientSlot, ...]:
return self._slots.list_slots()
def apply_runtime_settings(
self,
*,
max_peers: int,
black_list: Sequence[int],
) -> None:
"""Hot-reload proxy limits without dropping active sessions."""
self._max_peers = max_peers
self._black_list = frozenset(black_list)

@ -0,0 +1,55 @@
# ADN DMR Peer Server - application report init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Report application layer: payload mapping and protocol mode (no Twisted / wire bytes)."""
from .queue import (
DEFAULT_MAX_DRAIN_PER_TICK,
DEFAULT_MAX_EVENTS,
BoundedReportQueue,
QueuedReportSender,
)
from .dashboard_state import build_dashboard_state
from .payloads import (
REPORT_FEATURES,
REPORT_PROTOCOL,
build_routing_table,
build_topology,
hello_connected_system_names,
parse_bridge_event_csv,
routing_table_delta,
topology_delta,
)
__all__ = [
"DEFAULT_MAX_DRAIN_PER_TICK",
"DEFAULT_MAX_EVENTS",
"BoundedReportQueue",
"QueuedReportSender",
"REPORT_FEATURES",
"REPORT_PROTOCOL",
"build_dashboard_state",
"build_routing_table",
"build_topology",
"hello_connected_system_names",
"parse_bridge_event_csv",
"routing_table_delta",
"topology_delta",
]

@ -0,0 +1,154 @@
# ADN DMR Peer Server - application report dashboard state
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Minimal dashboard snapshot for external MQTT consumers (not full topology / routing)."""
from __future__ import annotations
import time
from typing import Any
from adn_server.domain import int_id
from .payloads import _peer_field_json, build_topology
def _connected_topology_peers(system: dict[str, Any]) -> list[dict[str, Any]]:
return [p for p in system.get("peers", []) if isinstance(p, dict) and p.get("connected")]
def _upstream_peer_connected(cfg: dict[str, Any]) -> bool:
mode = cfg.get("MODE", "MASTER")
if mode not in ("PEER", "XLXPEER"):
return False
for key in ("XLXSTATS", "STATS"):
block = cfg.get(key)
if isinstance(block, dict) and block.get("CONNECTION") == "YES":
return True
return False
def _upstream_peer_block(name: str, cfg: dict[str, Any]) -> dict[str, Any]:
"""Homebrew / XLX upstream (``CTABLE.PEERS``), not hotspots under a MASTER."""
mode = cfg.get("MODE", "PEER")
block: dict[str, Any] = {"mode": mode, "connected": True}
for legacy_key, json_key in (
("CALLSIGN", "callsign"),
("LOCATION", "location"),
("DESCRIPTION", "description"),
("URL", "url"),
("MASTER_IP", "master_ip"),
("MASTER_PORT", "master_port"),
):
text = _peer_field_json(cfg.get(legacy_key))
if text is not None:
block[json_key] = text
radio_id = cfg.get("RADIO_ID")
if radio_id is not None:
block["radio_id"] = int_id(radio_id)
stats_key = "XLXSTATS" if mode == "XLXPEER" else "STATS"
stats = cfg.get(stats_key)
if isinstance(stats, dict) and stats.get("CONNECTED"):
try:
block["connected_at"] = int(float(stats["CONNECTED"]))
except (TypeError, ValueError):
pass
return block
def _openbridge_block(name: str, cfg: dict[str, Any], topology_row: dict[str, Any] | None) -> dict[str, Any]:
"""Enabled OPENBRIDGE legs (``CTABLE.OPENBRIDGES``); STREAMS stay empty here (live chips = monitor/voice)."""
block: dict[str, Any] = {"mode": "OPENBRIDGE", "streams": {}}
network_id = cfg.get("NETWORK_ID")
if network_id is not None:
block["network_id"] = int_id(network_id)
row = topology_row or {}
if row.get("ip"):
block["ip"] = row["ip"]
if row.get("port") is not None:
block["port"] = int(row["port"])
if row.get("enhanced_obp") or cfg.get("ENHANCED_OBP"):
block["enhanced_obp"] = True
return block
def build_dashboard_state(
systems: dict[str, Any],
*,
server_id: str | None = None,
ts: float | None = None,
) -> dict[str, Any]:
"""Slim linked-systems view (masters with peers, homebrew peers, openbridges).
Mirrors adn-monitor WebSocket ``ctable_for_lnksys`` + ``ctable_for_opb`` intent:
no routing_table, no idle masters, no secrets.
"""
epoch = time.time() if ts is None else ts
topology = build_topology(systems, seq=0, ts=epoch)
topology_by_name = {
s["name"]: s
for s in topology.get("systems", [])
if isinstance(s, dict) and s.get("name")
}
masters: dict[str, Any] = {}
peers: dict[str, Any] = {}
openbridges: dict[str, Any] = {}
for name, cfg in systems.items():
if not isinstance(cfg, dict) or not cfg.get("ENABLED", True):
continue
mode = cfg.get("MODE", "MASTER")
topo = topology_by_name.get(name)
if mode == "MASTER":
if topo is None:
continue
live = _connected_topology_peers(topo)
if not live:
continue
block: dict[str, Any] = {
"mode": "MASTER",
"peers": {int(p["id"]): p for p in live if "id" in p},
}
if "SINGLE_MODE" in cfg:
block["single_mode"] = bool(cfg.get("SINGLE_MODE", False))
if cfg.get("DEFAULT_UA_TIMER") is not None:
block["default_ua_timer"] = float(cfg.get("DEFAULT_UA_TIMER", 10))
if topo.get("ip"):
block["ip"] = topo["ip"]
if topo.get("port") is not None:
block["port"] = int(topo["port"])
masters[name] = block
elif mode == "OPENBRIDGE":
openbridges[name] = _openbridge_block(name, cfg, topo)
elif mode in ("PEER", "XLXPEER") and _upstream_peer_connected(cfg):
peers[name] = _upstream_peer_block(name, cfg)
payload: dict[str, Any] = {
"type": "dashboard_state",
"ts": float(epoch),
"ctable": {
"MASTERS": masters,
"PEERS": peers,
"OPENBRIDGES": openbridges,
},
}
if server_id is not None:
payload["server_id"] = server_id
return payload

@ -0,0 +1,415 @@
# ADN DMR Peer Server - application report monitor topology
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Monitor topology parity for inject-only proxy (legacy SYSTEM-N / 56400+N).
Hotspot radio IDs often share a user/subscriber prefix, e.g. user ``7300391`` with
HS1 ``730039101``, HS2 ``730039102``, … HS99 ``730039199`` (``user * 100 + n``).
Voice-event peer resolution must not guess when several connected peers match the
same user or 6-digit legacy prefix.
"""
from __future__ import annotations
import copy
from typing import Any
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
DEFAULT_REPORT_BASE_PORT = 56400
def _connected_peers(peers: dict[Any, dict[str, Any]]) -> list[tuple[Any, dict[str, Any]]]:
return [
(peer_key, peer)
for peer_key, peer in peers.items()
if isinstance(peer, dict) and peer.get("CONNECTION") == "YES"
]
def _resolve_slot_map(
connected: list[tuple[Any, dict[str, Any]]],
peer_slots: dict[bytes, int] | None,
*,
max_slots: int,
) -> dict[Any, int]:
"""Map peer keys to upstream slot indices for monitor ``SYSTEM-N`` rows."""
slot_map: dict[Any, int] = {}
if peer_slots:
for peer_key, _peer in connected:
if isinstance(peer_key, bytes) and peer_key in peer_slots:
slot_map[peer_key] = peer_slots[peer_key]
used = set(slot_map.values())
for peer_key, _peer in sorted(connected, key=lambda item: int_id(item[0])):
if peer_key in slot_map:
continue
for index in range(max_slots):
if index not in used:
slot_map[peer_key] = index
used.add(index)
break
return slot_map
def expand_inject_proxy_systems(
config: dict[str, Any],
systems: dict[str, Any],
peer_slots: dict[bytes, int] | None = None,
) -> dict[str, Any]:
"""Fan inject-only ``SYSTEM`` peers into ``SYSTEM-N`` masters for monitor/report.
Runtime HBP stays on a single inject target; only the topology snapshot sent to
adn-monitor matches legacy ``expand_generator`` + adn-proxy upstream ports.
"""
target = proxy_target_system(config)
if not target or not is_proxy_inject_only(config, target):
return systems
sys_cfg = systems.get(target)
if not isinstance(sys_cfg, dict) or sys_cfg.get("MODE") != "MASTER":
return systems
peers = sys_cfg.get("PEERS", {})
if not isinstance(peers, dict):
return systems
max_slots = int(sys_cfg.get("MAX_PEERS", 1))
base_port = int(sys_cfg.get("_REPORT_BASE_PORT", DEFAULT_REPORT_BASE_PORT))
connected = _connected_peers(peers)
slot_map = _resolve_slot_map(connected, peer_slots, max_slots=max_slots)
out = {name: cfg for name, cfg in systems.items() if name != target}
# Legacy ``expand_generator``: emit every virtual master (SYSTEM-0..N-1) so the
# monitor does not delete unused upstream slots on topology/config update.
for slot in range(max_slots):
virtual_name = f"{target}-{slot}"
virtual = copy.deepcopy(sys_cfg)
virtual["PORT"] = base_port + slot
# Merged TS1/TS2 on inject SYSTEM are for bridges only — not per-hotspot dashboard chips.
virtual["TS1_STATIC"] = ""
virtual["TS2_STATIC"] = ""
virtual["PEERS"] = {
peer_key: peers[peer_key]
for peer_key, mapped in slot_map.items()
if mapped == slot and peer_key in peers
}
out[virtual_name] = virtual
return out
def _slot_for_voice_peer(
peer_key: bytes,
*,
peers: dict[Any, dict[str, Any]],
peer_slots: dict[bytes, int] | None,
max_slots: int,
) -> int | None:
connected = _connected_peers(peers)
slot_map = _resolve_slot_map(connected, peer_slots, max_slots=max_slots)
return slot_map.get(peer_key)
def _peer_key_from_int(peer_key: Any) -> bytes:
if isinstance(peer_key, bytes):
return peer_key
return bytes_4(int_id(peer_key))
def _connected_peer_keys(peers: dict[Any, Any]) -> list[bytes]:
keys: list[bytes] = []
for peer_key, peer in peers.items():
if not isinstance(peer, dict) or peer.get("CONNECTION") != "YES":
continue
keys.append(_peer_key_from_int(peer_key))
return keys
def _unique_peer_match(matches: list[bytes]) -> bytes | None:
unique = list(dict.fromkeys(matches))
return unique[0] if len(unique) == 1 else None
def _peers_for_voice_candidate(val: int, connected: list[bytes]) -> list[bytes]:
"""Match a BRDG_EVENT peer/subscriber field to connected hotspot radio ids."""
exact = bytes_4(val)
if exact in connected:
return [exact]
val_str = str(val)
matches: list[bytes] = []
for peer_key in connected:
peer_int = int_id(peer_key)
peer_str = str(peer_int)
if peer_str == val_str:
matches.append(peer_key)
continue
# user 7300391 → radios 730039101..730039199 (user * 100 + hs)
if peer_int // 100 == val:
matches.append(peer_key)
continue
if len(val_str) >= 5 and len(peer_str) >= 7 and peer_str.startswith(val_str):
matches.append(peer_key)
continue
if len(val_str) >= 7 and peer_str.startswith(val_str) and len(peer_str) == len(val_str) + 2:
matches.append(peer_key)
continue
# legacy 6-digit dst (bridge.py hotspot match) — ambiguous when user has >1 HS
if len(val_str) >= 6 and len(peer_str) >= 6 and peer_str[:6] == val_str[:6]:
matches.append(peer_key)
return matches
def _peer_key_from_voice_csv(parts: list[str], peers: dict[Any, Any]) -> bytes | None:
"""Resolve hotspot radio id from legacy BRDG_EVENT CSV (peer_id, then rf_src).
Prefer exact radio ids. Fuzzy user/6-digit matching applies only when a single
connected peer matches (e.g. one HS online for user 7300391).
"""
connected = _connected_peer_keys(peers)
if not connected:
return None
field_values: list[tuple[int, int]] = []
for idx in (5, 6):
if len(parts) <= idx:
continue
raw = parts[idx].strip()
if not raw:
continue
try:
field_values.append((idx, int(raw)))
except ValueError:
continue
for _idx, val in field_values:
key = bytes_4(val)
if key in connected:
return key
for _idx, val in field_values:
matched = _peers_for_voice_candidate(val, connected)
resolved = _unique_peer_match(matched)
if resolved is not None:
return resolved
return None
def _voice_event_tgid_slot(parts: list[str]) -> tuple[int, int] | None:
if len(parts) < 9:
return None
try:
return int(parts[8].strip()), int(parts[7].strip())
except ValueError:
return None
def _peers_receiving_tgid(
connected: list[tuple[Any, dict[str, Any]]],
*,
slot: int,
tgid: int,
exclude: bytes | None = None,
system: str | None = None,
bridges: dict[str, Any] | None = None,
sys_cfg: dict[str, Any] | None = None,
) -> list[tuple[Any, dict[str, Any]]]:
out: list[tuple[Any, dict[str, Any]]] = []
n_connected = len(connected)
for peer_key, peer in connected:
if exclude is not None and _peer_key_from_int(peer_key) == exclude:
continue
if peer_should_receive_group_voice(
peer,
slot,
tgid,
peer_id=_peer_key_from_int(peer_key),
system=system,
bridges=bridges,
connected_count=n_connected,
sys_cfg=sys_cfg,
):
out.append((peer_key, peer))
return out
def _echo_tx_target_peer(parts: list[str], peers: dict[Any, Any]) -> bytes | None:
"""Echo/static downlink: field 5 is 9990 and field 6 resolves one hotspot."""
if len(parts) <= 5:
return None
try:
if int(parts[5].strip()) != 9990:
return None
except ValueError:
return None
return _peer_key_from_voice_csv(parts, peers)
def _remap_voice_event_to_slot(
parts: list[str],
*,
target: str,
slot: int,
peer_key: bytes | None,
) -> str:
out = list(parts)
out[3] = f"{target}-{slot}"
# RX legs: field 5 is the RF source peer — normalize to full hotspot radio id.
# TX legs: keep legacy field 5 (echo 9990, OBP server id) so the hotspot chip
# shows TX/green while receiving; rewriting to the hotspot id would mark RX/red.
if (
peer_key is not None
and len(out) > 5
and len(out) > 2
and out[2].strip() == "RX"
):
resolved_peer = int_id(peer_key)
try:
reported_peer = int(out[5].strip())
except ValueError:
reported_peer = None
if reported_peer != resolved_peer:
out[5] = str(resolved_peer)
return ",".join(out)
def remap_inject_proxy_voice_events(
event: str,
config: dict[str, Any],
systems: dict[str, Any],
peer_slots: dict[bytes, int] | None = None,
bridges: dict[str, Any] | None = None,
) -> list[str]:
"""Map inject-only ``SYSTEM`` voice events to one or more ``SYSTEM-N`` rows.
Inject-only multi-hotspot needs fan-out in two cases:
* **TX** (bridge downlink, OBP → SYSTEM): peers that would get the DMRD downlink
(per-peer OPTIONS static list, sole connected hotspot, or owned dynamic UA).
* **RX** (local hotspot TX + HBP REPEAT): transmitter keeps RX; other eligible
peers get companion **TX** (field 5 = transmitter radio id).
Echo/static TX (field 5 == 9990) still targets a single resolved hotspot.
"""
target = proxy_target_system(config)
if not target or not is_proxy_inject_only(config, target):
return [event]
parts = event.split(",")
if len(parts) < 6 or parts[3].strip() != target:
return [event]
sys_cfg = systems.get(target, {})
if not isinstance(sys_cfg, dict):
return [event]
peers = sys_cfg.get("PEERS", {})
if not isinstance(peers, dict):
return [event]
max_slots = int(sys_cfg.get("MAX_PEERS", 1))
connected = _connected_peers(peers)
slot_map = _resolve_slot_map(connected, peer_slots, max_slots=max_slots)
trx = parts[2].strip() if len(parts) > 2 else ""
if trx == "TX":
echo_peer = _echo_tx_target_peer(parts, peers)
if echo_peer is not None:
slot = slot_map.get(echo_peer)
if slot is not None:
return [
_remap_voice_event_to_slot(
parts, target=target, slot=slot, peer_key=echo_peer
)
]
try:
if int(parts[5].strip()) == 9990:
# Echo/static path with ambiguous hotspot — legacy leaves event unchanged.
return [event]
except ValueError:
pass
tgid_slot = _voice_event_tgid_slot(parts)
if tgid_slot is None:
return [event]
tgid, voice_slot = tgid_slot
receivers = _peers_receiving_tgid(
connected,
slot=voice_slot,
tgid=tgid,
system=target,
bridges=bridges,
sys_cfg=sys_cfg,
)
if not receivers:
return [event]
remapped: list[str] = []
for peer_key, _peer in receivers:
mapped_slot = slot_map.get(peer_key)
if mapped_slot is None:
continue
remapped.append(
_remap_voice_event_to_slot(
parts, target=target, slot=mapped_slot, peer_key=peer_key
)
)
return remapped if remapped else [event]
peer_key = _peer_key_from_voice_csv(parts, peers)
if peer_key is None:
return [event]
slot = slot_map.get(peer_key)
if slot is None:
return [event]
results = [
_remap_voice_event_to_slot(
parts, target=target, slot=slot, peer_key=peer_key
)
]
action = parts[1].strip() if len(parts) > 1 else ""
tgid_slot = _voice_event_tgid_slot(parts)
if action in ("START", "END") and tgid_slot is not None:
tgid, voice_slot = tgid_slot
tx_parts = list(parts)
tx_parts[2] = "TX"
tx_parts[5] = str(int_id(peer_key))
for other_key, _peer in _peers_receiving_tgid(
connected,
slot=voice_slot,
tgid=tgid,
exclude=peer_key,
system=target,
bridges=bridges,
sys_cfg=sys_cfg,
):
other_slot = slot_map.get(other_key)
if other_slot is None:
continue
results.append(
_remap_voice_event_to_slot(
tx_parts,
target=target,
slot=other_slot,
peer_key=other_key,
)
)
return results
def remap_inject_proxy_voice_event(
event: str,
config: dict[str, Any],
systems: dict[str, Any],
peer_slots: dict[bytes, int] | None = None,
bridges: dict[str, Any] | None = None,
) -> str:
"""Single-event view of :func:`remap_inject_proxy_voice_events` (first mapping)."""
return remap_inject_proxy_voice_events(
event, config, systems, peer_slots, bridges
)[0]

@ -0,0 +1,505 @@
# ADN DMR Peer Server - application report payloads
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Map runtime SYSTEMS / BRIDGES / BRDG_EVENT CSV to report JSON payloads (application layer)."""
from __future__ import annotations
import re
import time
from typing import Any
from adn_server.application.routing.helpers import export_peer_ua_sessions
from adn_server.domain import int_id
REPORT_PROTOCOL = 2
REPORT_FEATURES = (
"INGRESS",
"END_TX_FORWARD",
"PUSH_ON_CONNECT",
"REPORT_V2",
"TOPOLOGY_JSON",
"ROUTING_TABLE_JSON",
"VOICE_EVENT_JSON",
"DELTA_UPDATES",
)
_SYSTEM_MODES = frozenset({"MASTER", "PEER", "XLXPEER", "OPENBRIDGE"})
_TO_TYPES = frozenset({"ON", "OFF", "STAT", "NONE"})
_CSV_FAMILIES = {
"GROUP VOICE": "GROUP",
"PRIVATE VOICE": "PRIVATE",
"UNIT DATA": "UNIT",
}
def _dmr_id(value: Any) -> int:
if isinstance(value, int):
return value
return int_id(value)
def _peer_connected(peer: dict[str, Any]) -> bool:
return peer.get("CONNECTION") == "YES"
def static_tg_list(value: Any) -> list[str]:
"""Normalize legacy TS1_STATIC / TS2_STATIC (comma string or list) to string TG ids."""
if value is None:
return []
if isinstance(value, str):
return [x.strip() for x in value.split(",") if x.strip()]
if isinstance(value, list):
return [str(x).strip() for x in value if str(x).strip()]
text = str(value).strip()
return [text] if text else []
def _parse_options_kv(options: Any) -> dict[str, str]:
"""Parse RPTO OPTIONS string into upper-case keys (legacy normalisation)."""
if options is None:
return {}
if isinstance(options, bytes):
text = options.decode("utf-8", errors="replace")
else:
text = str(options)
text = text.rstrip("\x00").encode("ascii", "ignore").decode()
text = re.sub(r"['\"]", "", text).strip()
if not text:
return {}
parsed: dict[str, str] = {}
for part in text.split(";"):
part = part.strip()
if "=" not in part:
continue
key, value = part.split("=", 1)
parsed[key.strip().upper()] = value.strip()
for old, new in (("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC"), ("TIMER", "DEFAULT_UA_TIMER")):
if old in parsed and new not in parsed:
parsed[new] = parsed[old]
return parsed
def parse_peer_options_fields(options: Any) -> dict[str, Any]:
"""Parse OPTIONS into static lists plus optional ``SINGLE`` / ``TIMER`` when present."""
parsed = _parse_options_kv(options)
if not parsed:
return {}
out: dict[str, Any] = {}
ts1, ts2 = parse_peer_options_static(options)
if ts1:
out["TS1_STATIC"] = ts1
if ts2:
out["TS2_STATIC"] = ts2
if "SINGLE" in parsed:
out["SINGLE"] = parsed["SINGLE"]
timer_raw = parsed.get("DEFAULT_UA_TIMER")
if timer_raw is not None:
try:
out["TIMER"] = float(timer_raw)
except (TypeError, ValueError):
pass
return out
def resolve_peer_single_and_timer(
fields: dict[str, Any],
sys_cfg: dict[str, Any],
) -> tuple[bool, float]:
"""Use OPTIONS ``SINGLE``/``TIMER`` when present; else YAML ``SINGLE_MODE``/``DEFAULT_UA_TIMER``."""
if "SINGLE" in fields:
single = str(fields["SINGLE"]).strip() == "1"
else:
single = bool(sys_cfg.get("SINGLE_MODE", False))
if "TIMER" in fields:
try:
timer = float(fields["TIMER"])
except (TypeError, ValueError):
timer = float(sys_cfg.get("DEFAULT_UA_TIMER", 10))
else:
timer = float(sys_cfg.get("DEFAULT_UA_TIMER", 10))
if timer <= 0:
timer = 35_791_394.0
return single, timer
def parse_peer_options_static(options: Any) -> tuple[list[str], list[str]]:
"""Parse hotspot RPTO OPTIONS (``TS1=…;TS2=…;``) into static TG id lists."""
parsed = _parse_options_kv(options)
if not parsed:
return [], []
for old, new in (("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC")):
if old in parsed and new not in parsed:
parsed[new] = parsed[old]
ts1_parts: list[str] = []
if "TS1_1" in parsed:
ts1_parts.append(parsed["TS1_1"])
for i in range(2, 10):
p = parsed.get(f"TS1_{i}")
if p:
ts1_parts.append(p)
elif parsed.get("TS1_STATIC"):
ts1_parts = [x.strip() for x in parsed["TS1_STATIC"].split(",") if x.strip()]
ts2_parts: list[str] = []
if "TS2_1" in parsed:
ts2_parts.append(parsed["TS2_1"])
for i in range(2, 10):
p = parsed.get(f"TS2_{i}")
if p:
ts2_parts.append(p)
elif parsed.get("TS2_STATIC"):
ts2_parts = [x.strip() for x in parsed["TS2_STATIC"].split(",") if x.strip()]
return ts1_parts, ts2_parts
def _peer_field_json(value: Any) -> str | None:
"""Sanitize a legacy peer field for JSON (no secrets)."""
if value is None:
return None
if isinstance(value, bytes):
text = value.decode("utf-8", errors="replace").strip()
return text or None
text = str(value).strip()
return text or None
# Legacy CONFIG peer keys → topology JSON (display / lnksys classification).
_TOPOLOGY_PEER_FIELDS: tuple[tuple[str, str], ...] = (
("CALLSIGN", "callsign"),
("RX_FREQ", "rx_freq"),
("TX_FREQ", "tx_freq"),
("LOCATION", "location"),
("DESCRIPTION", "description"),
("URL", "url"),
("SLOTS", "slots"),
("PACKAGE_ID", "package_id"),
("SOFTWARE_ID", "software_id"),
("COLORCODE", "colorcode"),
("TX_POWER", "tx_power"),
)
def _peer_connected_at(peer: dict[str, Any]) -> int | None:
"""Unix time when peer logged in (legacy CONFIG ``CONNECTED``), or None."""
if not _peer_connected(peer):
return None
raw = peer.get("CONNECTED", 0)
try:
ts = int(float(raw))
except (TypeError, ValueError):
return None
return ts if ts > 0 else None
def _sanitized_peer_options_text(options: Any) -> str | None:
"""RPTO OPTIONS for monitor display (omit ``PASS=`` secrets)."""
if options is None:
return None
if isinstance(options, bytes):
text = options.decode("utf-8", errors="replace")
else:
text = str(options)
text = text.rstrip("\x00").strip()
if not text:
return None
parts: list[str] = []
for part in text.split(";"):
piece = part.strip()
if not piece:
continue
if piece.upper().startswith("PASS="):
continue
parts.append(piece)
if not parts:
return None
return ";".join(parts) + ";"
def _topology_peer_row(
peer_key: Any,
peer: dict[str, Any],
*,
sys_cfg: dict[str, Any] | None = None,
) -> dict[str, Any]:
row: dict[str, Any] = {
"id": _dmr_id(peer_key),
"connected": _peer_connected(peer),
}
connected_at = _peer_connected_at(peer)
if connected_at is not None:
row["connected_at"] = connected_at
if peer.get("IP"):
row["ip"] = str(peer["IP"])
if peer.get("PORT") is not None:
row["port"] = int(peer["PORT"])
for legacy_key, json_key in _TOPOLOGY_PEER_FIELDS:
if legacy_key not in peer:
continue
text = _peer_field_json(peer[legacy_key])
if text is not None:
row[json_key] = text
yaml_cfg = sys_cfg if isinstance(sys_cfg, dict) else {}
if "OPTIONS" in peer:
opt_text = _sanitized_peer_options_text(peer.get("OPTIONS"))
if opt_text:
row["options"] = opt_text
fields = parse_peer_options_fields(peer.get("OPTIONS"))
ts1 = fields.get("TS1_STATIC") or []
ts2 = fields.get("TS2_STATIC") or []
if ts1:
row["ts1_static"] = ts1
if ts2:
row["ts2_static"] = ts2
single, timer = resolve_peer_single_and_timer(fields, yaml_cfg)
else:
single, timer = resolve_peer_single_and_timer({}, yaml_cfg)
ts1 = static_tg_list(yaml_cfg.get("TS1_STATIC"))
ts2 = static_tg_list(yaml_cfg.get("TS2_STATIC"))
if ts1:
row["ts1_static"] = ts1
if ts2:
row["ts2_static"] = ts2
row["single_mode"] = single
row["ua_timer_min"] = timer
row["ua_sessions"] = export_peer_ua_sessions(yaml_cfg, peer_key)
return row
def _system_has_connected_users(cfg: dict[str, Any]) -> bool:
"""True when an enabled system has at least one live peer or upstream link."""
if not cfg.get("ENABLED", True):
return False
mode = cfg.get("MODE", "MASTER")
if mode == "MASTER":
return any(
_peer_connected(peer)
for peer in cfg.get("PEERS", {}).values()
if isinstance(peer, dict)
)
if mode == "OPENBRIDGE":
return any(
_peer_connected(peer)
for peer in cfg.get("PEERS", {}).values()
if isinstance(peer, dict)
)
if mode in ("PEER", "XLXPEER"):
for key in ("XLXSTATS", "STATS"):
block = cfg.get(key)
if isinstance(block, dict) and block.get("CONNECTION") == "YES":
return True
return False
return False
def hello_connected_system_names(systems: dict[str, Any]) -> list[str]:
"""Enabled system names with connected users — optional HELLO hint (topology is authoritative)."""
return sorted(
name
for name, cfg in systems.items()
if isinstance(cfg, dict) and _system_has_connected_users(cfg)
)
def build_topology(systems: dict[str, Any], *, seq: int, ts: float | None = None) -> dict[str, Any]:
"""Sanitized topology snapshot (no passphrases or runtime-only keys)."""
epoch = time.time() if ts is None else ts
out_systems: list[dict[str, Any]] = []
for name, cfg in systems.items():
if not isinstance(cfg, dict):
continue
mode = cfg.get("MODE", "MASTER")
if mode not in _SYSTEM_MODES:
mode = str(mode)
entry: dict[str, Any] = {
"name": name,
"mode": mode,
"enabled": bool(cfg.get("ENABLED", True)),
}
if cfg.get("IP"):
entry["ip"] = str(cfg["IP"])
port = cfg.get("PORT")
if port is not None:
entry["port"] = int(port)
if "REPEAT" in cfg:
entry["repeat"] = bool(cfg["REPEAT"])
if cfg.get("ENHANCED_OBP"):
entry["enhanced_obp"] = True
if mode == "OPENBRIDGE" and cfg.get("NETWORK_ID") is not None:
entry["network_id"] = _dmr_id(cfg["NETWORK_ID"])
if mode == "MASTER":
ts1 = static_tg_list(cfg.get("TS1_STATIC"))
ts2 = static_tg_list(cfg.get("TS2_STATIC"))
if ts1:
entry["ts1_static"] = ts1
if ts2:
entry["ts2_static"] = ts2
peers_out: list[dict[str, Any]] = []
for peer_key, peer in cfg.get("PEERS", {}).items():
if not isinstance(peer, dict):
continue
peers_out.append(_topology_peer_row(peer_key, peer, sys_cfg=cfg))
entry["peers"] = peers_out
out_systems.append(entry)
return {"type": "topology", "seq": int(seq), "ts": float(epoch), "systems": out_systems}
def build_routing_table(bridges: dict[str, Any], *, seq: int, ts: float | None = None) -> dict[str, Any]:
"""Routing table snapshot from BRIDGES."""
epoch = time.time() if ts is None else ts
routes: list[dict[str, Any]] = []
for relay_table_key, legs in bridges.items():
if not isinstance(legs, list):
continue
leg_rows: list[dict[str, Any]] = []
for leg in legs:
if not isinstance(leg, dict):
continue
to_type = leg.get("TO_TYPE", "NONE")
if to_type not in _TO_TYPES:
to_type = str(to_type)
row: dict[str, Any] = {
"system": str(leg.get("SYSTEM", "")),
"ts": int(leg.get("TS", 1)),
"tgid": _dmr_id(leg.get("TGID", 0)),
"active": bool(leg.get("ACTIVE", False)),
"to_type": to_type,
}
timer = leg.get("TIMER")
if timer is not None:
row["timer_expires_at"] = float(timer)
leg_rows.append(row)
routes.append({"relay_table_key": str(relay_table_key), "legs": leg_rows})
return {"type": "routing_table", "seq": int(seq), "ts": float(epoch), "routes": routes}
def parse_bridge_event_csv(event: str, *, ts: float | None = None) -> dict[str, Any] | None:
"""Parse legacy BRDG_EVENT CSV into a ``voice_event`` dict."""
parts = [p.strip() for p in event.split(",")]
if len(parts) < 9:
return None
call_family = _CSV_FAMILIES.get(parts[0])
if call_family is None:
return None
phase = parts[1]
direction = parts[2]
try:
stream_id = int(parts[4])
peer_id = int(parts[5])
src_id = int(parts[6])
slot = int(parts[7])
dst_id = int(parts[8])
except (ValueError, IndexError):
return None
if slot not in (1, 2):
return None
if direction not in ("RX", "TX"):
return None
epoch = time.time() if ts is None else ts
voice: dict[str, Any] = {
"type": "voice_event",
"ts": epoch,
"call_family": call_family,
"phase": phase,
"direction": direction,
"system": parts[3],
"stream_id": stream_id,
"peer_id": peer_id,
"src_id": src_id,
"slot": slot,
"dst_id": dst_id,
}
if phase == "END" and len(parts) > 9:
try:
voice["duration_s"] = float(parts[9])
except ValueError:
voice["duration_s"] = None
elif phase != "END":
voice["duration_s"] = None
return voice
def routing_table_delta(
previous: dict[str, Any] | None,
current: dict[str, Any],
*,
seq: int,
ts: float | None = None,
) -> dict[str, Any] | None:
"""Build a delta message when only some routes changed; ``None`` if unchanged."""
if previous is None:
return None
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["relay_table_key"]
if prev_routes.get(key) != route:
changed.append(route)
if not changed:
return None
since_seq = int(previous.get("seq", 0))
epoch = time.time() if ts is None else ts
patch = {
"type": "routing_table",
"seq": int(seq),
"ts": float(epoch),
"routes": changed,
}
return {
"type": "delta",
"seq": int(seq),
"ts": float(epoch),
"since_seq": since_seq,
"patch": patch,
}
def topology_delta(
previous: dict[str, Any] | None,
current: dict[str, Any],
*,
seq: int,
ts: float | None = None,
) -> dict[str, Any] | None:
"""Build a delta when only some systems changed; ``None`` if unchanged."""
if previous is None:
return None
prev_systems = {s["name"]: s for s in previous.get("systems", [])}
changed: list[dict[str, Any]] = []
for system in current.get("systems", []):
name = system["name"]
if prev_systems.get(name) != system:
changed.append(system)
if not changed:
return None
since_seq = int(previous.get("seq", 0))
epoch = time.time() if ts is None else ts
patch = {
"type": "topology",
"seq": int(seq),
"ts": float(epoch),
"systems": changed,
}
return {
"type": "delta",
"seq": int(seq),
"ts": float(epoch),
"since_seq": since_seq,
"patch": patch,
}

@ -0,0 +1,124 @@
# ADN DMR Peer Server - application report queue
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Bounded in-process report queue — decouple hot path from TCP encode/send."""
from __future__ import annotations
import logging
from collections import deque
from dataclasses import dataclass, field
from typing import Any
from ..ports import ReportSender
logger = logging.getLogger(__name__)
DEFAULT_MAX_EVENTS = 2048
DEFAULT_MAX_DRAIN_PER_TICK = 128
@dataclass
class BoundedReportQueue:
"""Coalesce config/bridge snapshots; bound voice-event backlog (drop oldest)."""
max_events: int = DEFAULT_MAX_EVENTS
max_drain_per_tick: int = DEFAULT_MAX_DRAIN_PER_TICK
_events: deque[str] = field(default_factory=deque, init=False, repr=False)
_pending_config: tuple[dict[str, Any], bool] | None = field(default=None, init=False, repr=False)
_pending_bridge: tuple[dict[str, Any], bool] | None = field(default=None, init=False, repr=False)
dropped_events: int = field(default=0, init=False)
def enqueue_event(self, event: str) -> None:
if len(self._events) >= self.max_events:
self._events.popleft()
self.dropped_events += 1
self._events.append(event)
def enqueue_config(self, systems: dict[str, Any], *, incremental: bool = False) -> None:
self._pending_config = (systems, incremental)
def enqueue_bridge(self, bridges: dict[str, Any], *, incremental: bool = False) -> None:
self._pending_bridge = (bridges, incremental)
def pending_count(self) -> int:
n = len(self._events)
if self._pending_config is not None:
n += 1
if self._pending_bridge is not None:
n += 1
return n
def drain(self, sender: ReportSender) -> int:
"""Flush pending work to ``sender``; at most ``max_drain_per_tick`` voice events per call."""
sent = 0
budget = self.max_drain_per_tick
while self._events and budget > 0:
sender.send_routing_event(self._events.popleft())
sent += 1
budget -= 1
if self._pending_config is not None:
systems, incremental = self._pending_config
self._pending_config = None
sender.set_systems(systems)
sender.send_config(systems, incremental=incremental)
sent += 1
if self._pending_bridge is not None:
bridges, incremental = self._pending_bridge
self._pending_bridge = None
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)
return sent
class QueuedReportSender(ReportSender):
"""``ReportSender`` port: enqueue only; a reactor worker drains to ``inner``."""
def __init__(self, queue: BoundedReportQueue, inner: ReportSender) -> None:
self._queue = queue
self._inner = inner
def set_systems(self, systems: dict[str, Any]) -> None:
self._inner.set_systems(systems)
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_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_routing_event(self, event: str) -> None:
self._queue.enqueue_event(event)
@property
def inner(self) -> ReportSender:
return self._inner
@property
def queue(self) -> BoundedReportQueue:
return self._queue

@ -41,17 +41,17 @@ class ReportingUseCases:
self._sender = report_sender
self._config = config
def send_config(self, systems: dict[str, Any]) -> None:
"""Send CONFIG_SND to all report clients."""
self._sender.send_config(systems)
def send_config(self, systems: dict[str, Any], *, incremental: bool = False) -> None:
"""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]) -> None:
"""Send BRIDGE_SND to all report clients."""
self._sender.send_bridge(bridges)
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_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,40 @@
# ADN DMR Peer Server - application routing init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# 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
###############################################################################

@ -0,0 +1,219 @@
# ADN DMR Peer Server - bridge HBP forward path
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# 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
###############################################################################
"""HBP ingress packet control and unit-data forward (no Twisted imports)."""
from __future__ import annotations
import logging
from hashlib import blake2b
from ...domain import HBPF_SLT_VTERM, STREAM_TO, int_id
logger = logging.getLogger(__name__)
class HbpForwardMixin:
"""routerHBP group voice ingress controls and sendDataToHBP."""
def _hbp_group_voice_ingress_controls(
self,
system_name: str,
peer_id: bytes,
rf_src: bytes,
dst_id: bytes,
seq: int,
slot: int,
stream_id: bytes,
data: bytes,
pkt_time: float,
) -> bool:
"""Legacy routerHBP group/vcsbk packet control (~3270-3399).
Returns True when the packet may proceed to bridge routing; False when dropped.
Uses ingress ``pkt_time`` (UDP receive time) for rate/timeout parity with legacy.
"""
protocols = self._get_protocols() if self._get_protocols else {}
src_proto = protocols.get(system_name)
if not src_proto:
return True
systems_cfg = self._config.get("SYSTEMS", {})
_slot_st = getattr(src_proto, "STATUS", {}).get(slot, {})
_is_new_stream = stream_id != _slot_st.get("RX_STREAM_ID")
if _is_new_stream:
_slot_st["packets"] = 0
_slot_st["loss"] = 0
_slot_st["crcs"] = set()
_slot_st["LOOPLOG"] = False
_slot_st.pop("_bcsq", None)
_slot_st["lastSeq"] = False
_slot_st["lastData"] = False
if (
_slot_st.get("RX_TYPE") != HBPF_SLT_VTERM
and pkt_time < (_slot_st.get("RX_TIME", 0) + STREAM_TO)
and rf_src != _slot_st.get("RX_RFS", b"\x00")
):
logger.warning(
"(%s) Packet received with STREAM ID: %s <FROM> SUB: %s PEER: %s <TO> TGID %s, SLOT %s collided with existing call",
system_name, int_id(stream_id), int_id(rf_src), int_id(peer_id), int_id(dst_id), slot,
)
return False
_slot_st["RX_START"] = pkt_time
_slot_st["packets"] = _slot_st.get("packets", 0) + 1
_pkts = _slot_st["packets"]
_rx_start = _slot_st.get("RX_START", pkt_time)
if _pkts > 18 and _rx_start < pkt_time:
_rate = _pkts / (pkt_time - _rx_start)
if _rate > 25:
logger.warning(
"(%s) *PacketControl* RATE DROP! Stream ID: %s TGID: %s",
system_name, int_id(stream_id), int_id(dst_id),
)
_slot_st["LAST"] = pkt_time
return False
if _rx_start + 180 < pkt_time:
if not _slot_st.get("LOOPLOG"):
logger.info(
"(%s) HBP *SOURCE TIMEOUT* STREAM ID: %s, TG: %s, TS: %s, IGNORE THIS SOURCE",
system_name, int_id(stream_id), int_id(dst_id), slot,
)
_slot_st["LOOPLOG"] = True
_slot_st["LAST"] = pkt_time
return False
for other_name, proto in protocols.items():
if other_name == system_name:
continue
omode = systems_cfg.get(other_name, {}).get("MODE")
ostatus = getattr(proto, "STATUS", None)
if not ostatus:
continue
if omode != "OPENBRIDGE":
for _sysslot in ostatus:
ss = ostatus.get(_sysslot)
if isinstance(ss, dict) and stream_id == ss.get("RX_STREAM_ID"):
if not _slot_st.get("LOOPLOG"):
logger.debug(
"(%s) HBP *LoopControl* FIRST HBP: %s, STREAM ID: %s, TG: %s, TS: %s, IGNORE THIS SOURCE",
system_name, other_name, int_id(stream_id), int_id(dst_id), _sysslot,
)
_slot_st["LOOPLOG"] = True
_slot_st["LAST"] = pkt_time
return False
else:
if (
stream_id in ostatus
and "1ST" in ostatus[stream_id]
and ostatus[stream_id].get("TGID") == dst_id
):
if not _slot_st.get("LOOPLOG"):
logger.debug(
"(%s) HBP *LoopControl* FIRST OBP %s, STREAM ID: %s, TG %s, IGNORE THIS SOURCE",
system_name, other_name, int_id(stream_id), int_id(dst_id),
)
_slot_st["LOOPLOG"] = True
_slot_st["LAST"] = pkt_time
if (
systems_cfg.get(system_name, {}).get("ENHANCED_OBP")
and "_bcsq" not in _slot_st
):
if hasattr(src_proto, "_obp_send_bcsq"):
src_proto._obp_send_bcsq(dst_id, stream_id)
_slot_st["_bcsq"] = True
return False
if _slot_st.get("lastData") and _slot_st["lastData"] == data and seq > 1:
_slot_st["loss"] = _slot_st.get("loss", 0) + 1
logger.debug(
"(%s) *PacketControl* last packet is a complete duplicate, discarding. Stream ID: %s TGID: %s",
system_name, int_id(stream_id), int_id(dst_id),
)
return False
if seq and seq == _slot_st.get("lastSeq"):
_slot_st["loss"] = _slot_st.get("loss", 0) + 1
return False
if seq and _slot_st.get("lastSeq") and seq != 1 and seq < _slot_st.get("lastSeq", 0):
_slot_st["loss"] = _slot_st.get("loss", 0) + 1
return False
_h = blake2b(digest_size=16)
_h.update(data)
_pkt_crc = _h.digest()
if seq > 0 and "crcs" in _slot_st and _pkt_crc in _slot_st["crcs"]:
_slot_st["loss"] = _slot_st.get("loss", 0) + 1
return False
if seq and _slot_st.get("lastSeq") and seq > (_slot_st.get("lastSeq", 0) + 1):
_slot_st["loss"] = _slot_st.get("loss", 0) + 1
_slot_st["lastSeq"] = seq
_slot_st["lastData"] = data
if "crcs" in _slot_st:
_slot_st["crcs"].add(_pkt_crc)
return True
def _send_data_to_hbp(
self,
source_system: str,
d_system: str,
d_slot: int,
dst_id: bytes,
tmp_bits: int,
data: bytes,
dmrpkt: bytes,
rf_src: bytes,
stream_id: bytes,
peer_id: bytes,
) -> None:
"""Legacy sendDataToHBP: forward a unit-data packet to an HBP (MASTER/PEER) target."""
_tmp_data = b"".join([data[:15], tmp_bits.to_bytes(1, "big"), data[16:20], dmrpkt])
try:
self._send_to_system(d_system, _tmp_data)
except Exception as exc:
logger.warning("(%s) send_data_to_hbp %s failed: %s", source_system, d_system, exc)
return
logger.info("(%s) UNIT Data Bridged to HBP System: %s DST_ID: %s", source_system, d_system, int_id(dst_id))
if int_id(dst_id) == 900999 and len(dmrpkt) == 33:
logger.info(
"(%s) UNIT trace -> %s stream=%s payload33=%s",
source_system, d_system, int_id(stream_id), dmrpkt.hex(),
)
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),
)
)

@ -0,0 +1,620 @@
# ADN DMR Peer Server - bridge helpers
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# 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
###############################################################################
"""Shared bridge routing helpers (no Twisted)."""
from __future__ import annotations
import time
from typing import Any
from ...domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, bytes_3, bytes_4, int_id
def is_private_subscriber_dst(dst_id: bytes) -> bool:
"""True for 7-digit private/unit destinations (legacy routerHBP pvt_call branch)."""
return len(str(int_id(dst_id))) == 7
def unit_data_hbp_target_idle(
dst_slot: dict,
pkt_time: float,
hangtime: float,
) -> bool:
"""Legacy sendDataToHBP gate: both RX/TX idle and past group hangtime."""
from ...domain.hbp_protocol import HBPF_SLT_VTERM
return (
dst_slot.get("RX_TYPE") == HBPF_SLT_VTERM
and dst_slot.get("TX_TYPE") == HBPF_SLT_VTERM
and (pkt_time - dst_slot.get("TX_TIME", 0) > hangtime)
)
def is_unit_data_ingress(
call_type: str,
dtype_vseq: int,
stream_id: bytes,
slot_rx_stream_id: bytes | None,
) -> bool:
"""True when legacy routerHBP sets ``_data_call`` (bridge_master.py ~3130).
Unit data is routed but must not update per-slot RX STATUS (busy check for
downlink SUB_MAP / hotspot match stays open on the source MASTER).
"""
if call_type != "unit":
return False
if dtype_vseq in (6, 7, 8):
return True
if dtype_vseq == 3:
return stream_id != (slot_rx_stream_id or b"\x00")
return False
# Embedded LC codeword sits at bits 116:148 inside the 48-bit EMB field (108:156).
# Legacy bridge_master.py replaces dmrbits[116:148] on bursts B–E (dtype_vseq 1–4).
EMB_LC_SLICE = slice(116, 148)
def tg4000_reset_on_vhead(int_dst_id: int, frame_type: int, dtype_vseq: int) -> bool:
"""True when TG/ID 4000 voice header should trigger a one-shot dynamic reset."""
return (
int_dst_id == 4000
and frame_type == HBPF_DATA_SYNC
and dtype_vseq == HBPF_SLT_VHEAD
)
def obp_target_bcsq_quenches_stream(
systems_cfg: dict[str, Any], target_name: str, dst_id_b: bytes, stream_id: bytes
) -> bool:
"""True if target OBP config has _bcsq[tgid]==stream_id (bytes key or same int TG)."""
m = systems_cfg.get(target_name, {}).get("_bcsq")
if not isinstance(m, dict) or not m:
return False
tid = dst_id_b[:3] if isinstance(dst_id_b, bytes) and len(dst_id_b) >= 3 else bytes_3(int_id(dst_id_b))
if m.get(tid) == stream_id:
return True
for k, v in m.items():
if v != stream_id:
continue
try:
if isinstance(k, bytes) and len(k) >= 3 and int_id(k) == int_id(tid):
return True
except Exception:
continue
return False
def _peer_key_from_int(peer_key: Any) -> bytes:
if isinstance(peer_key, bytes):
return peer_key
if isinstance(peer_key, int):
return bytes_4(peer_key)
if isinstance(peer_key, str) and peer_key.isdigit():
return bytes_4(int(peer_key))
return bytes_4(int_id(peer_key))
def _fuzzy_peer_matches(
val: int,
peers: dict[Any, Any],
) -> list[bytes]:
val_str = str(val)
matches: list[bytes] = []
for pk in peers:
try:
pk_b = _peer_key_from_int(pk)
except (TypeError, ValueError):
continue
pk_int = int_id(pk_b)
pk_str = str(pk_int)
if pk_int == val or pk_int // 100 == val:
matches.append(pk_b)
continue
if len(val_str) >= 5 and len(pk_str) >= 7 and pk_str.startswith(val_str):
matches.append(pk_b)
return matches
def resolve_voice_peer_id(
peer_id: bytes,
rf_src: bytes,
system_name: str,
systems_cfg: dict[str, Any],
) -> bytes:
"""Resolve BRDG_EVENT field 5 for RX legs from a MASTER (hotspot transmitting).
Legacy bridge uses ``_peer_id`` from DMRD for TX legs unchanged. Only RX source
events need the full hotspot radio id so monitor ``rts_update`` marks that peer RX.
"""
peers = systems_cfg.get(system_name, {}).get("PEERS", {})
if not isinstance(peers, dict) or not peers:
return peer_id
peer_b = peer_id if isinstance(peer_id, bytes) else bytes_4(int_id(peer_id))
if peer_b in peers:
return peer_b
rf_b = rf_src if isinstance(rf_src, bytes) else bytes_3(int_id(rf_src))
if rf_b in peers:
return rf_b
peer_matches = _fuzzy_peer_matches(int_id(peer_id), peers)
if len(peer_matches) == 1:
return peer_matches[0]
rf_matches = _fuzzy_peer_matches(int_id(rf_src), peers)
if len(rf_matches) == 1:
return rf_matches[0]
return peer_id
# Back-compat alias for tests and imports.
report_peer_id_for_hbp_target = resolve_voice_peer_id
def is_special_tg(relay_table_key: str) -> bool:
"""True if bridge is special TGID 9990-9999 (excluded from infinite timer)."""
if relay_table_key and relay_table_key[0:1] == "#":
return False
try:
return 9990 <= int(relay_table_key) <= 9999
except ValueError:
return False
def parse_dmrd_route_fields(packet: bytes) -> tuple[int, int, str] | None:
"""Parse HBP DMRD slot, destination TG, and call type for downlink OPTIONS filter."""
if len(packet) < 17 or packet[:4] != b"DMRD":
return None
bits = packet[15]
slot = 2 if (bits & 0x80) else 1
if bits & 0x40:
call_type = "unit"
elif (bits & 0x23) == 0x23:
call_type = "vcsbk"
else:
call_type = "group"
return slot, int_id(packet[8:11]), call_type
def _system_has_active_bridge_leg(
bridges: dict[str, Any] | None,
system: str,
slot: int,
tgid: int,
*,
subscription_store: Any | None = None,
) -> bool:
"""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))
if not isinstance(legs, list):
return False
for leg in legs:
if not isinstance(leg, dict) or not leg.get("ACTIVE"):
continue
if str(leg.get("SYSTEM", "")) != system:
continue
if int(leg.get("TS", 0)) != int(slot):
continue
return True
return False
def peer_options_fields(peer: dict[str, Any]) -> dict[str, Any]:
"""Parse hotspot OPTIONS into fields used by SINGLE/TIMER resolution."""
from adn_server.application.report.payloads import parse_peer_options_fields
return parse_peer_options_fields(peer.get("OPTIONS"))
def _peer_ua_session_entry(
sys_cfg: dict[str, Any],
peer_id: bytes | None,
slot: int,
) -> dict[str, Any] | None:
if peer_id is None:
return None
store = sys_cfg.get("_PEER_UA_SESSIONS")
if not isinstance(store, dict):
return None
pk = bytes_4(int_id(peer_id))
per_peer = store.get(pk)
if not isinstance(per_peer, dict):
return None
entry = per_peer.get(slot)
return entry if isinstance(entry, dict) else None
def _write_peer_ua_session(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
tgid: int,
expires: float,
sys_cfg: dict[str, Any],
) -> None:
entry = {"tgid": int(tgid), "expires": float(expires)}
pk = bytes_4(int_id(peer_id))
sys_cfg.setdefault("_PEER_UA_SESSIONS", {}).setdefault(pk, {})[slot] = entry
peer.setdefault("_UA_SESSION", {})[slot] = entry
def peer_single_mode(peer: dict[str, Any], sys_cfg: dict[str, Any]) -> bool:
from adn_server.application.report.payloads import resolve_peer_single_and_timer
single, _ = resolve_peer_single_and_timer(peer_options_fields(peer), sys_cfg)
return single
def _peer_ua_multi_store(sys_cfg: dict[str, Any]) -> dict[bytes, dict[int, set[int]]]:
store = sys_cfg.setdefault("_PEER_UA_MULTI_TGS", {})
if not isinstance(store, dict):
store = {}
sys_cfg["_PEER_UA_MULTI_TGS"] = store
return store
def register_peer_ua_multi_tg(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
tgid: int,
sys_cfg: dict[str, Any],
) -> None:
"""SINGLE=0: accumulate keyed dynamic TGs per peer/slot until TG 4000."""
if peer_single_mode(peer, sys_cfg):
return
tgid_i = int(tgid)
if tgid_i <= 0 or tgid_i == 4000:
return
if peer_receives_group_tgid(peer, slot, tgid_i):
return
pk = bytes_4(int_id(peer_id))
per_peer = _peer_ua_multi_store(sys_cfg).setdefault(pk, {})
slot_set = per_peer.setdefault(int(slot), set())
slot_set.add(tgid_i)
def peer_owns_multi_dynamic_ua(
peer: dict[str, Any],
slot: int,
tgid: int,
sys_cfg: dict[str, Any] | None,
*,
peer_id: bytes | None = None,
) -> bool:
"""True when SINGLE=0 peer has keyed this non-static dynamic TG on ``slot``."""
if not sys_cfg or peer_single_mode(peer, sys_cfg):
return False
if peer_id is None:
return False
if peer_receives_group_tgid(peer, slot, tgid):
return False
pk = bytes_4(int_id(peer_id))
store = sys_cfg.get("_PEER_UA_MULTI_TGS")
if not isinstance(store, dict):
return False
per_peer = store.get(pk)
if not isinstance(per_peer, dict):
return False
slot_set = per_peer.get(int(slot))
return isinstance(slot_set, set) and int(tgid) in slot_set
def register_peer_ua_session(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
tgid: int,
sys_cfg: dict[str, Any],
*,
now: float | None = None,
) -> None:
"""Track UA TG for this hotspot (SINGLE=1 exclusive; SINGLE=0 multi-dynamic set)."""
if not peer_single_mode(peer, sys_cfg):
register_peer_ua_multi_tg(peer, peer_id, slot, tgid, sys_cfg)
return
from adn_server.application.report.payloads import resolve_peer_single_and_timer
_, timer_min = resolve_peer_single_and_timer(peer_options_fields(peer), sys_cfg)
pkt_time = time.time() if now is None else now
_write_peer_ua_session(
peer,
peer_id,
slot,
int(tgid),
pkt_time + float(timer_min) * 60.0,
sys_cfg,
)
def seed_peer_ua_session_from_status(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
status_slot: dict[str, Any],
sys_cfg: dict[str, Any],
*,
now: float | None = None,
) -> None:
"""Seed SINGLE session after RPTO when TX happened before OPTIONS (inject-only)."""
if not peer_single_mode(peer, sys_cfg):
return
pkt_time = time.time() if now is None else now
if peer_single_exclusive_tgid(peer, slot, sys_cfg, peer_id=peer_id, now=pkt_time) is not None:
return
rx_peer = status_slot.get("RX_PEER", b"\x00\x00\x00\x00")
if bytes_4(int_id(peer_id)) != bytes_4(int_id(rx_peer)):
return
rx_tgid = int_id(status_slot.get("RX_TGID", b"\x00\x00\x00"))
if rx_tgid <= 0:
return
connected_at = float(peer.get("CONNECTED", 0) or 0)
rx_time = float(status_slot.get("RX_TIME", 0) or 0)
if connected_at > 0 and rx_time < connected_at - 0.5:
return
register_peer_ua_session(peer, peer_id, slot, rx_tgid, sys_cfg, now=pkt_time)
def clear_peer_rx_status_slots(
status: dict[Any, Any],
peer_id: bytes,
) -> None:
"""Reset RX fields on slots last owned by this peer (avoids stale OPTIONS seed)."""
pk = bytes_4(int_id(peer_id))
for slot in (1, 2):
slot_st = status.get(slot)
if not isinstance(slot_st, dict):
continue
if bytes_4(int_id(slot_st.get("RX_PEER", b"\x00"))) != pk:
continue
slot_st["RX_PEER"] = b"\x00"
slot_st["RX_TGID"] = b"\x00\x00\x00"
slot_st["RX_STREAM_ID"] = b"\x00"
slot_st["RX_TIME"] = 0.0
def export_peer_ua_sessions(
sys_cfg: dict[str, Any],
peer_id: bytes | int,
*,
now: float | None = None,
) -> dict[str, dict[str, float | int]]:
"""Active SINGLE sessions for monitor snapshot (server source of truth)."""
pkt_time = time.time() if now is None else now
pk = bytes_4(int_id(peer_id))
out: dict[str, dict[str, float | int]] = {}
store = sys_cfg.get("_PEER_UA_SESSIONS")
if not isinstance(store, dict):
return out
per_peer = store.get(pk)
if not isinstance(per_peer, dict):
return out
for slot in (1, 2):
entry = per_peer.get(slot)
if not isinstance(entry, dict):
continue
exp = float(entry.get("expires", 0) or 0)
tgid = int(entry.get("tgid", 0) or 0)
if tgid > 0 and exp > pkt_time:
out[str(slot)] = {"tgid": tgid, "expires_at": exp}
return out
def clear_peer_ua_sessions(
peer: dict[str, Any],
sys_cfg: dict[str, Any],
peer_id: bytes,
*,
slot: int | None = None,
) -> None:
"""Clear per-peer UA state (SINGLE session and/or SINGLE=0 multi-dynamic set)."""
pk = bytes_4(int_id(peer_id))
store = sys_cfg.get("_PEER_UA_SESSIONS")
if isinstance(store, dict) and pk in store:
if slot is None:
store.pop(pk, None)
else:
per_peer = store.get(pk)
if isinstance(per_peer, dict):
per_peer.pop(slot, None)
multi = sys_cfg.get("_PEER_UA_MULTI_TGS")
if isinstance(multi, dict) and pk in multi:
if slot is None:
multi.pop(pk, None)
else:
per_peer = multi.get(pk)
if isinstance(per_peer, dict):
per_peer.pop(int(slot), None)
sessions = peer.get("_UA_SESSION")
if isinstance(sessions, dict):
if slot is None:
sessions.clear()
else:
sessions.pop(slot, None)
def peer_single_exclusive_tgid(
peer: dict[str, Any],
slot: int,
sys_cfg: dict[str, Any],
*,
peer_id: bytes | None = None,
now: float | None = None,
) -> int | None:
"""Active SINGLE session TG on ``slot``, or ``None`` when no exclusive lock."""
if not peer_single_mode(peer, sys_cfg):
return None
pkt_time = time.time() if now is None else now
entry = _peer_ua_session_entry(sys_cfg, peer_id, slot)
if entry is None:
sessions = peer.get("_UA_SESSION")
if isinstance(sessions, dict):
entry = sessions.get(slot)
if not isinstance(entry, dict):
return None
if pkt_time >= float(entry.get("expires", 0)):
return None
locked = entry.get("tgid")
return int(locked) if locked is not None else None
def peer_single_blocks_group_voice(
peer: dict[str, Any],
slot: int,
tgid: int,
sys_cfg: dict[str, Any] | None,
*,
peer_id: bytes | None = None,
now: float | None = None,
) -> bool:
"""True when SINGLE=1 peer must not receive downlink for ``tgid`` on ``slot``.
With an active session on TG X, every other TG (static or dynamic) is blocked
until the TIMER expires or a new local TX replaces the session.
"""
if not sys_cfg:
return False
locked = peer_single_exclusive_tgid(peer, slot, sys_cfg, peer_id=peer_id, now=now)
if locked is None:
return False
return int(tgid) != locked
def peer_receives_group_tgid(peer: dict[str, Any], slot: int, tgid: int) -> bool:
"""True when peer RPTO OPTIONS list the group TG on that voice timeslot."""
from adn_server.application.report.payloads import parse_peer_options_static
ts1, ts2 = parse_peer_options_static(peer.get("OPTIONS"))
static = ts1 if slot == 1 else ts2
if not static:
return False
return str(tgid) in static
def peer_single_blocks_uplink(
peer: dict[str, Any],
peer_id: bytes,
slot: int,
tgid: int,
sys_cfg: dict[str, Any] | None,
*,
now: float | None = None,
) -> bool:
"""SINGLE=1 never blocks local TX; a new TG replaces the session (see ``register_peer_ua_session``).
Downlink exclusivity is enforced by :func:`peer_single_blocks_group_voice` only.
"""
del peer, peer_id, slot, tgid, sys_cfg, now
return False
def _peer_owns_dynamic_ua(
peer: dict[str, Any],
slot: int,
tgid: int,
sys_cfg: dict[str, Any] | None,
*,
peer_id: bytes | None = None,
now: float | None = None,
) -> bool:
"""True when ``tgid`` is a non-static UA this peer activated (SINGLE session owner)."""
if peer_receives_group_tgid(peer, slot, tgid):
return False
if not sys_cfg:
return False
locked = peer_single_exclusive_tgid(peer, slot, sys_cfg, peer_id=peer_id, now=now)
return locked is not None and int(tgid) == locked
def peer_should_receive_group_voice(
peer: dict[str, Any],
slot: int,
tgid: int,
*,
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,
) -> bool:
"""Whether a hotspot should get downlink / monitor voice for ``(slot, tgid)``.
Inject-only multi-hotspot rules (per peer):
1. ``SINGLE=1`` with an active session on another TG → deny all other TGs.
2. TG in this peer's OPTIONS static list → allow (when not blocked by SINGLE).
3. ``SINGLE=1``: dynamic UA owned by this peer's exclusive session → allow.
4. ``SINGLE=0``: dynamic UA this peer keyed (multi set) → allow.
5. Sole connected hotspot with an ACTIVE bridge leg for ``(slot, tgid)`` → allow.
6. Otherwise → deny (no fan-out).
A system-wide ACTIVE bridge leg must **not** fan out to every hotspot when
several peers are online; that was the regression when ``bridges`` alone
decided fan-out for all connected hotspots.
"""
if peer_single_blocks_group_voice(peer, slot, tgid, sys_cfg, peer_id=peer_id, now=now):
return False
if peer_receives_group_tgid(peer, slot, tgid):
return True
if _peer_owns_dynamic_ua(peer, slot, tgid, sys_cfg, peer_id=peer_id, now=now):
return True
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, subscription_store=subscription_store
):
return True
return False
def peer_matches_rf_source(peer_id: bytes, rf_src: bytes, peers: dict[Any, Any]) -> bool:
"""True when a hotspot radio id matches the voice RF source (parrot / echo downlink)."""
peer_b = _peer_key_from_int(peer_id)
return peer_b in _fuzzy_peer_matches(int_id(rf_src), peers)

@ -0,0 +1,506 @@
# ADN DMR Peer Server - bridge LC / Talker Alias
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# 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
###############################################################################
"""Embedded LC rewrite and Talker Alias bridge coordination (no Twisted)."""
from __future__ import annotations
import logging
from typing import Any
from bitarray import bitarray
from ...domain.dmr.const import LC_OPT
from ...domain.talker_alias import DMRA_BLOCK_COUNT
from ...domain import int_id
from ..talker_alias_use_cases import passthrough_complete, talker_alias_settings
from .helpers import EMB_LC_SLICE
logger = logging.getLogger(__name__)
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:
if not self._get_dmra_blocks:
return None
return self._get_dmra_blocks(source_system, stream_id)
def _both_ta_key(self, source_system: str, stream_id: bytes) -> tuple[str, bytes]:
return (source_system, stream_id)
def _ta_capable_source(self, source_system: str) -> bool:
"""True when the source leg can buffer TA (MASTER DMRA/voice or OBP voice)."""
return self._config.get("SYSTEMS", {}).get(source_system, {}).get("MODE") in (
"MASTER",
"OPENBRIDGE",
)
def _master_ta_wait_source(self, source_system: str) -> bool:
"""``both`` mode waits for hotspot TA only on HBP MASTER sources (not OBP)."""
return self._config.get("SYSTEMS", {}).get(source_system, {}).get("MODE") == "MASTER"
def _cancel_both_ta_wait(self, source_system: str, stream_id: bytes) -> None:
wait = self._both_ta_wait.pop(self._both_ta_key(source_system, stream_id), None)
if wait and wait.get("timer") and getattr(wait["timer"], "cancel", None):
try:
wait["timer"].cancel()
except Exception:
pass
def _register_both_ta_wait(
self,
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
source_peer: bytes,
) -> None:
"""Defer DMRA UDP + embed inject until MMDVM fragments arrive (both mode)."""
if not self._call_later:
return
key = self._both_ta_key(source_system, stream_id)
wait = self._both_ta_wait.setdefault(
key,
{"rf_src": rf_src, "peer": source_peer, "targets": set()},
)
wait["rf_src"] = rf_src
wait["peer"] = source_peer
wait["targets"].add(target_system)
if wait.get("timer"):
return
# Wait long enough to detect a slow source TA (MMDVM emits TA ~1 s in) before
# falling back to inject; passthrough is applied earlier via on_dmra_fragment_stored.
wait["timer"] = self._call_later(2.0, self._finalize_both_ta, source_system, stream_id)
def _finalize_both_ta(self, source_system: str, stream_id: bytes) -> None:
key = self._both_ta_key(source_system, stream_id)
wait = self._both_ta_wait.pop(key, None)
if not wait:
return
rf_src = wait["rf_src"]
peer = wait["peer"]
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
# No source TA within the window: fall back to inject (template).
fallback_inject = not (blocks and passthrough_complete(blocks))
for target_system in wait["targets"]:
if not self._talker_alias.should_send_on_vhead(target_system, stream_id):
continue
self._send_talker_alias_to_target(
source_system, target_system, rf_src, stream_id, peer,
force=True, fallback_inject=fallback_inject,
)
self._apply_both_ta_embed(source_system, rf_src, stream_id, force_inject=fallback_inject)
def _apply_both_ta_embed(
self,
source_system: str,
rf_src: bytes,
stream_id: bytes,
*,
force_inject: bool = False,
) -> None:
"""Install embed LC state once the TA decision is known (passthrough or inject)."""
if not self._get_protocols:
return
for proto in self._get_protocols().values():
status = getattr(proto, "STATUS", None)
if not isinstance(status, dict):
continue
for st in status.values():
if not isinstance(st, dict):
continue
if st.get("TX_TA_ON"):
continue
if st.get("_ta_embed_kind") == "passthrough" and not force_inject:
continue
if st.get("TX_STREAM_ID") == stream_id and st.get("TX_RFS") == rf_src:
target = st.get("_ta_target_system", source_system)
self._init_talker_alias_embed(
st, source_system, target, rf_src, stream_id, force_inject=force_inject,
)
elif st.get("REP_STREAM_ID") == stream_id:
self._init_talker_alias_embed(
st, source_system, source_system, rf_src, stream_id, force_inject=force_inject,
)
def on_dmra_fragment_stored(
self,
source_system: str,
peer_id: bytes,
rf_src: bytes,
stream_id: bytes,
) -> None:
"""Source TA may complete after VHEAD (DMRA UDP or decoded from voice).
Once the buffer is complete, overlay the source TA on the outgoing embedded LC
(passthrough/both). For `both` with a pending wait, also relay DMRA now and cancel
the inject fallback.
"""
if talker_alias_settings(self._config, source_system)["mode"] not in ("both", "passthrough"):
return
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
if not blocks or not passthrough_complete(blocks):
return
relay_key = self._both_ta_key(source_system, stream_id)
already_relayed = relay_key in self._passthrough_relayed
wait = self._both_ta_wait.get(relay_key)
if wait and not already_relayed:
wait["peer"] = peer_id
self._cancel_both_ta_wait(source_system, stream_id)
for target_system in wait["targets"]:
self._send_talker_alias_to_target(
source_system, target_system, rf_src, stream_id, peer_id, force=True,
)
self._apply_both_ta_embed(source_system, rf_src, stream_id)
if not already_relayed:
self._relay_passthrough_dmra(source_system, peer_id, rf_src, stream_id)
self._passthrough_relayed.add(relay_key)
def _relay_passthrough_dmra(
self,
source_system: str,
peer_id: bytes,
rf_src: bytes,
stream_id: bytes,
) -> None:
"""After the source TA buffer is complete, relay DMRA to bridge/repeat targets."""
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
if not blocks or not passthrough_complete(blocks):
return
if self._get_protocols:
for proto in self._get_protocols().values():
status = getattr(proto, "STATUS", None)
if not isinstance(status, dict):
continue
for st in status.values():
if not isinstance(st, dict):
continue
if st.get("TX_STREAM_ID") != stream_id or st.get("TX_RFS") != rf_src:
continue
target = st.get("_ta_target_system")
if not isinstance(target, str) or not target:
continue
tx_peer = st.get("TX_PEER", peer_id)
self._send_talker_alias_to_target(
source_system, target, rf_src, stream_id, tx_peer, force=True,
)
src_cfg = self._config.get("SYSTEMS", {}).get(source_system, {})
if src_cfg.get("MODE") == "MASTER" and src_cfg.get("REPEAT", True):
self.send_talker_alias_local_repeat(source_system, peer_id, rf_src, stream_id)
def _dispatch_talker_alias_on_bridge_open(
self,
target_st: dict[str, Any],
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
source_peer: bytes,
) -> None:
"""Prepare TA on a new bridged HBP leg (VHEAD or first burst after hangtime)."""
settings = talker_alias_settings(self._config, source_system)
if not settings["enabled"]:
return
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
have_source_ta = bool(blocks and passthrough_complete(blocks))
if settings["mode"] == "both" and self._master_ta_wait_source(source_system) and not have_source_ta:
self._register_both_ta_wait(
source_system, target_system, rf_src, stream_id, source_peer,
)
return
self._init_talker_alias_embed(
target_st,
source_system,
target_system,
rf_src,
stream_id,
)
self._send_talker_alias_to_target(
source_system, target_system, rf_src, stream_id, source_peer,
)
def _send_talker_alias_to_target(
self,
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
source_peer: bytes,
*,
force: bool = False,
fallback_inject: bool = False,
) -> None:
"""Emit DMRA to an HBP target on VHEAD (once per target stream)."""
if not self._send_dmra_to_system:
return
tgt_mode = self._config.get("SYSTEMS", {}).get(target_system, {}).get("MODE")
if tgt_mode not in ("MASTER", "PEER"):
return
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
have_passthrough = bool(blocks and passthrough_complete(blocks))
if have_passthrough:
if force:
if not self._talker_alias.should_resend_passthrough_dmra(target_system, stream_id):
return
elif not self._talker_alias.should_send_on_vhead(target_system, stream_id):
return
elif not self._talker_alias.should_send_on_vhead(target_system, stream_id):
return
if not have_passthrough:
# Legacy resolve_ta (both): inject at VHEAD when the buffer is still empty.
fallback_inject = True
packets = self._talker_alias.packets_for_stream(
source_system,
rf_src,
stream_id,
self._get_dmra_blocks,
target_system=target_system,
fallback_inject=fallback_inject,
)
if not packets:
return
exclude = source_peer if target_system == source_system else None
try:
peer_count = self._send_dmra_to_system(target_system, packets, exclude_peer=exclude)
except Exception as e:
logger.warning("(ROUTER) send_dmra_to_system %s failed: %s", target_system, e)
return
self._talker_alias.mark_dmra_sent(
target_system,
stream_id,
kind="passthrough" if have_passthrough else "inject",
)
sid = int_id(stream_id)
if peer_count:
logger.debug(
"(%s) *TALKER ALIAS* stream %s sent %d DMRA block(s) to %d peer(s)",
target_system, sid, len(packets), peer_count,
)
elif exclude:
logger.debug(
"(%s) *TALKER ALIAS* stream %s no DMRA sent (source peer %s excluded on repeat)",
target_system, sid, int_id(exclude),
)
def send_talker_alias_local_repeat(
self,
system_name: str,
source_peer: bytes,
rf_src: bytes,
stream_id: bytes,
) -> None:
"""Inject/pass-through TA to other peers on this MASTER (REPEAT path)."""
self._send_talker_alias_to_target(
system_name, system_name, rf_src, stream_id, source_peer,
)
def prepare_talker_alias_local_repeat(
self,
system_name: str,
source_peer: bytes,
rf_src: bytes,
dst_id: bytes,
slot: int,
stream_id: bytes,
) -> None:
"""REPEAT on VHEAD: standalone DMRA plus embedded TA state for downlink DMRD.
WPSD/MMDVMHost ignores standalone DMRA UDP; it only displays Talker Alias decoded
from embedded LC inside repeated voice bursts (B–E).
"""
settings = talker_alias_settings(self._config, system_name)
if settings["enabled"] and self._get_protocols:
proto = self._get_protocols().get(system_name)
status = getattr(proto, "STATUS", None) if proto else None
if isinstance(status, dict) and slot in status:
st = status[slot]
if not isinstance(st, dict):
st = {}
status[slot] = st
if st.get("REP_STREAM_ID") != stream_id:
dst_lc = LC_OPT + dst_id + rf_src
st["REP_STREAM_ID"] = stream_id
st["REP_EMB_LC"] = self._encode_emblc(dst_lc)
self._init_talker_alias_embed(
st, system_name, system_name, rf_src, stream_id,
)
self.send_talker_alias_local_repeat(system_name, source_peer, rf_src, stream_id)
def rewrite_repeat_voice_burst(
self,
system_name: str,
slot: int,
stream_id: bytes,
dtype_vseq: int,
dmrpkt: bytes,
) -> bytes:
"""Overlay Talker Alias on embedded LC for REPEAT copies (voice bursts B–E)."""
if dtype_vseq not in (1, 2, 3, 4) or len(dmrpkt) < 33:
return dmrpkt
if not self._get_protocols:
return dmrpkt
proto = self._get_protocols().get(system_name)
status = getattr(proto, "STATUS", None) if proto else None
if not isinstance(status, dict):
return dmrpkt
st = status.get(slot)
if not isinstance(st, dict) or st.get("REP_STREAM_ID") != stream_id:
return dmrpkt
if "REP_EMB_LC" not in st or st.get("TX_TA_EMB") is None:
return dmrpkt
dmrbits = bitarray(endian="big")
dmrbits.frombytes(dmrpkt)
self._rewrite_embed_lc(dmrbits, st, dtype_vseq, "REP_EMB_LC")
return dmrbits.tobytes()
def clear_talker_alias_stream(self, system_name: str, stream_id: bytes) -> None:
"""Release per-stream TA dedupe state after VTERM."""
self._cancel_both_ta_wait(system_name, stream_id)
self._passthrough_relayed.discard(self._both_ta_key(system_name, stream_id))
self._talker_alias.clear_stream(system_name, stream_id)
if not self._get_protocols:
return
proto = self._get_protocols().get(system_name)
if proto is not None and hasattr(proto, "clear_ta_stream_buffer"):
proto.clear_ta_stream_buffer(stream_id)
if proto is None:
return
status = getattr(proto, "STATUS", None)
if not isinstance(status, dict):
return
for slot in (1, 2):
st = status.get(slot)
if not isinstance(st, dict):
continue
if st.get("TX_STREAM_ID") == stream_id:
self._clear_talker_alias_embed(st)
if st.get("REP_STREAM_ID") == stream_id:
st.pop("REP_STREAM_ID", None)
st.pop("REP_EMB_LC", None)
self._clear_talker_alias_embed(st)
def _init_talker_alias_embed(
self,
st: dict[str, Any],
source_system: str,
target_system: str,
rf_src: bytes,
stream_id: bytes,
*,
force_inject: bool = False,
) -> None:
"""Prepare per-stream embedded TA state for DMRD voice injection.
The group LC is always rewritten for the destination TG by ``_rewrite_embed_lc``;
here we only set ``TX_TA_EMB`` when a Talker Alias should be overlaid. The source
TA (passthrough/both) is re-encoded from its decoded DMRA/voice blocks once they
arrive; the template is used for ``inject`` and the ``both`` fallback. If no TA is
available yet (e.g. at VHEAD, before the source TA has been decoded), TX_TA_EMB is
left unset and only the destination group LC is emitted until it becomes available.
"""
st["_ta_source_system"] = source_system
st["_ta_target_system"] = target_system
settings = talker_alias_settings(self._config, source_system)
if not settings["enabled"]:
return
if settings["mode"] == "both" and not self._ta_capable_source(source_system):
force_inject = True
st.pop("TX_TA_EMB", None)
st.pop("TX_TA_PHASE", None)
st.pop("TX_TA_ON", None)
emblcs = self._talker_alias.embedded_emblc_for_stream(
source_system,
rf_src,
stream_id,
self._get_dmra_blocks,
target_system=target_system,
fallback_inject=force_inject,
)
if emblcs:
st["TX_TA_EMB"], st["TX_TA_BLOCK_COUNT"] = emblcs
st["TX_TA_PHASE"] = 0
# First B1–B4 cycle carries group LC; TA on the next cycle.
st["TX_TA_ON"] = False
blocks = self._get_stream_dmra_blocks(source_system, stream_id)
if blocks and passthrough_complete(blocks) and not force_inject:
st["_ta_embed_kind"] = "passthrough"
else:
st["_ta_embed_kind"] = "inject"
def _rewrite_embed_lc(
self,
dmrbits: bitarray,
st: dict[str, Any],
dtype_vseq: int,
emb_key: str,
) -> None:
"""Replace embedded LC on voice bursts B–E (legacy bridge.py parity).
Group-call superframes always carry the **destination** group LC (``emb_key``),
re-encoded for the rewritten TGID — this is required for the voice to be accepted
by the receiving MMDVM (a mismatched embedded LC causes packet loss). When a
Talker Alias is available (``TX_TA_EMB``: injected template or the source TA
re-encoded from its DMRA/voice blocks) it is overlaid on alternate superframes.
"""
if dtype_vseq not in (1, 2, 3, 4):
return
ta_emb = st.get("TX_TA_EMB")
if ta_emb is not None and st.get("TX_TA_ON"):
phase = st.get("TX_TA_PHASE", 0)
block_count = st.get("TX_TA_BLOCK_COUNT", DMRA_BLOCK_COUNT)
frag = ta_emb[phase][dtype_vseq]
if dtype_vseq == 4:
st["TX_TA_ON"] = False
st["TX_TA_PHASE"] = (phase + 1) % block_count
else:
frag = st[emb_key][dtype_vseq]
if dtype_vseq == 4 and ta_emb is not None:
st["TX_TA_ON"] = True
dmrbits[EMB_LC_SLICE] = frag
def _clear_talker_alias_embed(self, st: dict[str, Any]) -> None:
st.pop("TX_TA_EMB", None)
st.pop("TX_TA_PHASE", None)
st.pop("TX_TA_BLOCK_COUNT", None)
st.pop("TX_TA_ON", None)
st.pop("_ta_embed_kind", None)

@ -0,0 +1,461 @@
# ADN DMR Peer Server - bridge OBP forward path
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# 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
###############################################################################
"""OpenBridge ingress routing and unit-data forward (no Twisted imports)."""
from __future__ import annotations
import logging
import time
from hashlib import blake2b
from time import perf_counter
from typing import Any
from ...domain.dmr import decode
from ...domain.dmr.const import LC_OPT
from ...domain import HBPF_DATA_SYNC, HBPF_SLT_VHEAD, int_id
logger = logging.getLogger(__name__)
class ObpForwardMixin:
"""routerOBP group voice, stream tracking, sendDataToOBP."""
def _ensure_obp_source_for_tg(
self,
system_name: str,
relay_table_key: str,
dst_id_b: bytes,
dst_int: int,
) -> None:
"""Ensure this OBP has an ACTIVE source row for TG (TS1) in main and #reflector bridges.
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 ensure_dynamic_relay OBP entries.
"""
systems_cfg = self._config.get("SYSTEMS", {})
if systems_cfg.get(system_name, {}).get("MODE") != "OPENBRIDGE":
return
if not systems_cfg.get(system_name, {}).get("ENABLED", True):
return
if not (79 <= dst_int < 9990 or dst_int > 9999):
return
from ..subscription.obp_source_ops import ensure_obp_source_for_tg_store
ensure_obp_source_for_tg_store(
self._subscription_store,
system_name,
relay_table_key,
dst_id_b,
dst_int,
time.time(),
)
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)."""
status = getattr(src_proto, "STATUS", None)
if status is not None:
status[stream_id] = st
def _is_stream_known(self, system_name: str, stream_id: bytes, slot: int = 0) -> bool:
"""Return True if *stream_id* is already tracked.
Legacy routerHBP (bridge_master.py ~3054): ``_stream_id != STATUS[_slot]['RX_STREAM_ID']``
Legacy routerOBP (bridge_master.py ~2193): ``_stream_id not in self.STATUS``
For HBP (MASTER/PEER) we compare against the per-slot ``RX_STREAM_ID``.
For OBP we check membership in the flat ``STATUS`` dict — keyed only by
stream_id since routerOBP.__init__ does ``self.STATUS = {}`` (legacy
bridge_master.py:1911); slot-keyed entries do not exist on OBP protocols.
"""
systems_cfg = self._config.get("SYSTEMS", {})
sys_mode = systems_cfg.get(system_name, {}).get("MODE", "")
if sys_mode == "OPENBRIDGE":
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
if proto is None:
return False
status = getattr(proto, "STATUS", None)
if status is None:
return False
return stream_id in status
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
if proto is None:
return False
status = getattr(proto, "STATUS", None)
if status is None or not isinstance(status, dict):
return False
slot_st = status.get(slot)
if not isinstance(slot_st, dict):
return False
return slot_st.get("RX_STREAM_ID") == stream_id
def _obp_group_voice_router_obp(
self,
system_name: str,
peer_id: bytes,
rf_src: bytes,
dst_id: bytes,
seq: int,
slot: int,
call_type: str,
frame_type: int,
dtype_vseq: int,
stream_id: bytes,
data: bytes,
obp_hops: bytes,
) -> bool:
"""Port of bridge_master.routerOBP.dmrd_received group/vcsbk (~2269-2411). False = drop packet."""
pkt_time = time.time()
dmrpkt = data[20:53] if len(data) >= 53 else b""
_h = blake2b(digest_size=16)
_h.update(data)
_pkt_crc = _h.digest()
protocols = self._get_protocols() if self._get_protocols else {}
src_proto = protocols.get(system_name) if protocols else None
if not src_proto:
return True
systems_cfg = self._config.get("SYSTEMS", {})
_do_report = bool(self._config.get("REPORTS", {}).get("REPORT", True))
status = getattr(src_proto, "STATUS", None)
if status is None:
return True
if stream_id not in status:
st: dict[str, Any] = {
"START": pkt_time,
"CONTENTION": False,
"RFS": rf_src,
"TGID": dst_id,
"1ST": perf_counter(),
"lastSeq": False,
"lastData": False,
"RX_PEER": peer_id,
"packets": 0,
"loss": 0,
"crcs": set(),
}
if frame_type == HBPF_DATA_SYNC and dtype_vseq == HBPF_SLT_VHEAD:
try:
decoded = decode.voice_head_term(dmrpkt)
st["LC"] = decoded["LC"]
except Exception:
st["LC"] = LC_OPT + dst_id + rf_src
else:
st["LC"] = LC_OPT + dst_id + rf_src
self._obp_wire_stream_dict(src_proto, stream_id, st)
_inthops = int.from_bytes(obp_hops, "big") if obp_hops else 0
logger.info(
"(%s) *CALL START* STREAM ID: %s SUB: %s PEER: %s TGID %s TS %s HOPS %s",
system_name,
int_id(stream_id),
int_id(rf_src),
int_id(peer_id),
int_id(dst_id),
slot,
_inthops,
)
# INGRESS: debug-only (all OBP legs); monitor logs it but does not update OPENBRIDGES chips until START.
if _do_report:
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)
)
)
else:
st = status[stream_id]
if "packets" in st:
st["packets"] = st["packets"] + 1
if "_fin" in st:
if "_finlog" not in st:
logger.debug(
"(%s) OBP *LoopControl* STREAM ID: %s ALREADY FINISHED FROM THIS SOURCE, IGNORING",
system_name,
int_id(stream_id),
)
st["_finlog"] = True
return False
if st["START"] + 180 < pkt_time:
if "LOOPLOG" not in st or not st["LOOPLOG"]:
logger.info(
"(%s) OBP *TIMEOUT*, STREAM ID: %s, TG: %s, IGNORE THIS SOURCE",
system_name,
int_id(stream_id),
int_id(dst_id),
)
st["LOOPLOG"] = True
st["LAST"] = pkt_time
return False
# Legacy routerOBP ~2409: LoopControl only on 2nd+ packet (else branch).
hr_times: dict[str, float] = {}
_sysslot_last = 0
for other_name, proto in (protocols or {}).items():
omode = systems_cfg.get(other_name, {}).get("MODE")
if other_name != system_name and omode != "OPENBRIDGE":
ostatus = getattr(proto, "STATUS", None)
if not ostatus:
continue
for _sysslot in ostatus:
_sysslot_last = _sysslot if isinstance(_sysslot, int) else _sysslot_last
slot_st = ostatus.get(_sysslot)
if not isinstance(slot_st, dict):
continue
if "RX_STREAM_ID" in slot_st and stream_id == slot_st.get("RX_STREAM_ID"):
if "LOOPLOG" not in st or not st["LOOPLOG"]:
logger.debug(
"(%s) OBP *LoopControl* FIRST HBP: %s, STREAM ID: %s, TG: %s, TS: %s, IGNORE THIS SOURCE",
system_name,
other_name,
int_id(stream_id),
int_id(dst_id),
_sysslot,
)
st["LOOPLOG"] = True
st["LAST"] = pkt_time
return False
else:
obp_status = getattr(proto, "STATUS", None)
if not obp_status:
continue
if (
stream_id in obp_status
and "1ST" in obp_status[stream_id]
and obp_status[stream_id].get("TGID") == dst_id
):
hr_times[other_name] = obp_status[stream_id]["1ST"]
fi = min(hr_times, key=hr_times.get, default=False)
hr_times.clear()
if not fi:
logger.warning(
"(%s) OBP *LoopControl* fi is empty for some reason : STREAM ID: %s, TG: %s, TS: %s",
system_name,
int_id(stream_id),
int_id(dst_id),
_sysslot_last,
)
return False
if system_name != fi:
if "LOOPLOG" not in st or not st["LOOPLOG"]:
call_duration = pkt_time - st["START"]
logger.debug(
"(%s) OBP *LoopControl* FIRST OBP %s, STREAM ID: %s, TG %s, IGNORE THIS SOURCE. PACKET RATE %0.2f/s",
system_name,
fi,
int_id(stream_id),
int_id(dst_id),
call_duration,
)
st["LOOPLOG"] = True
if _do_report:
self._send_routing_event(
"GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format(
system_name,
int_id(stream_id),
int_id(peer_id),
int_id(rf_src),
slot,
int_id(dst_id),
max(0.0, pkt_time - st.get("START", pkt_time)),
)
)
st["LAST"] = pkt_time
if systems_cfg.get(system_name, {}).get("ENHANCED_OBP") and self._send_bcsq and "_bcsq" not in st:
self._send_bcsq(system_name, dst_id, stream_id)
st["_bcsq"] = True
return False
st = status[stream_id]
# Legacy skips packet control on the first frame of a stream (else branch only on 2nd+ packet).
if st.get("packets", 0) > 0:
# Legacy routerOBP ~2452: packets/START (START is epoch time, not elapsed) — never triggers in practice.
if st["packets"] > 18 and (st["packets"] / st["START"]) > 25:
logger.warning(
"(%s) *PacketControl* RATE DROP! Stream ID:, %s TGID: %s",
system_name,
int_id(stream_id),
int_id(dst_id),
)
pb = getattr(src_proto, "proxy_bad_peer", None)
if callable(pb):
pb()
return False
if st["lastData"] and st["lastData"] == data and seq > 1:
st["loss"] += 1
logger.debug(
"(%s) *PacketControl* last packet is a complete duplicate of the previous one, disgarding. Stream ID:, %s TGID: %s, LOSS: %.2f%%",
system_name,
int_id(stream_id),
int_id(dst_id),
((st["loss"] / st["packets"]) * 100) if st.get("packets") else 0.0,
)
return False
if seq and seq == st["lastSeq"]:
st["loss"] += 1
logger.debug(
"(%s) *PacketControl* Duplicate sequence number %s, disgarding. Stream ID:, %s TGID: %s, LOSS: %.2f%%",
system_name,
seq,
int_id(stream_id),
int_id(dst_id),
((st["loss"] / st["packets"]) * 100) if st.get("packets") else 0.0,
)
return False
if seq and st["lastSeq"] and (seq != 1) and (seq < st["lastSeq"]):
st["loss"] += 1
logger.debug(
"(%s) *PacketControl* Out of order packet - last SEQ: %s, this SEQ: %s, disgarding. Stream ID:, %s TGID: %s, LOSS: %.2f%%",
system_name,
st["lastSeq"],
seq,
int_id(stream_id),
int_id(dst_id),
((st["loss"] / st["packets"]) * 100) if st.get("packets") else 0.0,
)
return False
if seq > 0 and _pkt_crc in st["crcs"]:
st["loss"] += 1
logger.debug(
"(%s) *PacketControl* DMR packet payload with hash: %s seen before in this stream, disgarding. Stream ID:, %s TGID: %s: SEQ:%s PACKETS: %s, LOSS: %.2f%% ",
system_name,
_pkt_crc,
int_id(stream_id),
int_id(dst_id),
seq,
st["packets"],
((st["loss"] / st["packets"]) * 100) if st.get("packets") else 0.0,
)
return False
if seq and st["lastSeq"] and seq > (st["lastSeq"] + 1):
st["loss"] += 1
logger.debug(
"(%s) *PacketControl* Missed packet(s) - last SEQ: %s, this SEQ: %s. Stream ID:, %s TGID: %s , LOSS: %.2f%%",
system_name,
st["lastSeq"],
seq,
int_id(stream_id),
int_id(dst_id),
((st["loss"] / st["packets"]) * 100) if st.get("packets") else 0.0,
)
st["lastSeq"] = seq
st["lastData"] = data
# 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_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)
)
)
st["_monitor_canonical_rx"] = True
st = status[stream_id]
st["crcs"].add(_pkt_crc)
st["LAST"] = pkt_time
if self._config.get("GLOBAL", {}).get("GEN_STAT_BRIDGES"):
_di = int_id(dst_id)
_bk = str(_di)
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.ensure_stat_relay(dst_id)
self.apply_static_tg_to_bridge(_di)
return True
def _send_data_to_obp(
self,
source_system: str,
target: str,
data: bytes,
dmrpkt: bytes,
pkt_time: float,
stream_id: bytes,
dst_id: bytes,
peer_id: bytes,
rf_src: bytes,
bits: int,
slot: int,
hops: bytes = b"",
ber: bytes = b"\x00",
rssi: bytes = b"\x00",
source_server: bytes = b"\x00\x00\x00\x00",
source_rptr: bytes = b"\x00\x00\x00\x00",
) -> None:
"""Legacy sendDataToOBP: forward a unit-data packet to an OPENBRIDGE target."""
systems_cfg = self._config.get("SYSTEMS", {})
_target_system = systems_cfg.get(target, {})
if _target_system.get("ENHANCED_OBP") and "_bcka" in _target_system and _target_system["_bcka"] < pkt_time - 60:
return
protocols = self._get_protocols() if self._get_protocols else {}
target_proto = protocols.get(target)
if not target_proto:
return
_target_status = getattr(target_proto, "STATUS", {})
if stream_id not in _target_status:
_target_status[stream_id] = {
"START": pkt_time,
"CONTENTION": False,
"RFS": rf_src,
"TGID": dst_id,
"RX_PEER": peer_id,
"packets": 0,
}
_target_status[stream_id]["LAST"] = pkt_time
_tmp_bits = bits ^ (1 << 7) if slot == 2 else bits
_tmp_data = b"".join([data[:15], _tmp_bits.to_bytes(1, "big"), data[16:20], dmrpkt])
try:
self._send_to_system(target, _tmp_data, _hops=hops, _ber=ber, _rssi=rssi, _source_server=source_server, _source_rptr=source_rptr)
except Exception as exc:
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_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,171 @@
# ADN DMR Peer Server - application routing peer downlink index
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Inject-only MASTER downlink: narrow peer fan-out by (slot, TGID).
Legacy ``send_peers`` scans every registered peer per packet. On inject-only
proxies with hundreds of hotspots, that is O(peers × pkt/s). This index
builds a candidate set from static OPTIONS and UA session state; each
candidate is still checked with :func:`peer_should_receive_group_voice`.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from ...domain import bytes_4, int_id
from .helpers import peer_single_exclusive_tgid
def invalidate_peer_options_cache(peer: dict[str, Any]) -> None:
"""Drop cached OPTIONS parse after RPTO."""
peer.pop("_CACHED_OPTIONS_STATIC", None)
def cached_peer_static_tgs(peer: dict[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
"""Memoize ``parse_peer_options_static`` per peer OPTIONS blob."""
opts = peer.get("OPTIONS")
key = opts if isinstance(opts, bytes) else b""
cached = peer.get("_CACHED_OPTIONS_STATIC")
if cached and cached[0] == key:
return cached[1], cached[2]
from adn_server.application.report.payloads import parse_peer_options_static
ts1, ts2 = parse_peer_options_static(opts)
t1, t2 = tuple(ts1), tuple(ts2)
peer["_CACHED_OPTIONS_STATIC"] = (key, t1, t2)
return t1, t2
def count_connected_peers(peers: dict[bytes, dict[str, Any]]) -> int:
return sum(1 for p in peers.values() if p.get("CONNECTION") == "YES")
@dataclass
class PeerDownlinkIndex:
"""Precomputed (slot, TGID) → peer candidates for connected hotspots."""
static_by_slot_tgid: dict[tuple[int, int], frozenset[bytes]] = field(default_factory=dict)
ua_by_slot_tgid: dict[tuple[int, int], frozenset[bytes]] = field(default_factory=dict)
connected: frozenset[bytes] = frozenset()
def candidates(self, slot: int, tgid: int, *, connected_count: int) -> frozenset[bytes]:
if connected_count == 1:
return self.connected
out: set[bytes] = set()
key = (int(slot), int(tgid))
out.update(self.static_by_slot_tgid.get(key, ()))
out.update(self.ua_by_slot_tgid.get(key, ()))
return frozenset(out)
def _add_index_entry(
index: dict[tuple[int, int], set[bytes]],
slot: int,
tgid: int,
peer_id: bytes,
) -> None:
try:
tgid_i = int(tgid)
except (TypeError, ValueError):
return
if tgid_i <= 0:
return
index.setdefault((int(slot), tgid_i), set()).add(peer_id)
def build_peer_downlink_index(
peers: dict[bytes, dict[str, Any]],
sys_cfg: dict[str, Any],
*,
now: float | None = None,
) -> PeerDownlinkIndex:
"""Rebuild candidate map from all connected peers (call when index is dirty)."""
pkt_time = time.time() if now is None else now
static_map: dict[tuple[int, int], set[bytes]] = {}
ua_map: dict[tuple[int, int], set[bytes]] = {}
connected: set[bytes] = set()
for peer_id, peer in peers.items():
if peer.get("CONNECTION") != "YES":
continue
connected.add(peer_id)
ts1, ts2 = cached_peer_static_tgs(peer)
for tg in ts1:
_add_index_entry(static_map, 1, tg, peer_id)
for tg in ts2:
_add_index_entry(static_map, 2, tg, peer_id)
for slot in (1, 2):
locked = peer_single_exclusive_tgid(
peer, slot, sys_cfg, peer_id=peer_id, now=pkt_time,
)
if locked is not None:
_add_index_entry(ua_map, slot, locked, peer_id)
sessions = peer.get("_UA_SESSION")
if isinstance(sessions, dict):
for slot, entry in sessions.items():
if not isinstance(entry, dict):
continue
if pkt_time >= float(entry.get("expires", 0)):
continue
locked = entry.get("tgid")
if locked is not None:
_add_index_entry(ua_map, int(slot), locked, peer_id)
multi_store = sys_cfg.get("_PEER_UA_MULTI_TGS")
if isinstance(multi_store, dict):
for pk, per_slot in multi_store.items():
if not isinstance(per_slot, dict):
continue
peer_id = pk if isinstance(pk, bytes) else bytes_4(int_id(pk))
if peer_id not in connected:
continue
for slot, tg_set in per_slot.items():
if not isinstance(tg_set, set):
continue
for tgid in tg_set:
_add_index_entry(ua_map, int(slot), tgid, peer_id)
ua_sessions = sys_cfg.get("_PEER_UA_SESSIONS")
if isinstance(ua_sessions, dict):
for pk, per_slot in ua_sessions.items():
if not isinstance(per_slot, dict):
continue
peer_id = pk if isinstance(pk, bytes) else bytes_4(int_id(pk))
if peer_id not in connected:
continue
for slot, entry in per_slot.items():
if not isinstance(entry, dict):
continue
if pkt_time >= float(entry.get("expires", 0)):
continue
locked = entry.get("tgid")
if locked is not None:
_add_index_entry(ua_map, int(slot), locked, peer_id)
return PeerDownlinkIndex(
static_by_slot_tgid={k: frozenset(v) for k, v in static_map.items()},
ua_by_slot_tgid={k: frozenset(v) for k, v in ua_map.items()},
connected=frozenset(connected),
)

@ -0,0 +1,71 @@
# ADN DMR Peer Server - subscription store authority
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# 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
###############################################################################
"""``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."""

@ -0,0 +1,817 @@
# ADN DMR Peer Server - bridge table management
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# 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
###############################################################################
"""BRIDGES table lifecycle: create, static TG, OPTIONS refresh (no Twisted)."""
from __future__ import annotations
import logging
import re
import time
from typing import Any
from ...domain import bytes_3, bytes_4, int_id
logger = logging.getLogger(__name__)
class SubscriptionTableMixin:
"""ensure_dynamic_relay, stat/static TG, OPTIONS refresh (RPTO / startup / dmrd)."""
def ensure_dynamic_relay(
self,
_tgid: bytes | int,
_sourcesystem: str,
_slot: int,
_tmout: float,
) -> None:
"""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.subscription_table_ops import ensure_dynamic_relay_store
ensure_dynamic_relay_store(
self._subscription_store,
tgid_int,
_sourcesystem,
_slot,
float(_tmout),
self._config.get("SYSTEMS", {}),
time.time(),
)
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."""
_tgid_s = str(int_id(_tgid) if not isinstance(_tgid, int) else _tgid)
_bridge = "#" + _tgid_s
_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.subscription_table_ops import make_single_reflector_store
make_single_reflector_store(
self._subscription_store,
int(_tgid_s),
float(_tmout),
_sourcesystem,
self._config.get("SYSTEMS", {}),
time.time(),
)
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.subscription_table_ops import make_default_reflector_store
make_default_reflector_store(
self._subscription_store,
reflector,
float(_tmout),
system,
self._config.get("SYSTEMS", {}),
time.time(),
)
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.subscription_table_ops import make_static_tg_store
single_mode = bool(
self._config.get("SYSTEMS", {}).get(system, {}).get("SINGLE_MODE", False)
)
make_static_tg_store(
self._subscription_store,
tg,
ts,
float(_tmout),
system,
self._config.get("SYSTEMS", {}),
time.time(),
single_mode=single_mode,
)
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.subscription_table_ops import reset_static_tg_store
reset_static_tg_store(
self._subscription_store,
tg,
ts,
float(_tmout),
system,
time.time(),
)
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.subscription_table_ops import reset_all_reflector_system_store
reset_all_reflector_system_store(
self._subscription_store,
float(_tmout),
system,
time.time(),
)
def remove_bridge_system(self, system: str) -> None:
"""Deactivate all legs for one system (legacy remove_bridge_system)."""
from ..subscription.subscription_reset_ops import deactivate_system_legs_store
deactivate_system_legs_store(self._subscription_store, system, time.time())
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.subscription_table_ops import ensure_stat_relay_store
ensure_stat_relay_store(
self._subscription_store,
_tgid,
self._config.get("SYSTEMS", {}),
time.time(),
)
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.subscription_table_ops import readd_system_after_ua_timer_change_store
readd_system_after_ua_timer_change_store(
self._subscription_store,
system,
float(_tmout),
time.time(),
)
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")
for system, sys_cfg in self._config.get("SYSTEMS", {}).items():
if sys_cfg.get("MODE") != "MASTER":
continue
default_ref = int(sys_cfg.get("DEFAULT_REFLECTOR", 0))
if default_ref not in prohibited_tgs:
self.make_default_reflector(default_ref, float(sys_cfg.get("DEFAULT_UA_TIMER", 10)), system)
logger.debug("(ROUTER) setting static TGs")
for system, sys_cfg in self._config.get("SYSTEMS", {}).items():
if sys_cfg.get("MODE") != "MASTER":
continue
tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10))
ts1_raw = sys_cfg.get("TS1_STATIC") or ""
ts2_raw = sys_cfg.get("TS2_STATIC") or ""
ts1 = [s.strip() for s in ts1_raw.split(",") if s.strip()]
ts2 = [s.strip() for s in ts2_raw.split(",") if s.strip()]
for tg_s in ts1:
try:
tg = int(tg_s)
except ValueError:
continue
if tg in prohibited_tgs:
continue
self.make_static_tg(tg, 1, tmout, system)
for tg_s in ts2:
try:
tg = int(tg_s)
except ValueError:
continue
if tg in prohibited_tgs:
continue
self.make_static_tg(tg, 2, tmout, system)
for system, sys_cfg in self._config.get("SYSTEMS", {}).items():
if sys_cfg.get("MODE") != "MASTER":
continue
if not sys_cfg.get("ENABLED", True):
continue
self.options_config_for_system(system)
self._sync_subscription_store()
def _first_connected_peer_options(self, system_name: str) -> bytes | str | None:
"""First connected peer OPTIONS (legacy options_config peer scan without 26s loop)."""
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
peers = getattr(proto, "_peers", {}) if proto is not None else {}
if not isinstance(peers, dict):
return None
for peer in peers.values():
if isinstance(peer, dict) and peer.get("CONNECTION") == "YES" and peer.get("OPTIONS"):
return peer["OPTIONS"]
return None
def _options_key_allows(self, system_name: str, parsed: dict[str, Any]) -> bool:
"""Legacy OPTIONS KEY gate (_opt_key on MASTER)."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
if sys_cfg.get("_opt_key"):
if "KEY" not in parsed:
logger.debug(
"(OPTIONS) %s, options key set but no key in options string, skipping",
system_name,
)
return False
if sys_cfg["_opt_key"] != parsed.get("KEY"):
logger.debug(
"(OPTIONS) %s, options key set but key sent does not match, skipping",
system_name,
)
return False
elif parsed.get("KEY"):
sys_cfg["_opt_key"] = parsed["KEY"]
logger.debug(
"(OPTIONS) %s, _opt_key not set but key sent. Setting to sent key",
system_name,
)
else:
sys_cfg["_opt_key"] = False
return True
def _maybe_update_reflector_from_options(
self, system_name: str, parsed: dict[str, Any]
) -> None:
"""Apply DEFAULT_REFLECTOR / DIAL changes from parsed OPTIONS (legacy options_config)."""
prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999)
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
raw_timer = parsed.get("DEFAULT_UA_TIMER", sys_cfg.get("DEFAULT_UA_TIMER", 10))
try:
timer_int = int(raw_timer)
tmout = float(35791394 if timer_int == 0 else timer_int)
except (TypeError, ValueError):
tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10))
new_ref = int(parsed.get("DEFAULT_REFLECTOR", 0) or 0)
cur_ref = int(sys_cfg.get("DEFAULT_REFLECTOR", 0) or 0)
if new_ref == cur_ref:
return
if new_ref > 0:
logger.debug("(OPTIONS) %s default reflector changed, updating", system_name)
self.reset_all_reflector_system(tmout, system_name)
self.make_default_reflector(new_ref, tmout, system_name)
elif new_ref in prohibited_tgs and not bool(new_ref):
logger.debug("(OPTIONS) %s default reflector is prohibited, ignoring change", system_name)
else:
logger.debug("(OPTIONS) %s default reflector disabled, updating", system_name)
self.reset_all_reflector_system(tmout, system_name)
def _parse_options_string(self, opt_str: bytes | str) -> dict[str, Any] | None:
"""Parse hotspot OPTIONS / RPTO payload into a normalized options dict."""
try:
if isinstance(opt_str, bytes):
opt_str = opt_str.decode("utf8", errors="replace")
opt_str = opt_str.rstrip("\x00").encode("ascii", "ignore").decode()
opt_str = re.sub(r"['\"]", "", opt_str)
_options: dict[str, Any] = {}
for x in opt_str.split(";"):
try:
k, v = x.split("=", 1)
_options[k.strip()] = v.strip()
except ValueError:
continue
for old_k, new_k in [
("DIAL", "DEFAULT_REFLECTOR"),
("TIMER", "DEFAULT_UA_TIMER"),
("TS1", "TS1_STATIC"),
("TS2", "TS2_STATIC"),
("IDENTTG", "OVERRIDE_IDENT_TG"),
("VOICETG", "OVERRIDE_IDENT_TG"),
("IDENT", "VOICE"),
]:
if old_k in _options:
_options[new_k] = _options.pop(old_k)
for old_k, new_k in [("StartRef", "DEFAULT_REFLECTOR"), ("RelinkTime", "DEFAULT_UA_TIMER")]:
if old_k in _options:
_options[new_k] = _options.pop(old_k)
if "TS1_1" in _options:
parts = [_options.pop("TS1_1", "")]
for i in range(2, 10):
p = _options.pop(f"TS1_{i}", None)
if p is not None:
parts.append(p)
_options["TS1_STATIC"] = ",".join(parts)
if "TS2_1" in _options:
parts = [_options.pop("TS2_1", "")]
for i in range(2, 10):
p = _options.pop(f"TS2_{i}", None)
if p is not None:
parts.append(p)
_options["TS2_STATIC"] = ",".join(parts)
return _options
except Exception:
return None
def _yaml_default_ua_timer(self, sys_cfg: dict[str, Any]) -> float:
tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10))
return 35791394.0 if tmout <= 0 else tmout
def _peer_ua_timer_minutes(self, parsed: dict[str, Any], sys_cfg: dict[str, Any]) -> float:
try:
value = int(parsed.get("DEFAULT_UA_TIMER", sys_cfg.get("DEFAULT_UA_TIMER", 10)))
except (TypeError, ValueError):
return self._yaml_default_ua_timer(sys_cfg)
if value == 0:
return 35791394.0
return float(value)
def _ua_timer_minutes_for_peer(self, system_name: str, peer_id: bytes) -> float:
"""UA bridge timeout (minutes): transmitting peer OPTIONS TIMER, else YAML default."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
peers = getattr(proto, "_peers", {}) if proto is not None else {}
if isinstance(peers, dict):
peer_int = int_id(peer_id)
for pk, peer in peers.items():
if not isinstance(peer, dict) or peer.get("CONNECTION") != "YES":
continue
try:
pk_int = int_id(pk if isinstance(pk, bytes) else bytes_4(int(pk)))
except (TypeError, ValueError):
continue
if pk_int != peer_int:
continue
opt = peer.get("OPTIONS")
if opt is None:
break
parsed = self._parse_options_string(opt)
if parsed:
return self._peer_ua_timer_minutes(parsed, sys_cfg)
break
return self._yaml_default_ua_timer(sys_cfg)
def _connected_peer_options_strings(self, system_name: str) -> list[bytes | str]:
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
peers = getattr(proto, "_peers", {}) if proto is not None else {}
options: list[bytes | str] = []
if not isinstance(peers, dict):
return options
for peer in peers.values():
if not isinstance(peer, dict) or peer.get("CONNECTION") != "YES":
continue
opt = peer.get("OPTIONS")
if opt is not None:
options.append(opt)
return options
def _static_tg_timer_maps_for_master(
self,
system_name: str,
*,
peer_options: bytes | str | None = None,
) -> tuple[dict[int, float], dict[int, float]]:
"""Per-TG TIMER (minutes) from each peer OPTIONS; never merged with max() across peers."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
ts1_timers: dict[int, float] = {}
ts2_timers: dict[int, float] = {}
if peer_options is not None:
parsed = self._parse_options_static_tgs(peer_options, sys_cfg)
if parsed is not None:
peer_tmout, ts1_list, ts2_list = parsed
for tg in ts1_list:
ts1_timers[tg] = peer_tmout
for tg in ts2_list:
ts2_timers[tg] = peer_tmout
return ts1_timers, ts2_timers
for opt in self._connected_peer_options_strings(system_name):
parsed = self._parse_options_static_tgs(opt, sys_cfg)
if parsed is None:
continue
peer_tmout, ts1_list, ts2_list = parsed
for tg in ts1_list:
if tg not in ts1_timers:
ts1_timers[tg] = peer_tmout
for tg in ts2_list:
if tg not in ts2_timers:
ts2_timers[tg] = peer_tmout
return ts1_timers, ts2_timers
def _options_static_apply_fingerprint(self, system_name: str) -> str:
"""Fingerprint for duplicate RPTO short-circuit (includes runtime SINGLE_MODE)."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
merged = self._merged_static_tg_lists_for_master(system_name)
if merged is not None:
ts1_nums, ts2_nums = merged
new_ts1 = ",".join(str(x) for x in ts1_nums)
new_ts2 = ",".join(str(x) for x in ts2_nums)
else:
new_ts1 = str(sys_cfg.get("TS1_STATIC") or "").strip()
new_ts2 = str(sys_cfg.get("TS2_STATIC") or "").strip()
return (
f"{new_ts1}|{new_ts2}|"
f"{int(bool(sys_cfg.get('SINGLE_MODE', False)))}"
)
def _options_static_lists_valid(self, opt_str: bytes | str) -> bool:
"""Legacy: malformed TS1/TS2 in OPTIONS aborts static bridge refresh."""
parsed = self._parse_options_string(opt_str)
if not parsed:
return False
for key in ("TS1_STATIC", "TS2_STATIC"):
val = str(parsed.get(key) or "").strip()
if val and re.search(r"[^\d,]", val):
return False
return True
def _apply_master_runtime_options(self, system_name: str, _options: dict[str, Any]) -> None:
"""Apply SINGLE/TIMER/VOICE/LANG from peer OPTIONS over YAML defaults (legacy options_config).
Runtime YAML/OPTIONS flags only; bridge legs are updated via the subscription store.
"""
systems_cfg = self._config.get("SYSTEMS", {})
sys_cfg = systems_cfg.get(system_name, {})
if sys_cfg.get("MODE") != "MASTER":
return
if "VOICE" in _options and bool(_options["VOICE"]) and (
sys_cfg.get("VOICE_IDENT") != bool(int(_options["VOICE"]))
):
sys_cfg["VOICE_IDENT"] = bool(int(_options["VOICE"]))
logger.debug("(OPTIONS) %s - Setting voice ident to %s", system_name, sys_cfg["VOICE_IDENT"])
if "OVERRIDE_IDENT_TG" in _options and _options["OVERRIDE_IDENT_TG"] and (
sys_cfg.get("OVERRIDE_IDENT_TG") != int(_options["OVERRIDE_IDENT_TG"])
):
sys_cfg["OVERRIDE_IDENT_TG"] = int(_options["OVERRIDE_IDENT_TG"])
logger.debug(
"(OPTIONS) %s - Setting OVERRIDE_IDENT_TG to %s",
system_name,
sys_cfg["OVERRIDE_IDENT_TG"],
)
if "LANG" in _options and _options["LANG"] != sys_cfg.get("ANNOUNCEMENT_LANGUAGE"):
sys_cfg["ANNOUNCEMENT_LANGUAGE"] = _options["LANG"]
logger.debug("(OPTIONS) %s - Setting voice language to %s", system_name, sys_cfg["ANNOUNCEMENT_LANGUAGE"])
if "SINGLE" in _options and (sys_cfg.get("SINGLE_MODE") != bool(int(_options["SINGLE"]))):
sys_cfg["SINGLE_MODE"] = bool(int(_options["SINGLE"]))
logger.info("(OPTIONS) %s - Setting SINGLE_MODE to %s", system_name, sys_cfg["SINGLE_MODE"])
# TIMER is per-peer: applied via make_static_tg for that peer's static TGs only.
def options_config_for_system(
self,
system_name: str,
peer_options: bytes | str | None = None,
) -> None:
"""Update runtime flags and static TG bridges (RPTO or voice path).
``peer_options`` from RPTO overrides YAML (inject-only proxy: OPTIONS live on each peer).
"""
prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999)
systems_cfg = self._config.get("SYSTEMS", {})
sys_cfg = systems_cfg.get(system_name, {})
if sys_cfg.get("MODE") != "MASTER":
return
try:
runtime_source: bytes | str | None = peer_options
if runtime_source is None and "OPTIONS" in sys_cfg:
runtime_source = sys_cfg["OPTIONS"]
if runtime_source is None:
runtime_source = self._first_connected_peer_options(system_name)
if runtime_source is not None:
parsed_runtime = self._parse_options_string(runtime_source)
if parsed_runtime and self._options_key_allows(system_name, parsed_runtime):
self._apply_master_runtime_options(system_name, parsed_runtime)
self._maybe_update_reflector_from_options(system_name, parsed_runtime)
source_opt: bytes | str | None = peer_options
if source_opt is None and "OPTIONS" in sys_cfg:
source_opt = sys_cfg["OPTIONS"]
if source_opt is not None and not self._options_static_lists_valid(source_opt):
self._sync_subscription_store()
return
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 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 if peer_options is not None else None,
)
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
if merged is None:
# Echo TGs (9990–9999) are excluded from merged lists but still need restore.
self._restore_prohibited_static_bridge_legs(system_name)
self._sync_subscription_store()
return
ts1_nums, ts2_nums = merged
yaml_tmout = self._yaml_default_ua_timer(sys_cfg)
if peer_options is not None:
ts1_timers, ts2_timers = self._static_tg_timer_maps_for_master(
system_name, peer_options=peer_options
)
else:
ts1_timers, ts2_timers = self._static_tg_timer_maps_for_master(system_name)
new_ts1 = ",".join(str(x) for x in ts1_nums)
new_ts2 = ",".join(str(x) for x in ts2_nums)
if re.search(r"[^\d,]", new_ts1) or re.search(r"[^\d,]", new_ts2):
return
# Legacy: reset TGs that were removed (bridge_master.py 1736-1767)
old_ts1 = str(sys_cfg.get("TS1_STATIC") or "").strip()
old_ts2 = str(sys_cfg.get("TS2_STATIC") or "").strip()
new_ts1_set: set[int] = set()
new_ts2_set: set[int] = set()
for x in new_ts1.split(","):
if x.strip():
try:
new_ts1_set.add(int(x))
except ValueError:
pass
for x in new_ts2.split(","):
if x.strip():
try:
t = int(x)
if t != 0 and t < 16777215:
new_ts2_set.add(t)
except ValueError:
pass
for tg_s in old_ts1.split(","):
if not tg_s.strip():
continue
try:
tg = int(tg_s)
if tg not in new_ts1_set:
self.reset_static_tg(tg, 1, ts1_timers.get(tg, yaml_tmout), system_name)
except ValueError:
pass
for tg_s in old_ts2.split(","):
if not tg_s.strip():
continue
try:
tg = int(tg_s)
if tg not in new_ts2_set and tg != 0 and tg < 16777215:
self.reset_static_tg(tg, 2, ts2_timers.get(tg, yaml_tmout), system_name)
except ValueError:
pass
peer_ts1_set = set(ts1_timers) if peer_options is not None else None
peer_ts2_set = set(ts2_timers) if peer_options is not None else None
for tg in ts1_nums:
if tg in prohibited_tgs:
continue
if peer_ts1_set is not None and tg not in peer_ts1_set:
continue
self.make_static_tg(tg, 1, ts1_timers.get(tg, yaml_tmout), system_name)
for tg in ts2_nums:
if tg == 0 or tg >= 16777215 or tg in prohibited_tgs:
continue
if peer_ts2_set is not None and tg not in peer_ts2_set:
continue
self.make_static_tg(tg, 2, ts2_timers.get(tg, yaml_tmout), system_name)
systems_cfg[system_name]["TS1_STATIC"] = new_ts1
systems_cfg[system_name]["TS2_STATIC"] = new_ts2
systems_cfg[system_name]["_options_static_apply_fp"] = self._options_static_apply_fingerprint(
system_name
)
if new_ts1 or new_ts2:
logger.info("(OPTIONS) %s static TGs applied: TS1=%s TS2=%s", system_name, new_ts1 or "-", new_ts2 or "-")
self._restore_prohibited_static_bridge_legs(system_name)
self._sync_subscription_store()
except Exception as e:
logger.debug("(OPTIONS) options_config_for_system %s: %s", system_name, e)
def _static_tg_lists_from_runtime_cfg(self, sys_cfg: dict[str, Any]) -> tuple[list[int], list[int]] | None:
"""Build static TG lists from TS1_STATIC / TS2_STATIC (updated when peers send RPTO)."""
prohibited_tgs = (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999)
ts1_list: list[int] = []
ts2_list: list[int] = []
for tg_s in str(sys_cfg.get("TS1_STATIC") or "").split(","):
if not tg_s.strip():
continue
try:
tg = int(tg_s.strip())
except ValueError:
continue
if tg in prohibited_tgs:
continue
ts1_list.append(tg)
for tg_s in str(sys_cfg.get("TS2_STATIC") or "").split(","):
if not tg_s.strip():
continue
try:
tg = int(tg_s.strip())
except ValueError:
continue
if tg in prohibited_tgs:
continue
if tg == 0 or tg >= 16777215:
continue
ts2_list.append(tg)
if not ts1_list and not ts2_list:
return None
return (ts1_list, ts2_list)
def _parse_options_static_tgs(self, opt_str: str, sys_cfg: dict) -> tuple[float, list[int], list[int]] | None:
"""Parse OPTIONS string; return (tmout, ts1_tg_list, ts2_tg_list) or None. Used to apply static TGs to an existing bridge."""
try:
if isinstance(opt_str, bytes):
opt_str = opt_str.decode("utf8", errors="replace")
opt_str = opt_str.rstrip("\x00").encode("ascii", "ignore").decode()
opt_str = re.sub(r"['\"]", "", opt_str)
_options: dict[str, Any] = {}
for x in opt_str.split(";"):
try:
k, v = x.split("=", 1)
_options[k.strip()] = v.strip()
except ValueError:
continue
for old_k, new_k in [("DIAL", "DEFAULT_REFLECTOR"), ("TIMER", "DEFAULT_UA_TIMER"), ("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC")]:
if old_k in _options:
_options[new_k] = _options.pop(old_k)
if "TS1_1" in _options:
parts = [_options.pop("TS1_1", "")]
for i in range(2, 10):
p = _options.pop(f"TS1_{i}", None)
if p is not None:
parts.append(p)
_options["TS1_STATIC"] = ",".join(parts)
if "TS2_1" in _options:
parts = [_options.pop("TS2_1", "")]
for i in range(2, 10):
p = _options.pop(f"TS2_{i}", None)
if p is not None:
parts.append(p)
_options["TS2_STATIC"] = ",".join(parts)
_tmout = float(int(_options.get("DEFAULT_UA_TIMER", sys_cfg.get("DEFAULT_UA_TIMER", 10))))
if _tmout <= 0:
_tmout = 35791394
ts1_list: list[int] = []
ts2_list: list[int] = []
ts1_raw = str(_options.get("TS1_STATIC") or "").strip()
ts2_raw = str(_options.get("TS2_STATIC") or "").strip()
if ts1_raw and not re.search(r"[^\d,]", ts1_raw):
for tg_s in ts1_raw.split(","):
try:
tg1 = int(tg_s.strip())
if tg1 not in (0, 1, 2, 3, 4, 5, 9, 9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999):
ts1_list.append(tg1)
except ValueError:
pass
if ts2_raw and not re.search(r"[^\d,]", ts2_raw):
for tg_s in ts2_raw.split(","):
try:
tg2 = int(tg_s.strip())
if 0 < tg2 < 16777215 and tg2 not in (9990, 9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999):
ts2_list.append(tg2)
except ValueError:
pass
return (_tmout, ts1_list, ts2_list)
except Exception:
return None
def _merged_static_tg_lists_for_master(
self, system_name: str
) -> tuple[list[int], list[int]] | None:
"""Union static TG ids from runtime YAML and every connected peer RPTO (inject proxy)."""
sys_cfg = self._config.get("SYSTEMS", {}).get(system_name, {})
if sys_cfg.get("MODE") != "MASTER":
return None
ts1_set: set[int] = set()
ts2_set: set[int] = set()
runtime = self._static_tg_lists_from_runtime_cfg(sys_cfg)
if runtime is not None:
ts1_list, ts2_list = runtime
ts1_set.update(ts1_list)
ts2_set.update(ts2_list)
protocols = self._get_protocols() if self._get_protocols else {}
proto = protocols.get(system_name)
peers = getattr(proto, "_peers", {}) if proto is not None else {}
if isinstance(peers, dict):
for peer in peers.values():
if not isinstance(peer, dict) or peer.get("CONNECTION") != "YES":
continue
opt = peer.get("OPTIONS")
if opt is None:
continue
if isinstance(opt, bytes):
opt_str = opt.decode("utf8", errors="replace")
else:
opt_str = str(opt)
parsed = self._parse_options_static_tgs(opt_str, sys_cfg)
if parsed is None:
continue
_peer_tmout, ts1_list, ts2_list = parsed
ts1_set.update(ts1_list)
ts2_set.update(ts2_list)
if not ts1_set and not ts2_set and "OPTIONS" in sys_cfg:
parsed = self._parse_options_static_tgs(sys_cfg["OPTIONS"], sys_cfg)
if parsed is not None:
_peer_tmout, ts1_list, ts2_list = parsed
ts1_set.update(ts1_list)
ts2_set.update(ts2_list)
if not ts1_set and not ts2_set:
return None
return (sorted(ts1_set), sorted(ts2_set))
def apply_static_tg_to_bridge(self, tg_int: int) -> None:
"""When a bridge was just created from OBP, mark MASTER systems that have this TG in static TS1/TS2 (runtime lists or OPTIONS) ACTIVE so the first OBP traffic reaches them."""
systems_cfg = self._config.get("SYSTEMS", {})
for _system in systems_cfg:
if systems_cfg.get(_system, {}).get("MODE") != "MASTER":
continue
if not systems_cfg.get(_system, {}).get("ENABLED", True):
continue
parsed = self._merged_static_tg_lists_for_master(_system)
if not parsed:
continue
ts1_list, ts2_list = parsed
ts1_timers, ts2_timers = self._static_tg_timer_maps_for_master(_system)
yaml_tmout = self._yaml_default_ua_timer(systems_cfg.get(_system, {}))
if tg_int in ts1_list:
self.make_static_tg(tg_int, 1, ts1_timers.get(tg_int, yaml_tmout), _system)
if tg_int in ts2_list:
self.make_static_tg(tg_int, 2, ts2_timers.get(tg_int, yaml_tmout), _system)
def log_connected_systems_and_tgs(self) -> None:
"""Periodic debug: log each system, connection state, and static TGs (TS1/TS2). Only emits at DEBUG level."""
if not logger.isEnabledFor(logging.DEBUG):
return
systems_cfg = self._config.get("SYSTEMS", {})
protocols = self._get_protocols() if self._get_protocols else {}
lines: list[str] = ["(DEBUG) Systems and TGs:"]
for name in sorted(systems_cfg.keys()):
cfg = systems_cfg.get(name, {})
mode = cfg.get("MODE", "?")
enabled = cfg.get("ENABLED", True)
en = "enabled" if enabled else "disabled"
parts = [f" {name}: {mode} ({en})"]
if mode == "MASTER":
proto = protocols.get(name)
peers = getattr(proto, "_peers", {}) if proto else {}
connected = [p for p in peers.values() if p.get("CONNECTION") == "YES"]
parts.append(f"peers_connected={len(connected)}")
if connected:
def _cs(c):
v = c.get("CALLSIGN") or b""
if isinstance(v, bytes):
return v.decode("utf8", errors="replace").strip() or "?"
return str(v).strip() or "?"
parts.append(
"peers=[%s]"
% ", ".join("%s/%s" % (p.get("RADIO_ID", "?"), _cs(p)) for p in connected[:10])
)
if len(connected) > 10:
parts[-1] = parts[-1].rstrip("]") + f", +{len(connected) - 10} more]"
ts1 = (cfg.get("TS1_STATIC") or "").strip()
ts2 = (cfg.get("TS2_STATIC") or "").strip()
if ts1 or ts2:
parts.append("TS1=%s TS2=%s" % (ts1 or "-", ts2 or "-"))
elif mode == "PEER":
proto = protocols.get(name)
conn = getattr(proto, "_stats", {}).get("CONNECTION", "?") if proto else "?"
parts.append("connection=%s" % conn)
lines.append("".join(parts))
if len(lines) > 1:
logger.debug("\n".join(lines))

@ -0,0 +1,391 @@
# ADN DMR Peer Server - bridge timer loops
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# 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
###############################################################################
"""Legacy bridge timer / trimmer loops (no Twisted imports)."""
from __future__ import annotations
import logging
import time
from typing import Any
from ...domain import HBPF_SLT_VTERM, int_id
logger = logging.getLogger(__name__)
class RoutingTimerMixin:
"""rule_timer, stream_trimmer, bridge_reset, stat_trimmer, bridge_debug loops."""
def rule_timer_loop(self) -> None:
"""Run one iteration of rule_timer_loop (legacy 52s LoopingCall). Activate/deactivate by timeout."""
from ..subscription.rule_timer_ops import apply_rule_timer_store
apply_rule_timer_store(
self._subscription_store,
self._config.get("SYSTEMS", {}),
time.time(),
on_relay_deactivated=self._on_relay_deactivated,
)
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.subscription_debug_ops import apply_subscription_debug_store
apply_subscription_debug_store(
self._subscription_store,
self._config.get("SYSTEMS", {}),
time.time(),
)
def apply_in_band_signalling(
self, system_name: str, slot: int, dst_id: bytes, pkt_time: float
) -> None:
"""Legacy in-band signalling on voice terminator (bridge_master.py ~3447-3549).
Reflector bridges (#xxx) are ONLY processed when dst TG is 9 (legacy ~3455).
De-activation distinguishes SINGLE_MODE True/False (legacy ~3484-3548).
"""
from ..subscription.in_band_signalling_ops import apply_in_band_signalling_store
apply_in_band_signalling_store(
self._subscription_store,
system_name,
slot,
dst_id,
pkt_time,
self._config.get("SYSTEMS", {}),
)
self._send_routing_table_snapshot(incremental=True)
def _obp_emit_end_tx_forward_leg(
self,
tgt_name: str,
stream_id: bytes,
tst: dict[str, Any],
now: float,
) -> 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_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:
return False
if tst.get("_end_tx_sent"):
# Already cleared this leg's monitor TX chip; do not re-emit on repeated BCSQ.
return False
if not bool(self._config.get("REPORTS", {}).get("REPORT", True)):
return False
rfs = tst.get("RFS", b"\x00\x00\x00")
peer = tst.get("RX_PEER", b"\x00\x00\x00\x00")
tgid_b = tst.get("TGID", b"\x00\x00\x00")
start = tst.get("START", now)
duration = max(0.0, now - start)
if not self._send_routing_event(
"GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format(
tgt_name,
int_id(stream_id),
int_id(peer),
int_id(rfs),
1,
int_id(tgid_b),
duration,
)
):
return False
tst["_end_tx_sent"] = True
return True
def _obp_emit_end_tx_for_forward_legs(self, stream_id: bytes, source_system: str, now: float) -> None:
"""Emit GROUP VOICE,END,TX for every OBP that still holds this stream as a to_target forward leg.
On idle timeout the trimmer sends END,RX for the source only. VTERM may never arrive for
forwarded legs, so the monitor would otherwise keep stale TX chips on destination rows.
Forward legs are identified by STATUS[stream_id] containing H_LC (see to_target OPENBRIDGE).
"""
protocols = self._get_protocols() if self._get_protocols else {}
systems_cfg = self._config.get("SYSTEMS", {})
for tgt_name, tgt_proto in (protocols or {}).items():
if tgt_name == source_system:
continue
if systems_cfg.get(tgt_name, {}).get("MODE") != "OPENBRIDGE":
continue
tstatus = getattr(tgt_proto, "STATUS", None)
if not tstatus or stream_id not in tstatus:
continue
tst = tstatus[stream_id]
# Emit the monitor END,TX chip-clear but DO NOT pop STATUS: legacy keeps the
# forward-leg entry until the stream trimmer removes it by age (180s). Popping
# mid-stream caused the source/forward-leg entry to vanish and re-CALL-START churn.
self._obp_emit_end_tx_forward_leg(tgt_name, stream_id, tst, now)
def on_obp_bcsq_received(self, system_name: str, tgid: bytes, stream_id: bytes) -> None:
"""After valid BCSQ on this OBP leg: emit the monitor END,TX chip-clear only.
Legacy parity (hblink.py ~629-639): BCSQ only records CONFIG['_bcsq'][tgid]=stream_id
(done inline in udp_hbp) and routerOBP.to_target then *skips* the quenched target for
that stream (see _obp_target_bcsq_quenches_stream). It never destroys stream state.
We additionally emit a one-shot END,TX so the monitor clears stale TX chips, but we do
NOT pop STATUS[stream_id]: popping mid-call made the same stream re-CALL-START over and
over (visible as BCSQ-storm churn), which flapped loop-control and broke OBP->HBP audio.
The forward-leg entry is removed by the stream trimmer on idle (legacy behaviour).
"""
protocols = self._get_protocols() if self._get_protocols else {}
tgt_proto = protocols.get(system_name)
if not tgt_proto:
return
if self._config.get("SYSTEMS", {}).get(system_name, {}).get("MODE") != "OPENBRIDGE":
return
tstatus = getattr(tgt_proto, "STATUS", None)
if not tstatus or stream_id not in tstatus:
return
tst = tstatus[stream_id]
if not isinstance(tst, dict) or "H_LC" not in tst:
return
if tst.get("TGID", b"\x00\x00\x00") != tgid:
return
self._obp_emit_end_tx_forward_leg(system_name, stream_id, tst, time.time())
def flush_monitor_events_for_system(self, system_name: str, protocol: Any) -> None:
if not self._config.get("REPORTS", {}).get("REPORT", True):
return
status = getattr(protocol, "STATUS", None)
if not isinstance(status, dict):
return
mode = self._config.get("SYSTEMS", {}).get(system_name, {}).get("MODE")
now = time.time()
if mode == "OPENBRIDGE":
for stream_id, st in list(status.items()):
if not isinstance(stream_id, (bytes, bytearray)) or not isinstance(st, dict):
continue
trx = "TX" if "H_LC" in st else "RX"
start = st.get("START", now)
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")),
int_id(st.get("RFS", b"\x00\x00\x00")), 1,
int_id(st.get("TGID", b"\x00\x00\x00")),
max(0.0, now - start),
)
)
if trx == "RX":
self._obp_emit_end_tx_for_forward_legs(stream_id, system_name, now)
return
for slot in (1, 2):
slot_st = status.get(slot)
if not isinstance(slot_st, dict):
continue
for trx, sid_key, peer_key, rfs_key, tgid_key, start_key, type_key in (
("RX", "RX_STREAM_ID", "RX_PEER", "RX_RFS", "RX_TGID", "RX_START", "RX_TYPE"),
("TX", "TX_STREAM_ID", "TX_PEER", "TX_RFS", "TX_TGID", "TX_START", "TX_TYPE"),
):
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_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")),
int_id(slot_st.get(rfs_key, b"\x00\x00\x00")), slot,
int_id(slot_st.get(tgid_key, b"\x00\x00\x00")),
max(0.0, now - slot_st.get(start_key, now)),
)
)
def stream_trimmer_loop(self) -> None:
"""Trim old stream state (legacy stream_trimmer_loop, 5s). RX/TX timeout per system/slot; OBP streams (legacy bridge.py 181-240)."""
logger.debug("(ROUTER) Trimming inactive stream IDs from system lists")
protocols = self._get_protocols() if self._get_protocols else {}
systems_cfg = self._config.get("SYSTEMS", {})
now = time.time()
for system_name, protocol in protocols.items():
if not getattr(protocol, "STATUS", None):
continue
# OBP: legacy bridge_master.stream_trimmer_loop:631-703 — two-stage lifecycle:
# Stage 1 (5s idle, no _to, no _fin): set _to=True, emit END,RX, continue.
# Stage 2 (180s idle): remove stream entry.
#
# Legacy parity: routerOBP.STATUS is a *flat* dict keyed only by stream_id
# (bridge_master.py:1911). The loop iterates `for stream_id in systems[s].STATUS:`,
# which automatically catches everything seeded by to_target HBP->OBP,
# sendDataToOBP, pvt_call_received and the OBP source path itself. We do
# not keep a parallel dict -- that was a divergence that produced leaks.
if systems_cfg.get(system_name, {}).get("MODE") == "OPENBRIDGE":
obp_status = getattr(protocol, "STATUS", None)
if isinstance(obp_status, dict) and obp_status:
to_remove: list[bytes] = []
for stream_id, st in list(obp_status.items()):
if not isinstance(st, dict):
continue
last = st.get("LAST", 0)
# Stage 2: finished streams older than 180s → remove
if st.get("_fin") and last < now - 180:
to_remove.append(stream_id)
continue
# Stage 2: timed-out streams older than 180s → remove
if st.get("_to") and last < now - 180:
to_remove.append(stream_id)
continue
# Stage 1: 5s idle, not yet timed out → mark _to, emit END
if "_to" not in st and "_fin" not in st and last < now - 5:
rfs = st.get("RFS", b"\x00\x00\x00")
peer = st.get("RX_PEER", b"\x00\x00\x00\x00")
tgid = st.get("TGID", b"\x00\x00\x00")
start = st.get("START", now)
duration = max(0.0, last - start)
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
)
)
st["_to"] = True
# Legacy trimmer emits END,RX here only; forward legs waited ~180s.
# END,TX now (END_TX_FORWARD): same event as legacy VTERM path (~2039).
self._obp_emit_end_tx_for_forward_legs(stream_id, system_name, now)
continue
for stream_id in to_remove:
_syscfg = systems_cfg.get(system_name, {})
_bmap = _syscfg.get("_bcsq")
if isinstance(_bmap, dict):
for _tgid_k, _sid in list(_bmap.items()):
if _sid == stream_id:
_bmap.pop(_tgid_k, None)
self._obp_emit_end_tx_for_forward_legs(stream_id, system_name, now)
obp_status.pop(stream_id, None)
continue
for slot in (1, 2):
_slot = protocol.STATUS.get(slot)
if not _slot:
continue
if _slot.get("RX_TYPE") != HBPF_SLT_VTERM and _slot.get("RX_TIME", 0) < now - 5:
_slot["RX_TYPE"] = HBPF_SLT_VTERM
logger.info(
"(%s) *TIME OUT* RX STREAM ID: %s SUB: %s TGID %s, TS %s, Duration: %.2f",
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_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"")),
_slot.get("RX_TIME", 0) - _slot.get("RX_START", 0),
)
)
if _slot.get("RX_TIME", 0) < now - 60:
_slot["RX_STREAM_ID"] = b"\x00"
if _slot.get("TX_TYPE") != HBPF_SLT_VTERM and _slot.get("TX_TIME", 0) < now - 5:
_slot["TX_TYPE"] = HBPF_SLT_VTERM
logger.debug(
"(%s) *TIME OUT* TX STREAM ID: %s SUB: %s TGID %s, TS %s, Duration: %.2f",
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_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"")),
_slot.get("TX_TIME", 0) - _slot.get("TX_START", 0),
)
)
# -- Intentional divergence from legacy --
# Legacy bridge_master.py:602 only iterates `range(1,3)` in the HBP branch,
# so any STATUS[stream_id] entry seeded by voice_use_cases (announcements
# via sys_obj.STATUS[stream_id], cf. bridge_master.py:1156/1626) or by
# send_voice_packet against HBP MASTER targets is never freed in legacy.
# Here we defensively sweep any bytes-keyed entry with LAST > 180 s.
# This does not change the wire protocol; it only releases memory.
hbp_status = getattr(protocol, "STATUS", None)
if isinstance(hbp_status, dict):
for _k in list(hbp_status.keys()):
if isinstance(_k, (bytes, bytearray)):
_v = hbp_status.get(_k)
if isinstance(_v, dict) and _v.get("LAST", 0) < now - 180:
hbp_status.pop(_k, None)
if hasattr(protocol, "trim_dmra_streams"):
protocol.trim_dmra_streams()
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.subscription_reset_ops import (
deactivate_system_legs_store,
restore_prohibited_static_legs_store,
)
now = time.time()
for system_name in list(systems_cfg.keys()):
sys_cfg = systems_cfg.get(system_name, {})
if not sys_cfg.get("_reset"):
continue
logger.info("(BRIDGERESET) Bridge reset for %s - no peers", system_name)
deactivate_system_legs_store(self._subscription_store, system_name, now)
sys_cfg.pop("_opt_key", None)
sys_cfg.pop("_options_static_apply_fp", None)
restore_prohibited_static_legs_store(
self._subscription_store,
system_name,
sys_cfg,
self.acl_check,
now,
)
sys_cfg["_reset"] = False
sys_cfg["_resetlog"] = False
def _restore_prohibited_static_bridge_legs(self, system_name: str) -> None:
"""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.subscription_reset_ops import restore_prohibited_static_legs_store
restore_prohibited_static_legs_store(
self._subscription_store,
system_name,
sys_cfg,
self.acl_check,
time.time(),
)
def stat_trimmer_loop(self) -> None:
"""Trim STAT-only bridges with no ON/OFF in use (legacy statTrimmer, 303s)."""
logger.debug("(ROUTER) STAT trimmer loop started")
from ..subscription.stat_trimmer_ops import apply_stat_trimmer_store
apply_stat_trimmer_store(self._subscription_store)

@ -0,0 +1,146 @@
# ADN DMR Peer Server - application routing voice subscription
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
#
# Derived from ADN DMR Server / FreeDMR / HBlink. Original license:
###############################################################################
# Copyright (C) 2026 Joaquin Madrid Belando, EA5GVK <ea5gvk@gmail.com>
# 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
###############################################################################
"""Subscription router helpers for the voice hot path."""
from __future__ import annotations
import logging
from ..ports import SubscriptionStore
from ..subscription.ingress import build_voice_ingress
from ..subscription.router import SubscriptionRouter
from ...domain.voice_routing import ForwardLeg, VoiceIngress
logger = logging.getLogger(__name__)
class VoiceSubscriptionMixin:
"""Wire ``SubscriptionRouter`` into ``dmrd_received``."""
_subscription_store: SubscriptionStore
_subscription_router: SubscriptionRouter | None
def _subscription_router_instance(self) -> SubscriptionRouter:
router = getattr(self, "_subscription_router", None)
if router is None:
router = SubscriptionRouter(self._subscription_store)
self._subscription_router = router
return router
def _build_dmrd_voice_ingress(
self,
*,
system_name: str,
peer_id: bytes,
rf_src: bytes,
dst_id: bytes,
slot: int,
call_type: str,
stream_id: bytes,
source_is_obp: bool,
) -> VoiceIngress | None:
mode = "OPENBRIDGE" if source_is_obp else self._config.get("SYSTEMS", {}).get(system_name, {}).get("MODE", "")
return build_voice_ingress(
source_system=system_name,
system_mode=mode if isinstance(mode, str) else "",
peer_id=peer_id,
rf_src=rf_src,
dst_id=dst_id,
slot=slot,
call_type=call_type,
stream_id=stream_id,
)
def _voice_relay_tables_with_active_source(
self,
system_name: str,
bridge_match_slot: int,
dst_int: int,
) -> tuple[str, ...]:
tables, _ = self._voice_forward_plan(
system_name=system_name,
peer_id=b"",
rf_src=b"",
dst_id=b"\x00\x00\x00",
slot=bridge_match_slot,
call_type="group",
stream_id=b"",
source_is_obp=False,
bridge_match_slot=bridge_match_slot,
dst_int=dst_int,
ingress_required=False,
)
return tables
def _voice_forward_plan(
self,
*,
system_name: str,
peer_id: bytes,
rf_src: bytes,
dst_id: bytes,
slot: int,
call_type: str,
stream_id: bytes,
source_is_obp: bool,
bridge_match_slot: int,
dst_int: int,
ingress_required: bool = True,
) -> tuple[tuple[str, ...], tuple[ForwardLeg, ...]]:
"""Return bridge tables and resolved forward legs from the subscription store."""
router = self._subscription_router_instance()
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(
system_name=system_name,
peer_id=peer_id,
rf_src=rf_src,
dst_id=dst_id,
slot=slot,
call_type=call_type,
stream_id=stream_id,
source_is_obp=source_is_obp,
)
if ingress is None:
return tables, ()
return tables, router.resolve(ingress)

File diff suppressed because it is too large Load Diff

@ -0,0 +1,135 @@
# ADN DMR Peer Server - runtime context holder
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Runtime config container and atomic pointer swap on SIGHUP reload."""
from __future__ import annotations
import copy
from collections.abc import Iterator, MutableMapping
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class RuntimeContext:
"""Live server configuration and related metadata."""
config: dict[str, Any]
config_path: str = ""
subscription_store: Any = None
class RuntimeContextHolder:
"""Thread-local holder for the active RuntimeContext (swap on SIGHUP reload)."""
def __init__(self, initial: RuntimeContext) -> None:
self._ctx = initial
def get(self) -> RuntimeContext:
return self._ctx
def swap(self, ctx: RuntimeContext) -> RuntimeContext:
"""Replace the active context; returns the previous context."""
previous = self._ctx
self._ctx = ctx
return previous
class ConfigProxy(MutableMapping[str, Any]):
"""Dict-like view that always reads/writes the holder's current config dict."""
def __init__(self, holder: RuntimeContextHolder) -> None:
self._holder = holder
def _cfg(self) -> dict[str, Any]:
return self._holder.get().config
def __getitem__(self, key: str) -> Any:
return self._cfg()[key]
def __setitem__(self, key: str, value: Any) -> None:
self._cfg()[key] = value
def __delitem__(self, key: str) -> None:
del self._cfg()[key]
def __iter__(self) -> Iterator[str]:
return iter(self._cfg())
def __len__(self) -> int:
return len(self._cfg())
def __contains__(self, key: object) -> bool:
return key in self._cfg()
def get(self, key: str, default: Any = None) -> Any:
return self._cfg().get(key, default)
def setdefault(self, key: str, default: Any = None) -> Any:
return self._cfg().setdefault(key, default)
def pop(self, key: str, *default: Any) -> Any:
return self._cfg().pop(key, *default)
def keys(self) -> Any:
return self._cfg().keys()
def values(self) -> Any:
return self._cfg().values()
def items(self) -> Any:
return self._cfg().items()
def update(self, *args: Any, **kwargs: Any) -> None:
self._cfg().update(*args, **kwargs)
def prepare_reload_config(holder: RuntimeContextHolder) -> dict[str, Any]:
"""
Build a working copy for SIGHUP reload.
The live ``_SUB_MAP`` object is shared so subscriber state is not duplicated.
On failure the holder is unchanged; on success call ``swap`` with the merged dict.
"""
live = holder.get().config
sub_map = live.get("_SUB_MAP")
new_config = copy.deepcopy(live)
if sub_map is not None:
new_config["_SUB_MAP"] = sub_map
return new_config
def swap_runtime_config(
holder: RuntimeContextHolder,
new_config: dict[str, Any],
*,
config_path: str | None = None,
) -> RuntimeContext:
"""Atomically install a reloaded config dict."""
previous = holder.get()
path = config_path if config_path is not None else previous.config_path
return holder.swap(
RuntimeContext(
config=new_config,
config_path=path,
subscription_store=previous.subscription_store,
)
)

@ -0,0 +1,46 @@
# ADN DMR Peer Server - application subscription init
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Subscription application helpers."""
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__ = [
"RoutingTableLegacyView",
"SubscriptionRouter",
"active_system_slots_for_tg_in_store",
"build_voice_ingress",
"export_routing_table",
"replace_store_from_routing_table",
"store_has_table",
"subscription_to_legacy_row",
"subscriptions_from_routing_table",
"system_has_active_leg_in_store",
]

@ -0,0 +1,227 @@
# ADN DMR Peer Server - application subscription in band signalling ops
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Store-native in-band VTERM signalling."""
from __future__ import annotations
import logging
from typing import Any
from adn_server.application.ports import SubscriptionStore
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 ActivationPolicy, Subscription, SubscriptionPhase
logger = logging.getLogger(__name__)
def _tgid_matches(sub: Subscription, dst_id_b: bytes, dst_group: int) -> bool:
tgid_b = bytes_3(int(sub.target_tgid))
return tgid_b == dst_id_b or int(sub.target_tgid) == dst_group
def apply_in_band_signalling_store(
store: SubscriptionStore,
system_name: str,
slot: int,
dst_id: bytes,
pkt_time: float,
systems_cfg: dict[str, Any],
) -> None:
"""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)
for sub in store.snapshot():
if sub.system.value != system_name:
continue
relay_table_key = sub.table_key()
if relay_table_key[:1] == "#" and dst_group != 9:
continue
entry_ts = int(sub.channel.slot)
tgid_b = bytes_3(int(sub.target_tgid))
to_type = _legacy_to_type(sub)
active = sub.is_active()
timeout = sub.timeout_seconds
timeout_sec = float(timeout) if isinstance(timeout, (int, float)) else 0.0
changed = False
if slot == entry_ts and _tgid_matches(sub, dst_id_b, dst_group):
if (to_type == "ON" and active) or (to_type == "OFF" and not active):
if timeout_sec:
sub.state.timer_expires_at = pkt_time + timeout_sec
changed = True
logger.info(
"(%s) [1] Transmission match for Bridge: %s. Reset timeout to %s",
system_name,
relay_table_key,
sub.state.timer_expires_at,
)
on_list = sub.triggers.on
reset_list = sub.triggers.reset
if slot == entry_ts and (
dst_in_triggers(dst_id_b, dst_group, on_list)
or dst_in_triggers(dst_id_b, dst_group, reset_list)
):
if dst_in_triggers(dst_id_b, dst_group, on_list):
if not active:
sub.state.phase = SubscriptionPhase.ACTIVE
sub.state.timer_expires_at = pkt_time + (timeout_sec or 0.0)
changed = True
logger.info(
"(%s) [2] Bridge: %s, connection changed to state: %s",
system_name,
relay_table_key,
True,
)
if to_type == "OFF":
sub.state.timer_expires_at = pkt_time
logger.info(
"(%s) [3] Bridge: %s set to \"OFF\" with an on timer rule: timeout timer cancelled",
system_name,
relay_table_key,
)
if sub.is_active() and to_type == "ON" and timeout_sec:
sub.state.timer_expires_at = pkt_time + timeout_sec
changed = True
logger.info(
"(%s) [4] Bridge: %s, timeout timer reset to: %s",
system_name,
relay_table_key,
sub.state.timer_expires_at - pkt_time,
)
sys_cfg = systems_cfg.get(system_name, {})
is_single_mode = sys_cfg.get("MODE") == "MASTER" and sys_cfg.get("SINGLE_MODE", False)
off_list = sub.triggers.off
if is_single_mode:
if slot == entry_ts and (
dst_in_triggers(dst_id_b, dst_group, off_list)
or dst_in_triggers(dst_id_b, dst_group, reset_list)
or dst_id_b == bytes_3(4000)
or dst_id_b != tgid_b
):
if (
dst_in_triggers(dst_id_b, dst_group, off_list)
or dst_id_b != tgid_b
or dst_id_b == bytes_3(4000)
):
# OPTIONS static (OFF) legs stay armed when echo 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,
relay_table_key,
False,
)
if to_type == "ON":
sub.state.timer_expires_at = pkt_time
logger.info(
"(%s) [6] Bridge: %s set to \"OFF\" with an on timer rule: timeout timer cancelled",
system_name,
relay_table_key,
)
if not sub.is_active() and to_type == "OFF" and timeout_sec:
sub.state.timer_expires_at = pkt_time + timeout_sec
changed = True
logger.info(
"(%s) [7] Bridge: %s, timeout timer reset to: %s",
system_name,
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):
sub.state.timer_expires_at = pkt_time
changed = True
logger.info(
"(%s) [8] Bridge: %s set to ON with and \"OFF\" timer rule: timeout timer cancelled",
system_name,
relay_table_key,
)
elif dst_id_b == bytes_3(4000) and slot == entry_ts:
is_static_tg = False
ts1_static = sys_cfg.get("TS1_STATIC") or ""
ts2_static = sys_cfg.get("TS2_STATIC") or ""
if ts1_static and slot == 1:
static_tgs = [int(tg) for tg in ts1_static.split(",") if tg.strip()]
if dst_group in static_tgs:
is_static_tg = True
elif ts2_static and slot == 2:
static_tgs = [int(tg) for tg in ts2_static.split(",") if tg.strip()]
if dst_group in static_tgs:
is_static_tg = True
is_reflector = relay_table_key[:1] == "#"
if (
dst_in_triggers(dst_id_b, dst_group, off_list)
or dst_id_b == bytes_3(4000)
or (dst_id_b != tgid_b and not is_static_tg and not is_reflector)
):
if sub.is_active():
sub.state.phase = SubscriptionPhase.IDLE
changed = True
logger.info(
"(%s) [5b] Bridge: %s, connection changed to state: %s (TG 4000 forced deactivation)",
system_name,
relay_table_key,
False,
)
if to_type == "ON":
sub.state.timer_expires_at = pkt_time
logger.info(
"(%s) [6b] Bridge: %s set to \"OFF\" with an on timer rule: timeout timer cancelled",
system_name,
relay_table_key,
)
if not sub.is_active() and to_type == "OFF" and timeout_sec:
sub.state.timer_expires_at = pkt_time + timeout_sec
changed = True
logger.info(
"(%s) [7b] Bridge: %s, timeout timer reset to: %s",
system_name,
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):
sub.state.timer_expires_at = pkt_time
changed = True
logger.info(
"(%s) [8b] Bridge: %s set to ON with and \"OFF\" timer rule: timeout timer cancelled",
system_name,
relay_table_key,
)
if changed:
store.upsert(sub)

@ -0,0 +1,72 @@
# ADN DMR Peer Server - application subscription ingress
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Build immutable ``VoiceIngress`` from DMRD receive parameters (legacy ``dmrd_received``)."""
from __future__ import annotations
from adn_server.domain import int_id
from adn_server.domain.voice_routing import VoiceIngress
from adn_server.domain.value_objects import DmrId, TgId
_BRIDGE_CALL_TYPES = frozenset({"group", "vcsbk"})
def build_voice_ingress(
*,
source_system: str,
system_mode: str,
peer_id: bytes,
rf_src: bytes,
dst_id: bytes,
slot: int,
call_type: str,
stream_id: bytes = b"",
) -> VoiceIngress | None:
"""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
return VoiceIngress(
source_system=source_system,
slot=match_slot, # type: ignore[arg-type]
dst_tgid=TgId(int_id(dst_id)),
source_is_obp=system_mode == "OPENBRIDGE",
call_type=call_type,
stream_id=_stream_id(stream_id),
peer_id=_optional_dmr_id(peer_id),
src_id=_optional_dmr_id(rf_src),
)
def _optional_dmr_id(raw: bytes) -> DmrId | None:
if not raw:
return None
value = int_id(raw)
if value == 0:
return None
return DmrId(value)
def _stream_id(raw: bytes) -> int | None:
if not raw:
return None
value = int_id(raw)
return value if value != 0 else None

@ -0,0 +1,92 @@
# ADN DMR Peer Server - application subscription obp source ops
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Store-native OBP source leg ensure."""
from __future__ import annotations
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.domain import bytes_3, int_id
from adn_server.domain.subscription import (
ActivationPolicy,
AudioChannel,
InbandTriggers,
Subscription,
SubscriptionPhase,
SubscriptionRole,
SubscriptionState,
SystemId,
TgId,
)
def _tgid_match(entry_tgid: Any, dst_id_b: bytes, dst_int: int) -> bool:
if entry_tgid == dst_id_b:
return True
try:
return int_id(entry_tgid or b"\x00\x00\x00") == dst_int
except (TypeError, ValueError):
return False
def ensure_obp_source_for_tg_store(
store: SubscriptionStore,
system_name: 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 (relay_table_key, "#" + relay_table_key):
if not any(sub.table_key() == key for sub in store.snapshot()):
continue
channel_tgid = dst_int
patched = False
for sub in list(store.snapshot()):
if sub.system.value != system_name:
continue
if sub.table_key() != key:
continue
if int(sub.channel.slot) != 1:
continue
if not _tgid_match(bytes_3(int(sub.target_tgid.value)), dst_id_b, dst_int):
continue
if not sub.is_active():
sub.state.phase = SubscriptionPhase.ACTIVE
store.upsert(sub)
patched = True
break
if not patched:
store.upsert(
Subscription(
channel=AudioChannel(tgid=TgId(channel_tgid), slot=1), # type: ignore[arg-type]
system=SystemId(system_name),
target_tgid=TgId(dst_int),
role=SubscriptionRole.ECHO,
policy=ActivationPolicy.INBAND,
state=SubscriptionState(phase=SubscriptionPhase.ACTIVE, timer_expires_at=now),
relay_table_key=key if key.startswith("#") else None,
timeout_seconds=None,
triggers=InbandTriggers(),
)
)

@ -0,0 +1,100 @@
# ADN DMR Peer Server - application subscription router
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Resolve voice ingress to forward legs using the subscription store."""
from __future__ import annotations
from adn_server.application.ports import SubscriptionStore
from adn_server.domain.voice_routing import ForwardLeg, VoiceIngress
class SubscriptionRouter:
"""Pure router: no Twisted, no BRIDGES dict mutation (legacy parity for forward targets)."""
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.relay_tables_with_active_source(
ingress.source_system,
match_slot,
dst_tgid,
)
if not tables:
return ()
legs: list[ForwardLeg] = []
seen_obp: set[tuple[str, int]] = set()
for table_key in tables:
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():
continue
if ingress.source_is_obp:
obp_key = (sub.system.value, int(sub.channel.slot))
if obp_key in seen_obp:
continue
seen_obp.add(obp_key)
legs.append(
ForwardLeg(
target_system=sub.system.value,
slot=sub.channel.slot,
target_tgid=sub.target_tgid,
)
)
return tuple(legs)
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():
if sub.system.value != system:
continue
if int(sub.channel.slot) != int(slot):
continue
if not sub.is_active():
continue
if int(sub.target_tgid) != int(dst_tgid):
continue
key = sub.table_key()
if key not in seen:
seen.add(key)
tables.append(key)
return tuple(sorted(tables))

@ -0,0 +1,109 @@
# ADN DMR Peer Server - application subscription routing table export
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""One-way export: SubscriptionStore → legacy ``BRIDGES`` dict (D-08)."""
from __future__ import annotations
import time
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.domain import bytes_3
from adn_server.domain.subscription import (
ActivationPolicy,
Subscription,
SubscriptionPhase,
SubscriptionRole,
)
_DEFAULT_TIMEOUT_SEC = 600.0
def subscription_to_legacy_row(sub: Subscription, *, now: float | None = None) -> dict[str, Any]:
"""Map one subscription to a legacy ``BRIDGES[table][i]`` row."""
epoch = time.time() if now is None else now
channel_b = bytes_3(int(sub.channel.tgid))
target_b = bytes_3(int(sub.target_tgid))
to_type = _legacy_to_type(sub)
timeout = _legacy_timeout(sub, to_type)
on_list, off_list = _legacy_on_off(sub, channel_b, to_type)
active = sub.state.phase == SubscriptionPhase.ACTIVE
timer = sub.state.timer_expires_at
if timer is None:
timer = epoch + timeout if isinstance(timeout, (int, float)) else epoch
return {
"SYSTEM": sub.system.value,
"TS": sub.channel.slot,
"TGID": target_b,
"ACTIVE": active,
"TIMEOUT": timeout,
"TO_TYPE": to_type,
"ON": on_list,
"OFF": off_list,
"RESET": list(sub.triggers.reset),
"TIMER": timer,
}
def export_routing_table(
store: SubscriptionStore,
*,
now: float | None = None,
) -> dict[str, list[dict[str, Any]]]:
"""Build a legacy ``BRIDGES`` snapshot from the subscription store (export only)."""
epoch = time.time() if now is None else now
bridges: dict[str, list[dict[str, Any]]] = {}
for sub in store.snapshot():
key = sub.table_key()
bridges.setdefault(key, []).append(subscription_to_legacy_row(sub, now=epoch))
return bridges
def _legacy_to_type(sub: Subscription) -> str:
if sub.role == SubscriptionRole.ECHO:
return "NONE"
if sub.role == SubscriptionRole.PASSIVE_STAT:
return "STAT"
if sub.policy == ActivationPolicy.STATIC and sub.state.phase == SubscriptionPhase.ACTIVE:
return "OFF"
return "ON"
def _legacy_timeout(sub: Subscription, to_type: str) -> float | str:
if to_type == "STAT":
return ""
if sub.timeout_seconds is not None:
return sub.timeout_seconds
return _DEFAULT_TIMEOUT_SEC
def _legacy_on_off(
sub: Subscription,
channel_b: bytes,
to_type: str,
) -> tuple[list[bytes], list[bytes]]:
if to_type == "STAT":
return [], []
if to_type == "NONE":
return [], []
on_list = list(sub.triggers.on) if sub.triggers.on else [channel_b]
return on_list, list(sub.triggers.off)

@ -0,0 +1,103 @@
# ADN DMR Peer Server - application subscription routing table import
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Import legacy ``BRIDGES`` rows into domain subscriptions (mirror of ``bridges_export``)."""
from __future__ import annotations
from typing import Any
from adn_server.domain import int_id
from adn_server.domain.subscription import (
ActivationPolicy,
AudioChannel,
InbandTriggers,
Subscription,
SubscriptionPhase,
SubscriptionRole,
SubscriptionState,
SystemId,
TgId,
)
from .trigger_bytes import trigger_bytes_tuple
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():
for row in rows:
if not isinstance(row, dict):
continue
subs.append(_subscription_from_row(table_key, row))
return subs
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")
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")
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
timeout = row.get("TIMEOUT")
timeout_sec = float(timeout) if isinstance(timeout, (int, float)) else None
return Subscription(
channel=AudioChannel(tgid=TgId(channel_tgid), slot=ts), # type: ignore[arg-type]
system=SystemId(str(row.get("SYSTEM", ""))),
target_tgid=TgId(int_id(row.get("TGID") or b"\x00\x00\x00")),
role=_role_from_to_type(to_type),
policy=_policy_from_to_type(to_type),
state=SubscriptionState(
phase=SubscriptionPhase.ACTIVE if row.get("ACTIVE") else SubscriptionPhase.IDLE,
timer_expires_at=timer_at,
),
relay_table_key=relay_table_key,
timeout_seconds=timeout_sec,
triggers=InbandTriggers(
on=trigger_bytes_tuple(row.get("ON")),
off=trigger_bytes_tuple(row.get("OFF")),
reset=trigger_bytes_tuple(row.get("RESET")),
),
)
def _role_from_to_type(to_type: str) -> SubscriptionRole:
if to_type == "NONE":
return SubscriptionRole.ECHO
if to_type == "STAT":
return SubscriptionRole.PASSIVE_STAT
return SubscriptionRole.SINK
def _policy_from_to_type(to_type: str) -> ActivationPolicy:
if to_type == "STAT":
return ActivationPolicy.OPENBRIDGE_STAT
if to_type == "OFF":
return ActivationPolicy.STATIC
return ActivationPolicy.INBAND

@ -0,0 +1,39 @@
# ADN DMR Peer Server - application subscription routing table legacy view
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Legacy BRIDGES view from ``SubscriptionStore`` (v1 monitor shim, D-08)."""
from __future__ import annotations
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.routing_table_export import export_routing_table
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_routing_table``)."""
return export_routing_table(self._store, now=now)

@ -0,0 +1,174 @@
# ADN DMR Peer Server - application subscription rule timer ops
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Store-native rule_timer_loop; mirrors legacy BRIDGES row semantics."""
from __future__ import annotations
import logging
from collections import defaultdict
from collections.abc import Callable
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.routing_table_export import _legacy_to_type
from adn_server.application.routing.helpers import is_special_tg
from adn_server.domain.subscription import Subscription, SubscriptionPhase
logger = logging.getLogger(__name__)
def apply_rule_timer_store(
store: SubscriptionStore,
systems_cfg: dict[str, Any],
now: float,
*,
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)
for sub in store.snapshot():
by_table[sub.table_key()].append(sub)
remove_tables: list[str] = []
debug_msgs: list[str] = []
for relay_table_key, entries in list(by_table.items()):
bridge_used = False
special_tg = is_special_tg(relay_table_key)
for sub in entries:
system_name = sub.system.value
sys_config = systems_cfg.get(system_name, {})
is_single_mode = sys_config.get("SINGLE_MODE", False)
to_type = _legacy_to_type(sub)
active = sub.is_active()
timer = float(sub.state.timer_expires_at or 0.0)
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:
if to_type == "ON":
if active:
bridge_used = True
debug_msgs.append(
"(ROUTER) Conference Bridge ACTIVE (INFINITE TIMER): System: %s Bridge: %s, TS: %s, TGID: %s"
% (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, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
elif to_type == "OFF":
if not active:
sub.state.phase = SubscriptionPhase.ACTIVE
store.upsert(sub)
bridge_used = True
logger.info(
"(ROUTER) Conference Bridge ACTIVATED (NO TIMEOUT): System: %s, Bridge: %s, TS: %s, TGID: %s",
system_name,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
)
else:
bridge_used = True
debug_msgs.append(
"(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s"
% (system_name, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
else:
if to_type == "ON":
if active:
bridge_used = True
if timer < now:
sub.state.phase = SubscriptionPhase.IDLE
store.upsert(sub)
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,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
)
else:
logger.info(
"(ROUTER) Conference Bridge ACTIVE (ON timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,",
system_name,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
timer - now,
)
else:
debug_msgs.append(
"(ROUTER) Conference Bridge INACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s"
% (system_name, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
elif to_type == "OFF":
if not active:
if timer < now:
sub.state.phase = SubscriptionPhase.ACTIVE
store.upsert(sub)
bridge_used = True
logger.info(
"(ROUTER) Conference Bridge TIMEOUT: ACTIVATE System: %s, Bridge: %s, TS: %s, TGID: %s",
system_name,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
)
else:
bridge_used = True
logger.info(
"(ROUTER) Conference Bridge INACTIVE (OFF timer running): System: %s Bridge: %s, TS: %s, TGID: %s, Timeout in: %.2fs,",
system_name,
relay_table_key,
sub.channel.slot,
int(sub.target_tgid),
timer - now,
)
elif active:
bridge_used = True
debug_msgs.append(
"(ROUTER) Conference Bridge ACTIVE (no change): System: %s Bridge: %s, TS: %s, TGID: %s"
% (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, relay_table_key, sub.channel.slot, int(sub.target_tgid))
)
if not bridge_used:
remove_tables.append(relay_table_key)
if debug_msgs:
logger.debug("\n".join(debug_msgs))
for key in remove_tables:
for sub in by_table.get(key, ()):
store.remove(sub.subscription_id)
logger.debug("(ROUTER) Unused conference bridge %s removed", key)

@ -0,0 +1,57 @@
# ADN DMR Peer Server - application subscription stat trimmer ops
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Store-native stat_trimmer_loop."""
from __future__ import annotations
import logging
from collections import defaultdict
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.routing_table_export import _legacy_to_type
from adn_server.domain.subscription import Subscription
logger = logging.getLogger(__name__)
def apply_stat_trimmer_store(store: SubscriptionStore) -> None:
"""Remove STAT-only bridge tables with no ON-active or OFF legs in use."""
by_table: dict[str, list[Subscription]] = defaultdict(list)
for sub in store.snapshot():
by_table[sub.table_key()].append(sub)
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", relay_table_key)

@ -0,0 +1,41 @@
# ADN DMR Peer Server - application subscription store sync
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Import legacy ``BRIDGES`` snapshots into ``SubscriptionStore``.
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
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.routing_table_import import subscriptions_from_routing_table
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_routing_table(bridges))

@ -0,0 +1,142 @@
# ADN DMR Peer Server - application subscription subscription debug ops
#
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
#
###############################################################################
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
###############################################################################
"""Store-native subscription_debug_loop."""
from __future__ import annotations
import logging
from typing import Any
from adn_server.application.ports import SubscriptionStore
from adn_server.application.subscription.routing_table_export import _legacy_to_type
from adn_server.domain import bytes_3
from adn_server.domain.subscription import (
ActivationPolicy,
AudioChannel,
InbandTriggers,
Subscription,
SubscriptionPhase,
SubscriptionRole,
SubscriptionState,
SystemId,
TgId,
)
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_subscription_debug_store(
store: SubscriptionStore,
systems_cfg: dict[str, Any],
now: float,
) -> None:
"""Remove invalid bridge keys and fix >1 active dial (#) bridge per MASTER."""
for key in _PROHIBITED_TABLE_KEYS:
for sub in [s for s in store.snapshot() if s.table_key() == key]:
store.remove(sub.subscription_id)
statroll = sum(1 for sub in store.snapshot() if _legacy_to_type(sub) == "STAT")
for system, sys_cfg in systems_cfg.items():
bridgeroll = 0
dialroll = 0
activeroll = 0
for sub in store.snapshot():
if sub.system.value != system:
continue
bridgeroll += 1
if sub.is_active():
if sub.table_key().startswith("#"):
dialroll += 1
activeroll += 1
else:
activeroll += 1
if bridgeroll:
logger.debug(
"(BRIDGEDEBUG) system %s has %s bridges of which %s are in an ACTIVE state",
system,
bridgeroll,
activeroll,
)
if dialroll > 1 and sys_cfg.get("MODE") == "MASTER":
logger.warning(
"(BRIDGEDEBUG) system %s has more than one active dial bridge (%s) - fixing",
system,
dialroll,
)
_fix_duplicate_dial_bridges(store, system, sys_cfg, now)
logger.info("(BRIDGEDEBUG) The server currently has %s STATic bridges", statroll)
def _fix_duplicate_dial_bridges(
store: SubscriptionStore,
system: str,
sys_cfg: dict[str, Any],
now: float,
) -> None:
times: dict[float, str] = {}
for sub in store.snapshot():
if sub.system.value != system or not sub.is_active():
continue
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)] = relay_table_key
_tmout = float(sys_cfg.get("DEFAULT_UA_TIMER", 10))
timeout_sec = _tmout * 60.0
system_id = SystemId(system)
for relay_table_key in set(times.values()):
logger.warning("(BRIDGEDEBUG) deactivating system: %s for bridge: %s", system, relay_table_key)
try:
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() != relay_table_key:
continue
if int(sub.channel.slot) != 2:
continue
store.remove(sub.subscription_id)
store.upsert(
Subscription(
channel=AudioChannel(tgid=TgId(9), slot=2),
system=system_id,
target_tgid=TgId(9),
role=SubscriptionRole.SINK,
policy=ActivationPolicy.INBAND,
state=SubscriptionState(
phase=SubscriptionPhase.IDLE,
timer_expires_at=now + timeout_sec,
),
relay_table_key=relay_table_key,
timeout_seconds=timeout_sec,
triggers=InbandTriggers(on=(on_trigger,), off=(), reset=()),
)
)

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

Loading…
Cancel
Save

Powered by TurnKey Linux.