Merge pull request #1 from ce5rpy/develop
V2 peer server: architecture, report wire, subscription routing, and integrated proxypull/4/head v2.0.0-rc.1
commit
f194a185aa
@ -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
|
||||
@ -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`.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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,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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
@ -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
|
||||
@ -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,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,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,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,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…
Reference in new issue