From f6aaa9cb0997092f62d5108fa178444a8375fa31 Mon Sep 17 00:00:00 2001
From: ce5rpy <169016246+ce5rpy@users.noreply.github.com>
Date: Thu, 18 Jun 2026 16:24:56 -0400
Subject: [PATCH] Release 2.0.0-rc.4 (#8)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* docs: expand server guide and add report-proxy for legacy dashboards
Document ADN-report-proxy so adn-server 2.x can feed v1 legacy monitors,
and sync EN/ES MkDocs with DATABASE, dynamic TGs, monitoring, and related topics.
* docs: add bridges vs subscriptions, performance guide, and Mermaid diagrams (#6)
Document 2.x routing model with a concrete TG 52090 example, operator-focused
performance gains (indexes, reporting, integrated proxy), and MkDocs Mermaid
support across server and monitor architecture pages.
* fix: echo TG 9990 monitor parity and UA session exclusions (#7)
Exclude 9990–9999 from SINGLE/UA locks, remap inject-only echo TX events,
resolve bridge TX peer_id for monitor display, and fix dynamic TG restore callback.
* chore: bump version to 2.0.0-rc.4
---
CHANGELOG.md | 15 ++
README.md | 2 +-
docs/en/README.md | 4 +-
docs/en/monitor/architecture.md | 26 ++-
docs/en/monitor/configuration.md | 12 +
docs/en/monitor/index.md | 4 +-
docs/en/monitor/self-service.md | 17 ++
docs/en/server/development/architecture.md | 13 ++
.../development/behaviour-and-timers.md | 1 +
.../development/bridges-vs-subscriptions.md | 208 ++++++++++++++++++
docs/en/server/development/performance.md | 99 +++++++++
docs/en/server/protocols/report-v2.md | 16 ++
.../user-guide/bridges-and-talkgroups.md | 26 +++
docs/en/server/user-guide/configuration.md | 39 +++-
docs/en/server/user-guide/hotspot-proxy.md | 10 +-
docs/en/server/user-guide/introduction.md | 8 +-
docs/en/server/user-guide/monitoring.md | 27 +++
docs/en/server/user-guide/report-proxy.md | 100 +++++++++
docs/en/server/user-guide/special-numbers.md | 23 +-
docs/es/README.md | 2 +
docs/es/monitor/architecture.md | 26 ++-
docs/es/monitor/configuration.md | 4 +-
docs/es/monitor/index.md | 4 +-
docs/es/monitor/self-service.md | 17 ++
docs/es/server/development/architecture.md | 13 ++
.../development/behaviour-and-timers.md | 1 +
.../development/bridges-vs-subscriptions.md | 208 ++++++++++++++++++
docs/es/server/development/performance.md | 99 +++++++++
docs/es/server/protocols/report-v2.md | 16 ++
.../user-guide/bridges-and-talkgroups.md | 26 +++
docs/es/server/user-guide/configuration.md | 39 +++-
docs/es/server/user-guide/hotspot-proxy.md | 10 +-
docs/es/server/user-guide/introduction.md | 8 +-
docs/es/server/user-guide/monitoring.md | 27 +++
docs/es/server/user-guide/report-proxy.md | 100 +++++++++
docs/es/server/user-guide/special-numbers.md | 21 +-
mkdocs.es.yml | 10 +-
mkdocs.yml | 10 +-
pyproject.toml | 4 +-
src/adn_server/__init__.py | 2 +-
.../application/dynamic_tg_use_cases.py | 2 +-
.../application/report/monitor_topology.py | 17 +-
src/adn_server/application/routing/helpers.py | 10 +-
.../application/routing_use_cases.py | 20 +-
tests/application/test_dynamic_tg_persist.py | 46 ++++
tests/application/test_monitor_topology.py | 7 +
.../application/test_peer_single_downlink.py | 21 ++
47 files changed, 1353 insertions(+), 67 deletions(-)
create mode 100644 docs/en/server/development/bridges-vs-subscriptions.md
create mode 100644 docs/en/server/development/performance.md
create mode 100644 docs/en/server/user-guide/report-proxy.md
create mode 100644 docs/es/server/development/bridges-vs-subscriptions.md
create mode 100644 docs/es/server/development/performance.md
create mode 100644 docs/es/server/user-guide/report-proxy.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16a819f..b181d0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
All notable changes to **adn-server** are documented here.
+## [2.0.0-rc.4] - 2026-06-18
+
+### Added
+
+- **Docs (MkDocs EN/ES):** bridges vs subscriptions, performance notes, report-proxy guide for legacy dashboards, Mermaid diagrams in architecture/monitoring pages.
+
+### Fixed
+
+- **Echo TG 9990** — excluded from SINGLE/UA session locks; inject-only monitor remap for echo TX legs; bridge TX report field 5 resolves hotspot radio id for ECHO/hotspot live chips.
+- **Dynamic TG restore** — `sync_restored_dynamic_tgs` passes `now=` as keyword (fixes TypeError on echo startup after DB restore).
+
+### Compatibility
+
+- **Monitor:** adn-monitor **2.0.0-rc.5**.
+
## [2.0.0-rc.3] - 2026-06-17
### Added
diff --git a/README.md b/README.md
index 174de73..b54f590 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# ADN DMR Peer Server
-**Version 2.0.0-rc.3** — pairs with **adn-monitor 2.0.0-rc.4** (report v2 slim wire + JSON HELLO).
+**Version 2.0.0-rc.4** — pairs with **adn-monitor 2.0.0-rc.5** (report v2 slim wire + JSON HELLO).
ADN DMR conference bridge server. Configuration is YAML; the codebase follows clean architecture (domain, application, infrastructure). v2 adds integrated **PROXY**, **SubscriptionStore** routing, report v2 to the monitor, and a unified **`adn-server.py`** entrypoint (`--echo`, `--doctor`, `--no-proxy`). See [CHANGELOG.md](CHANGELOG.md).
diff --git a/docs/en/README.md b/docs/en/README.md
index fa9ee37..1baeb9b 100644
--- a/docs/en/README.md
+++ b/docs/en/README.md
@@ -24,6 +24,7 @@ The **ADN DMR Peer Server** is a [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.
| TG 4000, 999x, echo | [Special numbers](server/user-guide/special-numbers.md) |
| Private calls | [Private calls](server/user-guide/private-calls.md) |
| Voice / TTS | [Voice, announcements, and TTS](server/user-guide/voice-and-tts.md) |
+| Legacy dashboard + server 2.x | [Report proxy](server/user-guide/report-proxy.md) |
| OpenBridge / DMRE | [OpenBridge](server/protocols/openbridge.md), [DMRE v5](server/protocols/dmre-v5.md) |
| HBP | [HBP](server/protocols/hbp.md) |
| Code layout | [Architecture](server/development/architecture.md), [Behaviour and timers](server/development/behaviour-and-timers.md) |
@@ -34,6 +35,7 @@ The **ADN DMR Peer Server** is a [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.
```bash
pip install -r requirements.txt
cp adn-server.example.yaml adn-server.yaml
+# Edit DATABASE (MariaDB) and secrets before production start
python adn-server.py -c adn-server.yaml
```
@@ -50,7 +52,7 @@ Dashboard, WebSocket live view, FastAPI API, **MySQL** self-service — see [Mon
| `adn-server.yaml` — integrated `PROXY` / `SELF_SERVICE` | [Hotspot proxy (integrated)](server/user-guide/hotspot-proxy.md) |
| `adn-monitor.yaml`, layout | [Monitor configuration](monitor/configuration.md) |
| Integrated hotspot proxy | [Hotspot proxy](server/user-guide/hotspot-proxy.md) |
-| Standalone hotspot proxy (UDP port range) | [Hotspot proxy — standalone](monitor/hotspot-proxy.md#standalone-proxy-legacy-adn-monitor-repo) |
+| Standalone hotspot proxy (removed) | [Hotspot proxy — moved](monitor/hotspot-proxy.md) |
| Self-service | [Self-service](monitor/self-service.md) |
| How it connects to the server | [Monitoring and reports](server/user-guide/monitoring.md) |
diff --git a/docs/en/monitor/architecture.md b/docs/en/monitor/architecture.md
index 6d3cc33..cafde7c 100644
--- a/docs/en/monitor/architecture.md
+++ b/docs/en/monitor/architecture.md
@@ -10,6 +10,14 @@ Under `monitor/src/adn_monitor/`:
Composition root: `infrastructure/fastapi/composition.py` (`build_monitor_api`).
+```mermaid
+flowchart TD
+ INF["Infrastructure
YAML · FastAPI /ws · TCP/MQTT ingest
MySQL repos · pickle/JSON decoders"]
+ APP["Application
MonitorState · report/dashboard use cases
auth · self-service · aliases"]
+ DOM["Domain
value objects · errors · opcodes
UserSession · Result"]
+ INF --> APP --> DOM
+```
+
## Unified process (`monitor.py`)
Single uvicorn/FastAPI process:
@@ -41,15 +49,19 @@ The standalone **`adn-proxy`** process was removed from the **adn-monitor** repo
## Typical deployment topology
-```text
-[Hotspots] --UDP--> [adn-server PROXY] --UDP--> [peer MASTER]
- |
- v
- MySQL (Clients)
+```mermaid
+flowchart TD
+ HS[Hotspots] -->|UDP HBP| PROXY[adn-server PROXY]
+ PROXY --> MASTER[MASTER inject]
+ PROXY --> DB[(MySQL Clients)]
-[Peer server :REPORT_PORT] <--- TCP or MQTT --- [monitor.py ingest]
+ MASTER --> REPORT[REPORTS listener]
+ REPORT <-->|TCP or MQTT| ING[monitor.py ingest]
+ ING --> APP[FastAPI /api /ws]
+ APP --> DB
-[Browser] --HTTPS--> [Nginx: static frontend + proxy /api,/ws --> MONITOR_APP.LISTEN_PORT]
+ BR[Browser] -->|HTTPS| FE[Nginx or SERVE_STATIC]
+ FE --> APP
```
---
diff --git a/docs/en/monitor/configuration.md b/docs/en/monitor/configuration.md
index b58ec03..b170b73 100644
--- a/docs/en/monitor/configuration.md
+++ b/docs/en/monitor/configuration.md
@@ -102,6 +102,18 @@ Obsolete **`WEBSOCKET_SERVER`** YAML (Twisted on a separate port) is ignored; us
---
+## Database schema and migrations
+
+**adn-monitor** ships SQL migrations in `monitor/src/adn_monitor/infrastructure/persistence/schema.py`. Apply with **`db_bootstrap --update`** (or equivalent) when upgrading.
+
+| Migration | Table / change |
+|-----------|----------------|
+| **`004_peer_dynamic_tgs`** | **`peer_dynamic_tgs`** — per-peer dynamic TG rows written by **adn-server 2.0.0-rc.3+** (shared schema). |
+
+**adn-server** also ensures **`peer_dynamic_tgs`** exists on startup (idempotent). Either path is sufficient; both can run against the same **`hbmon`** database.
+
+---
+
## Environment
- **`ADN_CONFIG_PATH`**: Absolute path to **`adn-monitor.yaml`** for **`monitor.py`**.
diff --git a/docs/en/monitor/index.md b/docs/en/monitor/index.md
index 77f827d..e97bdad 100644
--- a/docs/en/monitor/index.md
+++ b/docs/en/monitor/index.md
@@ -18,7 +18,9 @@ This chapter documents the **adn-monitor** stack at the same level of detail as
| **`adn-server.yaml`** | **`adn-server.py`** (integrated **`PROXY`** / **`SELF_SERVICE`**) | `-c` / default path next to binary |
| **`monitor/adn-monitor.yaml`** | **`monitor.py`** | **`ADN_CONFIG_PATH`** |
-**`SELF_SERVICE`** (MySQL / PBKDF2) must **match** between **`adn-server.yaml`** and **`adn-monitor.yaml`**. **`ADN_CONNECTION`**, dashboard, WebSocket, and aliases live in **`adn-monitor.yaml`**; integrated **`PROXY`** lives in **`adn-server.yaml`** — see [Hotspot proxy (integrated)](../server/user-guide/hotspot-proxy.md).
+**`SELF_SERVICE`** (MySQL / PBKDF2) must **match** between **`adn-server.yaml`** and **`adn-monitor.yaml`**. On the server, MariaDB credentials are in **`DATABASE`** (shared pool for self-service and **`peer_dynamic_tgs`**). **`ADN_CONNECTION`**, dashboard, WebSocket, and aliases live in **`adn-monitor.yaml`**; integrated **`PROXY`** lives in **`adn-server.yaml`** — see [Hotspot proxy (integrated)](../server/user-guide/hotspot-proxy.md).
+
+**Recommended pairing:** **adn-server 2.0.0-rc.3** + **adn-monitor 2.0.0-rc.4** (dynamic TG persistence, TG 4000 monitor sync).
## Link to the peer server
diff --git a/docs/en/monitor/self-service.md b/docs/en/monitor/self-service.md
index a94ecca..6c6d7cb 100644
--- a/docs/en/monitor/self-service.md
+++ b/docs/en/monitor/self-service.md
@@ -41,6 +41,23 @@ Session lifetime is extended on activity (**SelfServiceController** uses a long
## End-to-end flow (why options reach the hotspot)
+```mermaid
+sequenceDiagram
+ participant UI as React /self-service
+ participant API as monitor FastAPI
+ participant DB as MySQL Clients
+ participant SRV as adn-server PROXY
+ participant HS as Hotspot
+
+ UI->>API: POST /api/self-service/device/options
+ API->>DB: UPDATE options, modified = 1
+ Note over SRV: send_opts loop ~every 10 s
+ SRV->>DB: read rows with modified = 1
+ SRV->>SRV: RPTO on MASTER leg
+ SRV->>HS: OPTIONS via HBP path
+ SRV->>DB: clear modified
+```
+
1. User saves options in the web UI → **monitor API** writes **`Clients.options`** and **`modified = 1`**.
2. The **hotspot proxy** runs **`send_opts`** on a loop (~every **10 s**). For rows with **`modified = 1`**, it reads options from the DB, sends **RPTO** (options) **to the peer server** at **`(MASTER, assigned_dest_port)`**, then clears **`modified`** in the DB.
3. The **ADN DMR Peer Server** receives **RPTO** on the MASTER leg and updates its **OPTIONS** / bridge state (same path as a normal hotspot registration refresh).
diff --git a/docs/en/server/development/architecture.md b/docs/en/server/development/architecture.md
index aa08227..087af46 100644
--- a/docs/en/server/development/architecture.md
+++ b/docs/en/server/development/architecture.md
@@ -8,6 +8,14 @@
**Dependency rule:** infrastructure → application → domain (inward only).
+```mermaid
+flowchart TD
+ INF["Infrastructure
Twisted UDP/TCP · YAML · voice
report_server · persistence · security"]
+ APP["Application
RoutingUseCases · VoiceUseCases
ports · SubscriptionStore · router"]
+ DOM["Domain
entities · value objects · errors · Result"]
+ INF --> APP --> DOM
+```
+
## Entrypoint
`main.py` wires configuration, **LoopingCall** timers, factories for **HBPProtocol**, report client, and injects use cases.
@@ -16,6 +24,10 @@
Voice routing is driven by **`SubscriptionStore`** (domain subscriptions). `RoutingUseCases` orchestrates `dmrd_received` and delegates forward resolution to **`SubscriptionRouter`**.
+Conceptual comparison with legacy **`BRIDGES`**: [BRIDGES vs Subscriptions](bridges-vs-subscriptions.md).
+
+Performance changes in 2.x (indexes, reporting, integrated proxy): [Performance (2.x)](performance.md).
+
- **`InMemoryAclRouter`** (`AclRouter` port) — ACL range checks only (`acl_check`).
- **`routing_table_for_report()`** — export shim for monitor/report (legacy BRIDGE_SND shape); not used for runtime forwards.
@@ -32,6 +44,7 @@ Wire opcodes and YAML keys may still say “bridge” for legacy monitor compati
| Voice / TTS | `application/voice_use_cases.py`, `infrastructure/voice/` |
| Hotspot proxy (fan-in) | `infrastructure/proxy/` (`udp_fanin.py`, `runtime.py`), `application/proxy/` use cases |
| Self-service (MySQL) | `infrastructure/proxy/self_service_bridge.py`, `infrastructure/proxy/persistence/` |
+| Dynamic TG persistence | `application/dynamic_tg_use_cases.py`, `infrastructure/persistence/dynamic_tg_repository.py`, `application/routing/dynamic_tg_restore.py` |
## Configuration as shared state
diff --git a/docs/en/server/development/behaviour-and-timers.md b/docs/en/server/development/behaviour-and-timers.md
index 182d359..16af023 100644
--- a/docs/en/server/development/behaviour-and-timers.md
+++ b/docs/en/server/development/behaviour-and-timers.md
@@ -20,6 +20,7 @@ The following intervals are part of the current runtime behavior:
| `stream_trimmer` | **5s** | Stream cleanup, timeout handling, end-of-call state trimming. |
| `bridge_reset` | **6s** | Bridge reset flag cleanup and pending reset completion. |
| OPTIONS refresh | **event-driven** | Static TG / reflector from **RPTO**, **startup/reload** (`apply_startup_bridges`), **dmrd** no-source fallback. No periodic 26s loop (**D-28**). |
+| `dynamic_tg_purge_loop` | **60s** | Purge expired **SINGLE=1** rows from `peer_dynamic_tgs` and in-memory `_PEER_UA_SESSIONS`. |
| `statTrimmer` | **303s** | Trim stale STAT bridges and transient status entries. |
If you change one of these intervals, document the operational impact for monitoring, loop behavior, and troubleshooting.
diff --git a/docs/en/server/development/bridges-vs-subscriptions.md b/docs/en/server/development/bridges-vs-subscriptions.md
new file mode 100644
index 0000000..8444a26
--- /dev/null
+++ b/docs/en/server/development/bridges-vs-subscriptions.md
@@ -0,0 +1,208 @@
+# BRIDGES (legacy) vs Subscriptions (new server)
+
+**adn-dmr-server** and **adn-server 2.x** forward group voice the same way at the wire: a talkgroup “bridge table” decides which **systems** receive a copy of the stream, with **LC rewrite** per leg. What changed in 2.x is **how that table is represented in code** — not the operator-visible rules.
+
+## For operators
+
+Runtime bridge behaviour is unchanged: TGs, slots, OPTIONS, UA, TG 4000, OpenBridge. **Subscriptions** is the internal name for the 2.x routing engine — not a separate operating mode and not something you configure on its own.
+
+**No operational change**
+
+- Configuration: **`SYSTEMS`**, hotspot OPTIONS, **`SELF_SERVICE`** / MariaDB — same as **adn-dmr-server**. Neither stack loads a `BRIDGES` block from YAML.
+- Rule parity: ACTIVE source row, `#…` tables, UA timers, `GEN_STAT_BRIDGES`, etc.
+
+**Concrete gains in 2.x**
+
+| Area | Effect |
+|------|--------|
+| **Routing stability** | Bridge state lives in a dedicated store; reports and timers no longer share the same mutable structure as voice forwarding. Fewer mismatches between what the server forwards and what the dashboard shows under load. |
+| **Monitor** | **Report v2** (`routing_table`, `topology`) to **adn-monitor 2.x** replaces pickle/CSV; BTABLE tracks peer state more faithfully. |
+| **Dynamic TGs** | With **`DATABASE`**, per-peer dynamics are **persisted** and restored on reconnect (≥ 2.0.0-rc.3). |
+| **Maintenance** | Timer, OpenBridge, ACL, and self-service fixes do not go through one process-wide dict shared with every subsystem. |
+
+The word **subscription** only matters when reading code or this guide; on the dashboard and on the air you still work with **bridges** and **talkgroups**.
+
+---
+
+## At a glance
+
+| | **Legacy (`adn-dmr-server`)** | **New (`adn-server` 2.x)** |
+|---|------------------------------|----------------------------|
+| **Runtime authority** | Global `BRIDGES` dict (`bridge_master.py`) | **`SubscriptionStore`** (domain `Subscription` objects) |
+| **Structure** | `bridge_key → [ row, row, … ]` | One **subscription** per system leg on a channel |
+| **Forward resolution** | Scan rows, call `to_target` | **`SubscriptionRouter.resolve()`** → `ForwardLeg` |
+| **Monitor / report wire** | Pickle `BRIDGE_SND` = `BRIDGES` | JSON `routing_table` (v2) or exported `BRIDGES` shim (v1 compat) |
+| **YAML `BRIDGES:` block** | Not loaded from config in either stack; rows are built at runtime | Same — rows come from OPTIONS, UA, STAT, OpenBridge, echo bootstrap |
+
+Observable behaviour (source-row guard, dynamic UA, static TG, reflector keys `#…`, OpenBridge TS1 match, timers) follows **legacy parity** with `bridge_master.py`.
+
+## Legacy: `BRIDGES` dict
+
+In **adn-dmr-server**, routing state is a **process-wide dictionary**:
+
+```text
+BRIDGES["52090"] = [
+ { "SYSTEM": "MASTER-A", "TS": 2, "TGID": b'...', "ACTIVE": True, "TO_TYPE": "ON", "TIMER": …, … },
+ { "SYSTEM": "MASTER-B", "TS": 2, "TGID": b'...', "ACTIVE": True, "TO_TYPE": "ON", … },
+ { "SYSTEM": "OBP-UK", "TS": 1, "TGID": b'...', "ACTIVE": False, … },
+]
+BRIDGES["#310"] = [ … ] # reflector / marked TG tables
+```
+
+Each **row** is a leg. Important fields:
+
+- **`SYSTEM`** — configured system name (HBP master or OpenBridge leg).
+- **`TS`** — timeslot 1 or 2 (OpenBridge sources use **TS 1** in the match path).
+- **`TGID`** — destination ID bytes used for **LC rewrite** toward that leg.
+- **`ACTIVE`** — leg participates in forwarding when true.
+- **`TIMEOUT` / `TIMER` / `TO_TYPE` / `ON` / `OFF` / `RESET`** — UA timers, static/stat, in-band VTERM rules.
+
+**Voice path (`dmrd_received`):**
+
+1. Derive **bridge key** from destination TG (and reflector `#…` tables when applicable).
+2. Create a dynamic table if missing (UA / STAT / static OPTIONS — same triggers as legacy).
+3. Find an **ACTIVE source row** matching current **system + slot + TGID**.
+4. For each other **ACTIVE** row in **that same table**, call **`to_target`** (contention, ACL, LC/TA rewrite, OpenBridge loop control).
+
+```mermaid
+flowchart TB
+ IN[DMRD / DMRE ingress] --> DM[dmrd_received]
+ DM --> KEY{bridge key exists?}
+ KEY -->|no| CREATE[ensure dynamic / stat / static row]
+ CREATE --> BR
+ KEY -->|yes| BR[(BRIDGES dict)]
+ BR --> SRC{ACTIVE source row\nSYSTEM + TS + TGID?}
+ SRC -->|yes| SCAN[other ACTIVE legs\nsame table]
+ SCAN --> TT[to_target per leg]
+ TT --> OUT[HBP / OpenBridge egress]
+ SRC -->|no| DROP[no forward]
+```
+
+The monitor reads the **same dict** via pickle **`BRIDGE_SND`**.
+
+## New server: `Subscription` + `SubscriptionStore`
+
+In **adn-server 2.x**, the **domain model** replaces ad-hoc dict rows:
+
+- **`AudioChannel`** — logical TG + slot `(tgid, slot)`.
+- **`Subscription`** — one system’s participation: **role**, **activation policy**, **state** (phase, timer), **target_tgid** (LC rewrite), optional **`relay_table_key`** (reflector `#…` tables).
+- **`SubscriptionStore`** — sole **runtime routing authority** (no parallel `BRIDGES` mutation).
+
+**Voice path** (same semantics, different types):
+
+1. `RoutingUseCases.dmrd_received` updates the store (create relay table, static TG, UA timeout — legacy hooks).
+2. **`SubscriptionRouter.relay_tables_with_active_source`** — tables where the ingress system has an **ACTIVE** subscription matching slot/TG.
+3. **`SubscriptionRouter.resolve`** — returns **`ForwardLeg`** targets (system, slot, tgid) for all other **ACTIVE** subscriptions in those tables.
+4. Forward mixins send packets (`to_target` parity).
+
+```mermaid
+flowchart TB
+ IN[DMRD / DMRE ingress] --> RU[RoutingUseCases.dmrd_received]
+ RU --> SS[(SubscriptionStore)]
+ RU --> SR[SubscriptionRouter]
+ SS --> SR
+ SR --> LEGS[ForwardLeg list]
+ LEGS --> FWD[HBP / OBP forward mixins]
+ SS --> EXP[RoutingTableLegacyView\nexport shim only]
+ EXP --> MON[BRIDGE_SND pickle\nor routing_table JSON]
+```
+
+**Important:** `routing_table_for_report()` / **`BRIDGE_SND`** is a **one-way export** for dashboards (`RoutingTableLegacyView`). It is **not** used to decide forwards. That avoids the legacy pattern of mutating a global dict shared with reporting.
+
+## Row → subscription mapping
+
+| Legacy `BRIDGES` row | Domain `Subscription` |
+|----------------------|-------------------------|
+| Table key (`"52090"`, `"#310"`) | `relay_table_key` + channel TG |
+| `SYSTEM` | `system` (`SystemId`) |
+| `TS` + table TG context | `channel.slot` / `channel.tgid` |
+| `TGID` (bytes) | `target_tgid` (LC rewrite) |
+| `ACTIVE` | `state.phase` (`ACTIVE` / `IDLE`) |
+| `TIMER` | `state.timer_expires_at` |
+| `TIMEOUT` | `timeout_seconds` |
+| `TO_TYPE` (`ON`, `OFF`, `STAT`, `NONE`) | `role` + `policy` (`ActivationPolicy`, `SubscriptionRole`) |
+| `ON` / `OFF` / `RESET` | `triggers` (`InbandTriggers`) |
+
+Import/export helpers: `routing_table_import.py`, `routing_table_export.py` (mirror of legacy `bridges_export`).
+
+## End-to-end comparison (one voice frame)
+
+```mermaid
+sequenceDiagram
+ participant Radio
+ participant Server
+ participant Peer as Other system
+
+ Radio->>Server: Group voice TG 52090 TS2
+
+ rect rgb(40,40,50)
+ note right of Server: Legacy
+ Server->>Server: BRIDGES["52090"] source row ACTIVE?
+ Server->>Server: foreach ACTIVE leg in table
+ Server->>Peer: to_target (rewrite LC)
+ end
+
+ rect rgb(30,50,40)
+ note right of Server: New server (same rules)
+ Server->>Server: SubscriptionRouter.resolve()
+ Server->>Peer: forward ForwardLeg(s)
+ end
+```
+
+## What did **not** change
+
+- Bridge **keys** (`52090`, `#reflector`, …) and **multi-leg tables**.
+- **Source-row guard** — forward only from a table where **this** system is an ACTIVE source for that TG/slot context.
+- **Dynamic UA**, **static OPTIONS**, **`GEN_STAT_BRIDGES`**, **TG 4000** clearing, **echo 9990** bootstrap.
+- Timer passes (`rule_timer`, `bridgeDebug`, …) — still driven off the same logical table, implemented on the store in 2.x.
+
+## Concrete example
+
+**Bridge** (network concept): “TG 52090 connects these systems and forwards voice between them”.
+**Subscription** (2.x code only): **one leg** of that table — e.g. “MASTER-A on TG 52090, slot 2, ACTIVE, with its LC and timer”.
+
+It is not bridge *or* subscription: in 2.x a bridge **is** a set of subscriptions on the same channel (TG + slot).
+
+### Scenario
+
+Someone keys **TG 52090** and **MASTER-A**, **MASTER-B**, and an **OpenBridge** leg should hear it. On the dashboard and on the air that is a **bridge** (the TG 52090 table).
+
+**Legacy (`adn-dmr-server`)** — everything in one global dict:
+
+```text
+BRIDGES["52090"] = [
+ { SYSTEM: "MASTER-A", TS: 2, ACTIVE: True, TGID: …, TIMER: … },
+ { SYSTEM: "MASTER-B", TS: 2, ACTIVE: True, TGID: … },
+ { SYSTEM: "OBP-UK", TS: 1, ACTIVE: False, TGID: … },
+]
+```
+
+When a voice frame arrives:
+
+1. Find the row where **this** system is the **ACTIVE** source (same TG/slot).
+2. Walk the **other ACTIVE rows** in the same table.
+3. For each, call **`to_target`** → forward with rewritten LC.
+
+The monitor reads **the same dict** (pickle **`BRIDGE_SND`**).
+
+**New server (`adn-server` 2.x)** — same table, different internal shape:
+
+```text
+SubscriptionStore — TG 52090 / slot 2:
+ - subscription MASTER-A (ACTIVE, target_tgid, timer…)
+ - subscription MASTER-B (ACTIVE, …)
+ - subscription OBP-UK (IDLE, …)
+```
+
+On voice, **`SubscriptionRouter.resolve()`** applies the **same rules** (ACTIVE source, other ACTIVE legs) and returns **`ForwardLeg`** entries to forward. The monitor gets an **exported view** (`BRIDGE_SND` or JSON **`routing_table`**); that export does **not** drive forwarding.
+
+## Where to read code
+
+| Topic | Legacy | New |
+|-------|--------|-----|
+| Voice ingress | `adn-dmr-server/bridge_master.py` (`routerHBP.dmrd_received`) | `application/routing_use_cases.py` |
+| Forward to leg | `to_target` | `application/routing/hbp_forward.py`, `obp_forward.py` |
+| Table state | global `BRIDGES` | `application/subscription/` (`store`, `router`, ops) |
+| Monitor export | `send_routing_table` / pickle | `routing_table_legacy_view.py`, report v2 `routing_table` |
+
+See also: [Bridges and talkgroups](../user-guide/bridges-and-talkgroups.md), [Architecture](architecture.md), [Performance (2.x)](performance.md), [Report protocol v2](../protocols/report-v2.md#routing_table).
diff --git a/docs/en/server/development/performance.md b/docs/en/server/development/performance.md
new file mode 100644
index 0000000..5dfdc50
--- /dev/null
+++ b/docs/en/server/development/performance.md
@@ -0,0 +1,99 @@
+# Performance (2.x)
+
+**adn-server 2.x** and **adn-monitor 2.x** include several changes that reduce CPU work and memory footprint compared with **adn-dmr-server** and the old monitor/proxy stack. This page lists **what** improves and **what causes it**.
+
+## At a glance
+
+| Area | Typical effect | Main cause |
+|------|----------------|------------|
+| **Voice downlink (inject proxy)** | Lower CPU under busy group traffic | **`PeerDownlinkIndex`** — fan-out to peers that match `(slot, TG)` instead of scanning every connected hotspot per packet |
+| **Bridge source lookup** | Faster “am I the ACTIVE source?” | **`SubscriptionStore`** indexes (`relay_tables_with_active_source`) — O(1) by `(system, slot, tgid)` vs scanning table rows |
+| **Background CPU** | Fewer wakeups | **Event-driven OPTIONS / static TG** — removed legacy **26 s** `options_config_loop` ([Behaviour and timers](behaviour-and-timers.md)) |
+| **Mass peer login** | Less redundant CONFIG traffic | **`ConfigPushThrottle`** — adaptive debounce on CONFIG push to the monitor |
+| **Reporting vs voice** | Voice path less blocked by reports | **`BoundedReportQueue`** — coalesced snapshots, bounded drain per tick |
+| **Server → monitor wire** | Less serialize/send work | **Report v2** JSON (`routing_table`, `topology`, `voice_event`) instead of periodic full pickle of `CONFIG`/`BRIDGES` ([Report protocol v2](../protocols/report-v2.md)) |
+| **Process count (RAM)** | One Python process instead of two | **Integrated `PROXY`** in `adn-server.py` — no separate **adn-proxy** process ([Hotspot proxy](../user-guide/hotspot-proxy.md)) |
+| **Monitor RAM / WS load** | Smaller in-memory dashboard state | **Slim `dashboard_state` wire**, `clean_sys_dict`, lighter WebSocket fingerprints ([Monitor architecture](../../monitor/architecture.md)) |
+
+## Server: inject-only downlink index
+
+The largest **CPU** win on many ADN networks is on the **MASTER inject-only** path (`PROXY` with inject-only mode).
+
+**Legacy:** `send_peers` walks **every registered peer** for each downlink packet → cost grows as **O(peers × packets/s)**.
+
+**2.x:** `PeerDownlinkIndex` precomputes candidates from each peer’s **OPTIONS** (static TGs) and **UA session** state. For each group voice frame, only peers that **might** want that `(slot, TG)` are considered; each candidate still passes `peer_should_receive_group_voice`.
+
+```text
+Legacy: every DMRD → try all N peers
+2.x: every DMRD → index lookup → try k peers (k ≪ N on busy proxies)
+```
+
+OPTIONS parsing is **cached per peer** (`_CACHED_OPTIONS_STATIC`): if the OPTIONS blob is unchanged, already-parsed static TGs are reused instead of re-parsing on every packet.
+
+| Code | Role |
+|------|------|
+| `application/routing/peer_downlink_index.py` | Index build and `(slot, tgid) → candidates` |
+| `infrastructure/twisted_adapters/udp_hbp.py` | `_iter_downlink_peers`, `send_peers` |
+| `tests/infrastructure/test_peer_downlink_fanout.py` | Inject-only fan-out tests |
+
+**When it matters:** proxy with **tens to hundreds** of hotspots and steady group voice. On a small conference with few peers, the difference is minor.
+
+## Server: routing indexes
+
+On every group voice frame the server must find relay tables where **this system is the ACTIVE source**.
+
+**Legacy:** scan rows inside `BRIDGES[table_key]` (and related tables).
+
+**2.x:** `InMemorySubscriptionStore.relay_tables_with_active_source()` uses a maintained **`_source_tables`** index — lookup by `(system, slot, dst_tgid)` without walking all legs.
+
+This lives in the subscription store implementation; it is an **algorithmic index**, not a separate feature you configure.
+
+| Code | Role |
+|------|------|
+| `infrastructure/subscription_store.py` | `_source_tables`, `_by_table`, `_active_target_counts` |
+| `application/subscription/router.py` | `SubscriptionRouter.resolve()` |
+
+## Server: less periodic and login-storm work
+
+| Change | What it avoids |
+|--------|----------------|
+| **No 26 s OPTIONS loop** | Timer firing every 26 s across all systems to refresh static bridges when RPTO/startup/reload already handle it |
+| **`ConfigPushThrottle`** | Flooding the monitor with CONFIG snapshots when many peers connect within a few seconds (debounce widens from ~0.3 s to ~2 s during bursts) |
+| **`BoundedReportQueue`** | Doing pickle/JSON encode and TCP send synchronously on the voice hot path; coalesces duplicate config/bridge snapshots |
+
+## Server: reporting and deployment
+
+- **Report v2** — structured JSON replaces opaque pickle snapshots for bridge/config state on the **2.x monitor** wire. See [Monitoring and reports](../user-guide/monitoring.md) and [Report protocol v2](../protocols/report-v2.md).
+- **Integrated proxy** — `PROXY` runs **in-process**; dropping the standalone **adn-proxy** saves baseline **RAM** (one interpreter, shared config) and simplifies ops.
+
+## Monitor (adn-monitor 2.x)
+
+Pair **adn-server 2.x** with **adn-monitor 2.x** to get the reporting-side gains:
+
+| Change | Effect |
+|--------|--------|
+| **Slim wire / `dashboard_state`** | Monitor ingests compact JSON state instead of holding full duplicated pickle trees from v1 |
+| **`clean_sys_dict`** | Periodic eviction of stale in-memory entries (caps runaway growth on long-lived panels) |
+| **Last-heard row cache, lighter WS fingerprints** | Less work per dashboard refresh |
+| **Unified FastAPI stack** | Removed separate PHP API and standalone monitor **proxy** process |
+
+Details: [Monitor architecture](../../monitor/architecture.md).
+
+## When you will notice a difference
+
+| Deployment | CPU | RAM |
+|------------|-----|-----|
+| Few masters, no inject proxy, light traffic | Small | Small |
+| **Inject-only proxy, many hotspots, busy TG** | **Clear** (downlink index) | Moderate (single server process vs server+proxy) |
+| Long-lived monitor + report v2 | Moderate (less serialize on wire) | **Clearer** on monitor (slim state, `clean_sys_dict`) |
+
+Crypto, AMBE, and OpenBridge MAC work still dominate on OpenBridge-heavy paths — routing-table optimizations do not remove that cost.
+
+## Related reading
+
+- [Architecture](architecture.md) — layers and entrypoint
+- [BRIDGES vs Subscriptions](bridges-vs-subscriptions.md) — routing model (not a performance feature)
+- [Behaviour and timers](behaviour-and-timers.md) — event-driven OPTIONS vs legacy 26 s loop
+- [Hotspot proxy](../user-guide/hotspot-proxy.md) — integrated `PROXY` / inject-only
+- [Report protocol v2](../protocols/report-v2.md) — JSON wire to monitor
+- Release notes: `CHANGELOG.md` at the repository root (`Performance` under **2.0.0-rc.1**).
diff --git a/docs/en/server/protocols/report-v2.md b/docs/en/server/protocols/report-v2.md
index d78e3be..0200fb5 100644
--- a/docs/en/server/protocols/report-v2.md
+++ b/docs/en/server/protocols/report-v2.md
@@ -28,6 +28,22 @@ Replace monitor snapshots that today use **pickle** (`CONFIG_SND`, `BRIDGE_SND`)
Proposed opcodes `0x10`–`0x13` are reserved in the schema phase; exact values may change before P1-002 ships.
+```mermaid
+flowchart TB
+ subgraph v1 [Report v1 — adn-server 1.0.x + monitor 1.0.x]
+ H1[HELLO protocol 1] --> C1[CONFIG_SND pickle]
+ C1 --> B1[BRIDGE_SND pickle]
+ B1 --> V1[BRDG_EVENT CSV]
+ end
+
+ subgraph v2 [Report v2 — adn-server 2.x + monitor 2.x]
+ H2[HELLO report_protocol 2] --> T2[TOPOLOGY_SND JSON]
+ T2 --> R2[ROUTING_TABLE_SND JSON]
+ R2 --> V2[VOICE_EVENT_SND JSON]
+ R2 -.-> D2[DELTA_SND optional]
+ end
+```
+
## Handshake (`hello`)
On connect the server sends **`HELLO` (`0xFF`)** first (same as today). v2 clients inspect `report_protocol`:
diff --git a/docs/en/server/user-guide/bridges-and-talkgroups.md b/docs/en/server/user-guide/bridges-and-talkgroups.md
index cfbc2bb..5a05de1 100644
--- a/docs/en/server/user-guide/bridges-and-talkgroups.md
+++ b/docs/en/server/user-guide/bridges-and-talkgroups.md
@@ -12,11 +12,37 @@ The bridge table maps **talkgroup keys** (strings, e.g. `"26811"`, `"#reflector"
The router scans `BRIDGES` for an **ACTIVE** row matching the **current source system**, **slot**, and **destination TG** before forwarding (`dmrd_received` → `to_target`).
+In **adn-server 2.x** the same rules live in **`SubscriptionStore`** / **`SubscriptionRouter`**; `BRIDGES` is only an export shape for the monitor. See [BRIDGES vs Subscriptions](../development/bridges-vs-subscriptions.md).
+
## Dynamic vs static
- **User-activated** bridges are created when a user keys a TG without a pre-built row (subject to `DEFAULT_UA_TIMER` and options).
- **Static** TGs and **STAT** bridges are created from **OPTIONS** / `make_static_tg` / `GEN_STAT_BRIDGES` flows.
+## Dynamic TG persistence (MariaDB) {#dynamic-tg-persistence-mariadb}
+
+Since **2.0.0-rc.3**, user-activated dynamic TGs for each hotspot can be **persisted in MariaDB** (`peer_dynamic_tgs`) so they survive **hotspot disconnect/reconnect** without re-keying the TG.
+
+| Event | Server behaviour |
+|-------|------------------|
+| **Group voice header** (new dynamic TG on a slot) | Registers UA session in memory and **async upsert** to `peer_dynamic_tgs`. |
+| **RPTC** (hotspot login OK) | **Restores** rows for that peer/system into memory and re-syncs bridge rows (`ensure_dynamic_relay`). |
+| **TG 4000** | Clears **all** dynamic slots for that peer (memory + DB). See [Special numbers — TG 4000](special-numbers.md#tg--id-4000--deactivate-dynamic-bridges). |
+| **Hotspot disconnect** | Clears per-peer **mirror** state only; persisted rows and global `_PEER_UA_*` maps are kept until expiry or TG 4000. |
+| **Periodic purge** | Every **60 s**, expired **SINGLE=1** rows are removed from DB and memory. |
+
+**SINGLE=0** peers accumulate several dynamic TGs per slot in memory (`_PEER_UA_MULTI_TGS`). **SINGLE=1** stores one exclusive TG per slot with a timer.
+
+**TG 4000** is never stored as a dynamic session (reset command only).
+
+Requires **`DATABASE`** in `adn-server.yaml` — see [Configuration](configuration.md#database-mariadb).
+
+## Cross-slot static TG downlink (inject-only)
+
+On **inject-only** MASTER systems (integrated **`PROXY`**), group voice downlink respects **static TGs listed in either TS1 or TS2 OPTIONS**, even when the **wire timeslot** differs. This matches legacy REPEAT behaviour for hotspots that list a TG on one slot but transmit on another.
+
+The server does **not** rewrite the incoming DMRD slot; it filters **which peers receive** the repeated packet via `peer_should_receive_group_voice` and the downlink index.
+
## Source-row guard and safe iteration
Forwarding is allowed only when the current system has a matching **ACTIVE source row** for that TG/slot context. This prevents accidental forwarding from rows that are present but not currently eligible as source legs.
diff --git a/docs/en/server/user-guide/configuration.md b/docs/en/server/user-guide/configuration.md
index 143fc8a..728b7c8 100644
--- a/docs/en/server/user-guide/configuration.md
+++ b/docs/en/server/user-guide/configuration.md
@@ -29,9 +29,9 @@ kill -HUP $(pidof adn-server.py) # or: systemctl reload adn-server
Example unit: **`examples/systemd/adn-server.service`** (copy to `/etc/systemd/system/`; includes `ExecReload` for `systemctl reload`).
-**Reload applies:** `GLOBAL`, `REPORTS`, `ALIASES`, **`LOGGER.LOG_LEVEL`** (without process restart), **`PROXY`** (timeouts, debug, block lists — not bind or target), **`SELF_SERVICE`** (merged; enabling/disabling DB loops needs restart), per-system settings, **new/removed SYSTEMS** (including `GENERATOR` expansion/collapse and new OpenBridge legs), and updated bind addresses (listener restart for that system only).
+**Reload applies:** `GLOBAL`, `REPORTS`, `ALIASES`, **`LOGGER.LOG_LEVEL`** (without process restart), **`PROXY`** (timeouts, debug, block lists — not bind or target), **`SELF_SERVICE`** (PBKDF2 flags merged; enabling/disabling DB loops needs restart), per-system settings, **new/removed SYSTEMS** (including `GENERATOR` expansion/collapse and new OpenBridge legs), and updated bind addresses (listener restart for that system only).
-**Not reloaded:** `adn-voice.yaml` (separate 15 s loop), Python code, subscriber alias files (separate periodic reload). **BRIDGES** table is not rebuilt on reload — restart if bridge rules changed in a way that requires a full reset. **`PROXY.LISTEN_PORT`**, **`LISTEN_IP`**, and **`TARGET_SYSTEM`** require a **full restart** to take effect.
+**Not reloaded:** **`DATABASE`** (MariaDB pool and `peer_dynamic_tgs` bootstrap), `adn-voice.yaml` (separate 15 s loop), Python code, subscriber alias files (separate periodic reload). **BRIDGES** table is not rebuilt on reload — restart if bridge rules changed in a way that requires a full reset. **`PROXY.LISTEN_PORT`**, **`LISTEN_IP`**, and **`TARGET_SYSTEM`** require a **full restart** to take effect.
**Secrets:** Never commit real passphrases, security URLs, or `user_passwords.json` / `encryption_key.secret`. Use placeholders in templates and keep production files local.
@@ -169,7 +169,7 @@ For the conceptual model (ACTIVE, TS, TGID, timeouts): [Bridges and talkgroups](
## `REPORTS`
-TCP report channel for **adn-monitor** (or compatible dashboards).
+TCP report channel for **adn-monitor** (or compatible dashboards). **adn-server 2.x** speaks **report v2** only; legacy dashboards that expect **v1** need the optional [report-proxy](report-proxy.md) (separate package).
| Key | Meaning |
|-----|---------|
@@ -178,7 +178,7 @@ TCP report channel for **adn-monitor** (or compatible dashboards).
| **REPORT_PORT** | Local port the **server listens on** for report clients. |
| **REPORT_CLIENTS** | Comma-separated or list of allowed client IPs (see example). |
-Details: [Monitoring and reports](monitoring.md).
+Details: [Monitoring and reports](monitoring.md). Legacy v1 monitors: [Report proxy](report-proxy.md).
---
@@ -198,9 +198,38 @@ Do **not** run standalone **`adn-proxy`** on the same **`LISTEN_PORT`** when the
---
+## `DATABASE` (MariaDB)
+
+**Required** for typical conference-server configs: any deployment with **`PROXY`**, or at least one **`MASTER`** / **`OPENBRIDGE`** system. **Not** required for minimal **echo-only** PEER fleets (`adn-server.py --echo`).
+
+| Key | Meaning |
+|-----|---------|
+| **DB_SERVER** | MariaDB/MySQL host. |
+| **DB_USERNAME** / **DB_PASSWORD** | Credentials. |
+| **DB_NAME** | Database name (often the same as **adn-monitor**, e.g. `hbmon`). |
+| **DB_PORT** | TCP port (default **3306**). |
+
+**Uses one shared connection pool** for:
+
+- **Dynamic TG persistence** — table **`peer_dynamic_tgs`** (per-peer user-activated TGs across hotspot reconnects). The server **creates the table on startup** if missing (migration id **`004_peer_dynamic_tgs`**, same schema as adn-monitor).
+- **Integrated self-service** — table **`Clients`** when **`SELF_SERVICE.USE_SELFSERVICE: true`**.
+
+Startup aborts with a clear log if MariaDB is unreachable or **`DATABASE`** is incomplete. Install **`mysqlclient`** (`pip install -e ".[selfservice]"` includes it).
+
+**Hot reload:** changing **`DATABASE`** requires a **full process restart**.
+
+Details: [Bridges and talkgroups — dynamic TG persistence](bridges-and-talkgroups.md#dynamic-tg-persistence-mariadb).
+
+---
+
## `SELF_SERVICE` (MySQL / dashboard options)
-Optional; requires `pip install -e ".[selfservice]"` when **`USE_SELFSERVICE: true`**. Uses the same **`Clients`** table and PBKDF2 parameters as **adn-monitor**. Keys match the monitor docs — see [Self-service](../../monitor/self-service.md) and [Hotspot proxy](hotspot-proxy.md#self_service-keys).
+Optional; requires `pip install -e ".[selfservice]"` when **`USE_SELFSERVICE: true`**. Uses the **`DATABASE`** block above (not separate DB keys in **`SELF_SERVICE`**). PBKDF2 parameters must **match** **adn-monitor**. See [Self-service](../../monitor/self-service.md) and [Hotspot proxy](hotspot-proxy.md#self_service-keys).
+
+| Key | Meaning |
+|-----|---------|
+| **USE_SELFSERVICE** | Enable MySQL-backed options sync from the dashboard (`true` / `false`). |
+| **PBKDF2_SALT** / **PBKDF2_ITERATIONS** | Must match **`adn-monitor.yaml`** / password tooling. |
---
diff --git a/docs/en/server/user-guide/hotspot-proxy.md b/docs/en/server/user-guide/hotspot-proxy.md
index 0acc149..3650697 100644
--- a/docs/en/server/user-guide/hotspot-proxy.md
+++ b/docs/en/server/user-guide/hotspot-proxy.md
@@ -14,6 +14,13 @@ Configuration lives in **`adn-server.yaml`** under **`PROXY`** and optional **`S
The integrated proxy uses **fan-in**: hotspots only need **`PROXY.LISTEN_PORT`** (e.g. **62031**). The target **MASTER** is **inject-only** — it does **not** bind its own UDP port for that system (no per-hotspot port range on the server host).
+```mermaid
+flowchart LR
+ HS1[Hotspot A] -->|UDP HBP| LP[PROXY LISTEN_PORT]
+ HS2[Hotspot B] -->|UDP HBP| LP
+ LP -->|inject| MASTER[TARGET_SYSTEM MASTER]
+```
+
---
## Optional dependency (self-service)
@@ -72,9 +79,10 @@ Same semantics as **`adn-monitor.yaml`** — shared **`Clients`** table, **`modi
| Key | Role |
|-----|------|
| **USE_SELFSERVICE** | Enable MySQL-backed options sync (`true` / `false`). |
-| **DB_SERVER**, **DB_USERNAME**, **DB_PASSWORD**, **DB_NAME**, **DB_PORT** | MySQL connection. |
| **PBKDF2_SALT**, **PBKDF2_ITERATIONS** | Must **match** monitor/backend for password hashing. |
+MariaDB connection settings live in the top-level **`DATABASE`** block (shared with dynamic TG persistence) — see [Configuration](configuration.md#database-mariadb).
+
On startup the server logs **`(SELF_SERVICE) Database connection test: OK`** and **`(SELF_SERVICE) Enabled`** when the pool connects. Self-service runs **asynchronously**; voice forwarding is not blocked on DB latency.
Details of the dashboard flow: [Self-service](../../monitor/self-service.md).
diff --git a/docs/en/server/user-guide/introduction.md b/docs/en/server/user-guide/introduction.md
index 4fa25f5..7a49696 100644
--- a/docs/en/server/user-guide/introduction.md
+++ b/docs/en/server/user-guide/introduction.md
@@ -17,8 +17,8 @@ Routing, timers, OpenBridge loop control, and protocol handling are implemented
| Subsystem | Role |
|-----------|------|
-| **Bridge router** | `BRIDGES` table: which systems forward which TG on which slot; dynamic bridges; static/stat bridges. |
-| **HBP protocol** | Authentication, DMRD ingress/egress, repeat to peers, TG filters. |
+| **Bridge router** | `BRIDGES` table: which systems forward which TG on which slot; dynamic bridges; static/stat bridges; **MariaDB dynamic TG restore** on reconnect. |
+| **HBP protocol** | Authentication, DMRD ingress/egress, repeat to peers, TG filters, **per-peer UA session** tracking. |
| **OpenBridge** | DMRE ingress, hop limit, loop control (`min(1ST)`), BCSQ/BCKA when enabled. |
| **Voice** | AMBE files, scheduled announcements, TTS pipeline, on-demand playback (TG 9991–9999). |
| **Reporting** | TCP netstring channel to **adn-monitor** (and compatible dashboards): config, bridge state, call events (report v2 JSON). |
@@ -28,12 +28,14 @@ Routing, timers, OpenBridge loop control, and protocol handling are implemented
- **Echo / playback** — `adn-server.py --echo` with minimal `adn-echo.yaml`; see [Echo](echo.md).
- **Integrated hotspot proxy** — `PROXY` in **`adn-server.yaml`**; see [Hotspot proxy](hotspot-proxy.md).
+- **Report proxy (legacy dashboards)** — optional **[ADN-report-proxy](https://github.com/ce5rpy/ADN-report-proxy)** so **adn-server 2.x** can feed old HBMonitor / FDMR-style monitors (v1 wire); see [Report proxy](report-proxy.md). Not used with **adn-monitor 2.x**.
## Next steps
-- [Configuration](configuration.md) — files, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACLs, reports, **`PROXY`**, **`SELF_SERVICE`**, aliases, voice merge.
+- [Configuration](configuration.md) — files, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACLs, **`DATABASE`**, reports, **`PROXY`**, **`SELF_SERVICE`**, aliases, voice merge.
- [Bridges and talkgroups](bridges-and-talkgroups.md) — how `BRIDGES` works.
- [Special numbers](special-numbers.md) — TG 4000, information services, echo.
- [Hotspot proxy](hotspot-proxy.md) — integrated **`PROXY`** / **`SELF_SERVICE`** in `adn-server.yaml`.
- [ADN Monitor](../../monitor/index.md) — dashboard, `adn-monitor.yaml`, self-service UI (separate repo, deployed with the server).
+- [Performance (2.x)](../development/performance.md) — CPU/RAM improvements in this release and what causes them.
- [Credits & license](attribution.md) — ADN → FreeDMR → hblink3, license.
diff --git a/docs/en/server/user-guide/monitoring.md b/docs/en/server/user-guide/monitoring.md
index fa7390f..696d6bd 100644
--- a/docs/en/server/user-guide/monitoring.md
+++ b/docs/en/server/user-guide/monitoring.md
@@ -12,12 +12,26 @@ When **`REPORTS`** is enabled in the server config, the **ADN DMR Peer Server**
**Version pairing:** **server 1.0.x + monitor 1.0.x** = report v1 (frozen tags). **server 2.x** emits **report v2 only** — requires **monitor 2.x** on the same line. No `dual` wire; monitor 1.0.x will not decode this server.
+### Legacy dashboards (report-proxy)
+
+If you keep an **old dashboard** whose backend monitor speaks **report v1** only (pickle/CSV, no HELLO v2), it **cannot** connect to **adn-server 2.x** on `REPORTS.REPORT_PORT`. Use the optional **[ADN-report-proxy](https://github.com/ce5rpy/ADN-report-proxy)** to translate **v2 → v1**: the proxy connects to the server; the legacy monitor connects to the proxy. **adn-monitor 2.x** does **not** need this proxy — connect it directly to the server.
+
+See [Report proxy (legacy dashboards)](report-proxy.md) for topology, `REPORT_CLIENTS`, ports, and start order.
+
Older stacks (**legacy** `adn-dmr-server`-style) may **omit** HELLO. **adn-monitor** waits up to **`ADN_CONNECTION.HELLO_TIMEOUT_MS`** (see [Monitor configuration](../../monitor/configuration.md#adn_connection)); if no HELLO arrives, it assumes **legacy** reporting.
The **monitor** decodes these messages, updates its **CTABLE** / **BTABLE**, and (when MySQL is configured) persists Last Heard / statistics.
**Full stack:** [ADN Monitor overview](../../monitor/index.md) (FastAPI monitor, WebSocket, self-service).
+```mermaid
+flowchart LR
+ SRV[adn-server\nREPORTS.REPORT_PORT] -->|TCP netstring| ING[adn-monitor ingest]
+ ING --> STATE[CTABLE / BTABLE]
+ STATE --> WS[WebSocket /ws]
+ WS --> UI[React dashboard]
+```
+
### Report channel log lines (`adn-monitor` logger)
Python uses the logger name **`adn-monitor`** (see **`LOGGER.LOG_FILE`** in `adn-monitor.yaml`). Typical **INFO** lines for the TCP report client:
@@ -40,6 +54,19 @@ At **WARNING**: invalid HELLO JSON (`(REPORT) HELLO payload not valid JSON`), or
The dashboard shows **operational** state from **START** (canonical); the **Monitor** log shows **INGRESS** plus **START** for troubleshooting mesh duplicates.
+### Dynamic UA chips (hotspot OPTIONS)
+
+The monitor tracks **user-activated** TGs per hotspot for dashboard indigo chips:
+
+| Peer OPTIONS | Monitor source |
+|--------------|----------------|
+| **SINGLE=1** | **`UA_SESSIONS`** in **CONFIG_SND** / `dashboard_state` (server source of truth). |
+| **SINGLE=0** | Voice events (`BRDG_EVENT` / `voice_event`) — multiple dynamics per slot until cleared. |
+
+**TG 4000** clears UA state via **`GROUP VOICE,INGRESS,RX`** with destination **4000** (server sends this because the voice path returns early and never emits a normal **START**). The monitor must **not** register **4000** as a dynamic TG.
+
+**Version pairing:** **adn-server 2.0.0-rc.3** + **adn-monitor 2.0.0-rc.4** for dynamic TG persistence and TG 4000 monitor sync.
+
## Log file rotation (logrotate)
After **logrotate** renames or moves a log file (common pattern: **`create`** so the old path is rotated away and a **new empty file** appears at the configured path), the process may still hold an open file descriptor on the **previous inode**. Logs then appear “missing” from the current path until the process **reopens** its file handlers.
diff --git a/docs/en/server/user-guide/report-proxy.md b/docs/en/server/user-guide/report-proxy.md
new file mode 100644
index 0000000..2a7a403
--- /dev/null
+++ b/docs/en/server/user-guide/report-proxy.md
@@ -0,0 +1,100 @@
+# Report proxy (legacy dashboards)
+
+**ADN DMR Peer Server 2.x** emits **report wire v2** (JSON over TCP). **adn-monitor 2.x** understands that protocol and connects **directly** to the server — no extra component is required.
+
+Some **legacy dashboard stacks** still ship their own `dashboard.py` / `monitor.py` backend and speak **report wire v1** only (pickled `CONFIG_SND` / `BRIDGE_SND`, CSV `BRDG_EVENT`). Those monitors **cannot** connect to **adn-server 2.x** on the report port.
+
+The optional **[ADN-report-proxy](https://github.com/ce5rpy/ADN-report-proxy)** package sits between the two: it connects **upstream** to the real server (v2), listens **downstream** where the legacy monitor expects the server (v1), and translates **v2 → v1**.
+
+| Stack | Upstream server | Works without proxy? |
+|-------|-----------------|----------------------|
+| **adn-monitor 2.x** (React) | **adn-server 2.x** | Yes — connect to `REPORTS.REPORT_PORT` |
+| Legacy dashboard + bundled monitor (v1) | **adn-dmr-server** (v1) | Yes — direct to server report port |
+| Legacy dashboard + bundled monitor (v1) | **adn-server 2.x** (v2) | **No** — use **report-proxy** |
+
+Typical legacy targets: old **ADN-Dashboard** forks, **HBMonitor** / **FDMR Monitor** deployments that still run a Python monitor process against `dashboard.cfg` / `monitor.cfg`.
+
+## Topology
+
+```text
+┌───────────────────┐
+│ adn-server │
+│ LISTENS :4321 │
+└─────────▲─────────┘
+ │
+ │ TCP v2 JSON
+ │ (report-proxy is CLIENT)
+ │
+┌─────────┴─────────┐
+│ report-proxy │
+│ LISTENS :4322 │
+└─────────▲─────────┘
+ │
+ │ TCP v1 pickle
+ │ (legacy dashboard is CLIENT)
+ │
+┌─────────┴─────────┐
+│ legacy dashboard │
+│ monitor.py │
+└───────────────────┘
+```
+
+| Component | Role | Default port | Config | Key setting |
+|-----------|------|--------------|--------|-------------|
+| **adn-server** | Listens for report clients | **4321** | `adn-server.yaml` | `REPORTS.REPORT_PORT` |
+| **report-proxy** | Connects to the server | 4321 | `report-proxy.yaml` | `UPSTREAM.PORT` |
+| **report-proxy** | Listens for the legacy monitor | **4322** | `report-proxy.yaml` | `LISTEN.PORT` |
+| **Legacy dashboard** | Connects to the proxy | **4322** | `dashboard.cfg` | `SERVER_PORT` |
+
+**Do not** point the legacy dashboard at **4321** — that is the server’s v2 port.
+
+**Do not** set `UPSTREAM.PORT` to **4322** — that is the proxy’s own listen port.
+
+## Server side (`adn-server.yaml`)
+
+Reporting must be enabled and the **proxy host IP** must be in the allow list:
+
+```yaml
+REPORTS:
+ REPORT: true
+ REPORT_INTERVAL: 60
+ REPORT_PORT: 4321
+ REPORT_CLIENTS: "127.0.0.1" # IP of the machine running report-proxy
+```
+
+If the proxy runs on another host, use **that host’s IP** in `REPORT_CLIENTS`, not only `127.0.0.1`. See [Configuration](configuration.md#reports) for all `REPORTS` keys.
+
+## Proxy and legacy dashboard
+
+Install and run the proxy from the **[ADN-report-proxy](https://github.com/ce5rpy/ADN-report-proxy)** repository (`report-proxy.yaml`, `python3 report-proxy.py -c report-proxy.yaml`). Point `UPSTREAM` at the server’s `REPORT_PORT` and `LISTEN` at the port the legacy monitor uses (often **4322**).
+
+In legacy `dashboard.cfg` / `monitor.cfg`:
+
+```ini
+[SERVER CONNECTION]
+SERVER_IP = 127.0.0.1
+SERVER_PORT = 4322
+```
+
+`SERVER_IP` is the host where **report-proxy** listens, not necessarily the adn-server host.
+
+**Start order:** adn-server → report-proxy → legacy monitor backend.
+
+Full step-by-step, multi-host examples, verification checks, and common mistakes: **[ADN-report-proxy README](https://github.com/ce5rpy/ADN-report-proxy#configuration-legacy-dashboard--adn-server-2x)**.
+
+## Wire translation (summary)
+
+| Upstream (v2 from adn-server) | Downstream (v1 to legacy monitor) |
+|--------------------------------|-----------------------------------|
+| `HELLO` (`report_protocol: 2`) | `HELLO` (`protocol: 1`) |
+| `STATE_SND` / `dashboard_state` | `CONFIG_SND` (pickle) |
+| `ROUTING_TABLE_SND` | `BRIDGE_SND` (pickle) |
+| `TOPOLOGY_SND` | `CONFIG_SND` (pickle) |
+| `VOICE_EVENT_SND` | `BRDG_EVENT` (CSV) |
+
+Schema detail for v2: [Report protocol v2 (JSON)](../protocols/report-v2.md).
+
+## See also
+
+- [Monitoring and reports](monitoring.md) — report channel, **adn-monitor** pairing, log lines.
+- [ADN Monitor overview](../../monitor/index.md) — preferred dashboard for **adn-server 2.x** (no proxy).
diff --git a/docs/en/server/user-guide/special-numbers.md b/docs/en/server/user-guide/special-numbers.md
index 3f70ec1..f195bb9 100644
--- a/docs/en/server/user-guide/special-numbers.md
+++ b/docs/en/server/user-guide/special-numbers.md
@@ -56,19 +56,24 @@ In-band bridge activation/deactivation is applied on **voice terminator (VTERM)*
- For reflector bridges (`#...`), in-band handling is evaluated only when the destination is **TG 9**.
- This is why reflector prompts and dial wiring are tied to TG 9 while private calls do not trigger that bridge-timer logic.
-## TG / ID 4000 — deactivate dynamic bridges
+## TG / ID 4000 — deactivate dynamic bridges {#tg--id-4000--deactivate-dynamic-bridges}
-**Purpose:** Clear **user-activated (dynamic) bridges** for the system that receives the call.
+**Purpose:** Clear **user-activated (dynamic)** state for the hotspot that keys **4000**. **TG 4000 is not** a talkgroup to monitor or persist — it is a **reset command**.
-**Behaviour:**
+**Behaviour (group voice header):**
+
+- Clears per-peer UA sessions in memory (**all slots** for that peer).
+- Deletes matching rows from **`peer_dynamic_tgs`** (MariaDB).
+- Clears stale **STATUS** RX fields so a later **RPTO** does not re-seed the old TG.
+- Runs **in-band bridge deactivation** on the slot (same as legacy).
+- Sends **`GROUP VOICE,INGRESS,RX,…,4000`** to the monitor (not **START**) so **SINGLE=0** multi-dynamic chips clear **without** lighting a live TX chip.
+- On **inject-only** MASTER, pushes updated **CONFIG_SND** to the monitor.
-- Implemented for **group** traffic to destination **4000** (and related checks in the router).
-- Runs **before** normal TG ACL in the OpenBridge path so the command is not blocked by allow lists.
-- Invokes **`deactivate_all_dynamic_bridges`**: deactivates non-stat, non-reflector dynamic bridge rows.
+**Inject-only vs global:** With integrated **`PROXY`**, reset is **per peer** (only that hotspot’s dynamics). Without inject-only filtering, legacy **`deactivate_all_dynamic_bridges`** still runs for the whole system.
-Use this when operators need to **reset** dynamic routing without restarting the server.
+**TG 4000 must never appear** as a dynamic UA chip on the monitor or in `peer_dynamic_tgs`.
-### `SINGLE_MODE` impact on deactivation logic
+### `SINGLE_MODE` impact on in-band deactivation
When in-band rules evaluate deactivation on a MASTER slot:
@@ -93,6 +98,8 @@ The **audio** is sent with **source ID 5000** and **destination TG 9** in the ge
**Purpose:** Bridge rows for **echo** often use **9990** with the **ECHO** system (see `BRIDGES` and options in your YAML).
+**`SINGLE=1`:** Keying **9990** does **not** create an exclusive listen session (same as **4000**). Downlink echo always returns to the calling hotspot even when another TG holds the SINGLE lock. See [Hotspot proxy](hotspot-proxy.md#behaviour-with-multiple-hotspots).
+
**Note:** A **standalone echo** is also available as a separate process — [Echo](echo.md).
## Private call to ID 4000
diff --git a/docs/es/README.md b/docs/es/README.md
index aec91d2..8e1b060 100644
--- a/docs/es/README.md
+++ b/docs/es/README.md
@@ -23,6 +23,7 @@ El **ADN DMR Peer Server** es un puente de conferencia [GPL-3.0](https://www.gnu
| TG 4000, 999x, eco | [Números especiales](server/user-guide/special-numbers.md) |
| Llamadas privadas | [Llamadas privadas](server/user-guide/private-calls.md) |
| Voz / TTS | [Voz, anuncios y TTS](server/user-guide/voice-and-tts.md) |
+| Panel legacy + servidor 2.x | [Proxy de informes](server/user-guide/report-proxy.md) |
| OpenBridge / DMRE | [OpenBridge](server/protocols/openbridge.md), [DMRE v5](server/protocols/dmre-v5.md) |
| HBP | [HBP](server/protocols/hbp.md) |
| Código | [Arquitectura](server/development/architecture.md), [Comportamiento y temporizadores](server/development/behaviour-and-timers.md) |
@@ -33,6 +34,7 @@ El **ADN DMR Peer Server** es un puente de conferencia [GPL-3.0](https://www.gnu
```bash
pip install -r requirements.txt
cp adn-server.example.yaml adn-server.yaml
+# Edita DATABASE (MariaDB) y secretos antes de producción
python adn-server.py -c adn-server.yaml
```
diff --git a/docs/es/monitor/architecture.md b/docs/es/monitor/architecture.md
index f72758c..dfef025 100644
--- a/docs/es/monitor/architecture.md
+++ b/docs/es/monitor/architecture.md
@@ -10,6 +10,14 @@ Bajo `monitor/src/adn_monitor/`:
El **composition root** está en `infrastructure/fastapi/composition.py` (`build_monitor_api`).
+```mermaid
+flowchart TD
+ INF["Infraestructura
YAML · FastAPI /ws · ingest TCP/MQTT
MySQL · decodificadores pickle/JSON"]
+ APP["Aplicación
MonitorState · informes/dashboard
auth · self-service · alias"]
+ DOM["Dominio
objetos de valor · errores · opcodes
UserSession · Result"]
+ INF --> APP --> DOM
+```
+
## Proceso unificado (`monitor.py`)
Un solo proceso uvicorn/FastAPI:
@@ -39,15 +47,19 @@ El proceso **`adn-proxy`** independiente se eliminó del repositorio **adn-monit
## Topología típica
-```text
-[Hotspots] --UDP--> [adn-server PROXY] --UDP--> [peer MASTER]
- |
- v
- MySQL (Clients)
+```mermaid
+flowchart TD
+ HS[Hotspots] -->|UDP HBP| PROXY[adn-server PROXY]
+ PROXY --> MASTER[MASTER inyectado]
+ PROXY --> DB[(MySQL Clients)]
-[Peer :REPORT_PORT] <--- TCP o MQTT --- [monitor.py ingest]
+ MASTER --> REPORT[REPORTS listener]
+ REPORT <-->|TCP o MQTT| ING[monitor.py ingest]
+ ING --> APP[FastAPI /api /ws]
+ APP --> DB
-[Navegador] --HTTPS--> [Nginx: frontend/dist + proxy /api,/ws --> :8080]
+ BR[Navegador] -->|HTTPS| FE[Nginx o SERVE_STATIC]
+ FE --> APP
```
---
diff --git a/docs/es/monitor/configuration.md b/docs/es/monitor/configuration.md
index 8e76741..55906ed 100644
--- a/docs/es/monitor/configuration.md
+++ b/docs/es/monitor/configuration.md
@@ -120,7 +120,9 @@ No hay Alembic: el monitor usa **`schema_migrations`** y comprobaciones en **`in
- **Replace:** carga en `{tabla}_import` con **commit cada 10 000 filas** (la tabla live sigue legible); swap atómico `RENAME TABLE` (bloqueo metadata breve).
- **Merge** (ficheros locales): `INSERT IGNORE` con **commit cada 2 000 filas**.
-Migraciones: `001_clients_callsign`, `002_clients_options_width`, `003_alias_pk_only`.
+Migraciones: `001_clients_callsign`, `002_clients_options_width`, `003_alias_pk_only`, **`004_peer_dynamic_tgs`** (tabla compartida con **adn-server 2.0.0-rc.3+**).
+
+**adn-server** también asegura **`peer_dynamic_tgs`** al arrancar (idempotente). Cualquiera de los dos caminos basta; ambos pueden usar la misma base **`hbmon`**.
---
diff --git a/docs/es/monitor/index.md b/docs/es/monitor/index.md
index 42f9dd1..a989b1f 100644
--- a/docs/es/monitor/index.md
+++ b/docs/es/monitor/index.md
@@ -18,7 +18,9 @@ Este capítulo documenta la pila **adn-monitor** con el mismo nivel de detalle q
| **`adn-server.yaml`** | **`adn-server.py`** (**`PROXY`** / **`SELF_SERVICE`** integrados) | `-c` / ruta por defecto junto al binario |
| **`monitor/adn-monitor.yaml`** | **`monitor.py`** | **`ADN_CONFIG_PATH`** |
-**`SELF_SERVICE`** (MySQL / PBKDF2) debe **coincidir** entre **`adn-server.yaml`** y **`adn-monitor.yaml`**. **`ADN_CONNECTION`**, panel, WebSocket y alias van en **`adn-monitor.yaml`**; **`PROXY`** integrado va en **`adn-server.yaml`** — ver [Proxy hotspot integrado](../server/user-guide/hotspot-proxy.md).
+**`SELF_SERVICE`** (MySQL / PBKDF2) debe **coincidir** entre **`adn-server.yaml`** y **`adn-monitor.yaml`**. En el servidor, las credenciales MariaDB van en **`DATABASE`** (pool compartido con **`peer_dynamic_tgs`**). **`ADN_CONNECTION`**, panel, WebSocket y alias van en **`adn-monitor.yaml`**; **`PROXY`** integrado va en **`adn-server.yaml`** — ver [Proxy hotspot integrado](../server/user-guide/hotspot-proxy.md).
+
+**Emparejamiento recomendado:** **adn-server 2.0.0-rc.3** + **adn-monitor 2.0.0-rc.4** (persistencia TG dinámicos, sincronización TG 4000 en monitor).
## Enlace con el peer server
diff --git a/docs/es/monitor/self-service.md b/docs/es/monitor/self-service.md
index 96a8227..37b02fd 100644
--- a/docs/es/monitor/self-service.md
+++ b/docs/es/monitor/self-service.md
@@ -41,6 +41,23 @@ La sesión se prolonga con actividad (**SelfServiceController** usa un timeout l
## Flujo extremo a extremo (cómo llegan las opciones al hotspot)
+```mermaid
+sequenceDiagram
+ participant UI as React /self-service
+ participant API as monitor FastAPI
+ participant DB as MySQL Clients
+ participant SRV as adn-server PROXY
+ participant HS as Hotspot
+
+ UI->>API: POST /api/self-service/device/options
+ API->>DB: UPDATE options, modified = 1
+ Note over SRV: bucle send_opts ~cada 10 s
+ SRV->>DB: lee filas con modified = 1
+ SRV->>SRV: RPTO en pata MASTER
+ SRV->>HS: OPTIONS vía HBP
+ SRV->>DB: limpia modified
+```
+
1. El usuario guarda opciones en la web → la **API del monitor** escribe **`Clients.options`** y **`modified = 1`**.
2. El **proxy hotspot** ejecuta **`send_opts`** en bucle (~cada **10 s**). Para filas con **`modified = 1`**, lee opciones de la BD, envía **RPTO** **al peer server** en **`(MASTER, puerto_destino_asignado)`**, luego limpia **`modified`** en la BD.
3. El **ADN DMR Peer Server** recibe **RPTO** en la pata MASTER y actualiza su estado **OPTIONS** / bridge (mismo camino que un refresco normal de registro del hotspot).
diff --git a/docs/es/server/development/architecture.md b/docs/es/server/development/architecture.md
index 6190812..1db3780 100644
--- a/docs/es/server/development/architecture.md
+++ b/docs/es/server/development/architecture.md
@@ -8,6 +8,14 @@
**Regla de dependencias:** infraestructura → aplicación → dominio (solo hacia dentro).
+```mermaid
+flowchart TD
+ INF["Infraestructura
Twisted UDP/TCP · YAML · voz
report_server · persistencia · seguridad"]
+ APP["Aplicación
RoutingUseCases · VoiceUseCases
ports · SubscriptionStore · router"]
+ DOM["Dominio
entidades · value objects · errors · Result"]
+ INF --> APP --> DOM
+```
+
## Punto de entrada
`main.py` cablea configuración, temporizadores **LoopingCall**, fábricas para **HBPProtocol**, servidor de informes e inyecta casos de uso.
@@ -16,6 +24,10 @@
El enrutado de voz lo gobierna **`SubscriptionStore`** (suscripciones de dominio). `RoutingUseCases` orquesta `dmrd_received` y delega la resolución de reenvío a **`SubscriptionRouter`**.
+Comparación conceptual con **`BRIDGES`** legacy: [BRIDGES vs Subscriptions](bridges-vs-subscriptions.md).
+
+Cambios de rendimiento en 2.x (índices, informes, proxy integrado): [Rendimiento (2.x)](performance.md).
+
- **`InMemoryAclRouter`** (port `AclRouter`) — solo comprobaciones ACL (`acl_check`).
- **`routing_table_for_report()`** — shim de exportación para monitor/informes (forma legacy BRIDGE_SND); no se usa para reenvíos en runtime.
@@ -32,6 +44,7 @@ Los opcodes wire y claves YAML pueden seguir diciendo “bridge” por compatibi
| Voz / TTS | `application/voice_use_cases.py`, `infrastructure/voice/` |
| Proxy hotspot (fan-in) | `infrastructure/proxy/` (`udp_fanin.py`, `runtime.py`), casos de uso en `application/proxy/` |
| Self-service (MySQL) | `infrastructure/proxy/self_service_bridge.py`, `infrastructure/proxy/persistence/` |
+| Persistencia TG dinámicos | `application/dynamic_tg_use_cases.py`, `infrastructure/persistence/dynamic_tg_repository.py`, `application/routing/dynamic_tg_restore.py` |
## Configuración como estado compartido
diff --git a/docs/es/server/development/behaviour-and-timers.md b/docs/es/server/development/behaviour-and-timers.md
index b303041..314098c 100644
--- a/docs/es/server/development/behaviour-and-timers.md
+++ b/docs/es/server/development/behaviour-and-timers.md
@@ -20,6 +20,7 @@ Los siguientes intervalos forman parte del comportamiento actual en ejecución:
| `stream_trimmer` | **5s** | Limpieza de streams, manejo de timeout y cierre de estado de llamada. |
| `bridge_reset` | **6s** | Limpieza de flags de reset y cierre de resets pendientes. |
| OPTIONS refresh | **por evento** | TG estáticas / reflector vía **RPTO**, **startup/reload** (`apply_startup_bridges`), fallback **dmrd** sin source. Sin loop periódico de 26s (**D-28**). |
+| `dynamic_tg_purge_loop` | **60s** | Purga filas **SINGLE=1** expiradas de `peer_dynamic_tgs` y `_PEER_UA_SESSIONS` en memoria. |
| `statTrimmer` | **303s** | Limpieza de bridges STAT obsoletos y estados transitorios. |
Si cambias uno de estos intervalos, documenta el impacto operativo en monitorización, comportamiento de bucles y troubleshooting.
diff --git a/docs/es/server/development/bridges-vs-subscriptions.md b/docs/es/server/development/bridges-vs-subscriptions.md
new file mode 100644
index 0000000..4022fc1
--- /dev/null
+++ b/docs/es/server/development/bridges-vs-subscriptions.md
@@ -0,0 +1,208 @@
+# BRIDGES (legacy) vs Subscriptions (servidor nuevo)
+
+**adn-dmr-server** y **adn-server 2.x** reenvían voz de grupo igual en el wire: una “tabla de bridge” por talkgroup decide qué **systems** reciben copia del stream, con **reescritura LC** por pata. Lo que cambia en 2.x es **cómo se representa esa tabla en código** — no las reglas visibles para el operador.
+
+## Para el operador
+
+En runtime el comportamiento de bridges es el mismo: TG, slots, OPTIONS, UA, TG 4000, OpenBridge. **Subscriptions** es el nombre interno del motor de enrutado en 2.x; no es un modo de operación distinto ni algo que configures aparte.
+
+**Sin cambios operativos**
+
+- Configuración: **`SYSTEMS`**, OPTIONS del hotspot, **`SELF_SERVICE`** / MariaDB — igual que con **adn-dmr-server**. No hay bloque `BRIDGES` en YAML en ninguno de los dos.
+- Paridad de reglas: fila origen ACTIVE, tablas `#…`, timers UA, `GEN_STAT_BRIDGES`, etc.
+
+**Mejoras reales en 2.x**
+
+| Área | Efecto |
+|------|--------|
+| **Estabilidad del enrutado** | El estado de bridges vive en un store dedicado; informes y timers ya no comparten la misma estructura mutable que el forward de voz. Menos desvíos entre lo que reenvía el servidor y lo que muestra el panel bajo carga. |
+| **Monitor** | **Informe v2** (`routing_table`, `topology`) hacia **adn-monitor 2.x** sustituye pickle/CSV; el BTABLE refleja mejor el estado del peer. |
+| **TG dinámicos** | Con **`DATABASE`**, los dinámicos por peer se **persisten** y se restauran al reconectar (≥ 2.0.0-rc.3). |
+| **Evolución** | Parches de timers, OpenBridge, ACL o self-service no pasan por un dict global compartido con todo el proceso. |
+
+El término **subscription** solo importa si lees código o esta guía; en el panel y en el aire sigues hablando de **bridges** y **talkgroups**.
+
+---
+
+## Resumen
+
+| | **Legacy (`adn-dmr-server`)** | **Nuevo (`adn-server` 2.x)** |
+|---|------------------------------|------------------------------|
+| **Autoridad en runtime** | Dict global `BRIDGES` (`bridge_master.py`) | **`SubscriptionStore`** (objetos `Subscription` de dominio) |
+| **Estructura** | `bridge_key → [ fila, fila, … ]` | Una **subscription** por pata de system en un canal |
+| **Resolución de reenvío** | Recorrer filas, llamar `to_target` | **`SubscriptionRouter.resolve()`** → `ForwardLeg` |
+| **Wire monitor / informes** | Pickle `BRIDGE_SND` = `BRIDGES` | JSON `routing_table` (v2) o export `BRIDGES` (compat v1) |
+| **Bloque YAML `BRIDGES:`** | No se carga desde config en ninguno; filas en runtime | Igual — filas desde OPTIONS, UA, STAT, OpenBridge, bootstrap echo |
+
+El comportamiento observable (guardia de fila origen, UA dinámico, TG estática, claves reflector `#…`, match OpenBridge TS1, timers) sigue **paridad legacy** con `bridge_master.py`.
+
+## Legacy: dict `BRIDGES`
+
+En **adn-dmr-server**, el estado de enrutado es un **diccionario global**:
+
+```text
+BRIDGES["52090"] = [
+ { "SYSTEM": "MASTER-A", "TS": 2, "TGID": b'...', "ACTIVE": True, "TO_TYPE": "ON", "TIMER": …, … },
+ { "SYSTEM": "MASTER-B", "TS": 2, "TGID": b'...', "ACTIVE": True, "TO_TYPE": "ON", … },
+ { "SYSTEM": "OBP-UK", "TS": 1, "TGID": b'...', "ACTIVE": False, … },
+]
+BRIDGES["#310"] = [ … ] # tablas reflector / marcado
+```
+
+Cada **fila** es una pata. Campos importantes:
+
+- **`SYSTEM`** — nombre del system configurado (master HBP o pata OpenBridge).
+- **`TS`** — slot 1 o 2 (fuentes OpenBridge usan **TS 1** en el match).
+- **`TGID`** — bytes de destino para **reescritura LC** hacia esa pata.
+- **`ACTIVE`** — la pata participa en el reenvío si es true.
+- **`TIMEOUT` / `TIMER` / `TO_TYPE` / `ON` / `OFF` / `RESET`** — timers UA, static/stat, reglas VTERM in-band.
+
+**Camino de voz (`dmrd_received`):**
+
+1. Obtener **clave de bridge** desde la TG destino (y tablas `#…` cuando aplica).
+2. Crear tabla dinámica si no existe (UA / STAT / OPTIONS estáticas — mismos disparadores que legacy).
+3. Buscar **fila origen ACTIVE** que coincida con **system + slot + TGID** actuales.
+4. Por cada otra fila **ACTIVE** en **esa misma tabla**, llamar **`to_target`** (contención, ACL, LC/TA, control de bucle OpenBridge).
+
+```mermaid
+flowchart TB
+ IN[Ingreso DMRD / DMRE] --> DM[dmrd_received]
+ DM --> KEY{¿existe bridge key?}
+ KEY -->|no| CREATE[ensure dynamic / stat / static]
+ CREATE --> BR
+ KEY -->|si| BR[(dict BRIDGES)]
+ BR --> SRC{¿fila origen ACTIVE\nSYSTEM + TS + TGID?}
+ SRC -->|si| SCAN[otras patas ACTIVE\nmisma tabla]
+ SCAN --> TT[to_target por pata]
+ TT --> OUT[Salida HBP / OpenBridge]
+ SRC -->|no| DROP[sin reenvío]
+```
+
+El monitor lee el **mismo dict** vía pickle **`BRIDGE_SND`**.
+
+## Servidor nuevo: `Subscription` + `SubscriptionStore`
+
+En **adn-server 2.x**, el **modelo de dominio** sustituye filas ad hoc:
+
+- **`AudioChannel`** — TG lógica + slot `(tgid, slot)`.
+- **`Subscription`** — participación de un system: **role**, **política de activación**, **state** (fase, timer), **target_tgid** (LC), **`relay_table_key`** opcional (tablas `#…`).
+- **`SubscriptionStore`** — única **autoridad de enrutado** en runtime (sin mutar `BRIDGES` en paralelo).
+
+**Camino de voz** (misma semántica, otros tipos):
+
+1. `RoutingUseCases.dmrd_received` actualiza el store (crear relay, TG estática, timeout UA — hooks legacy).
+2. **`SubscriptionRouter.relay_tables_with_active_source`** — tablas donde el system de ingreso tiene subscription **ACTIVE** para slot/TG.
+3. **`SubscriptionRouter.resolve`** — devuelve **`ForwardLeg`** (system, slot, tgid) del resto de subscriptions **ACTIVE** en esas tablas.
+4. Mixins de forward envían paquetes (paridad `to_target`).
+
+```mermaid
+flowchart TB
+ IN[Ingreso DMRD / DMRE] --> RU[RoutingUseCases.dmrd_received]
+ RU --> SS[(SubscriptionStore)]
+ RU --> SR[SubscriptionRouter]
+ SS --> SR
+ SR --> LEGS[Lista ForwardLeg]
+ LEGS --> FWD[Mixins HBP / OBP]
+ SS --> EXP[RoutingTableLegacyView\nsolo export]
+ EXP --> MON[Pickle BRIDGE_SND\no JSON routing_table]
+```
+
+**Importante:** `routing_table_for_report()` / **`BRIDGE_SND`** es un **export unidireccional** para paneles (`RoutingTableLegacyView`). **No** decide reenvíos. Evita el patrón legacy de mutar un dict global compartido con informes.
+
+## Mapeo fila → subscription
+
+| Fila legacy `BRIDGES` | `Subscription` de dominio |
+|-----------------------|---------------------------|
+| Clave tabla (`"52090"`, `"#310"`) | `relay_table_key` + TG del canal |
+| `SYSTEM` | `system` (`SystemId`) |
+| `TS` + contexto TG | `channel.slot` / `channel.tgid` |
+| `TGID` (bytes) | `target_tgid` (reescritura LC) |
+| `ACTIVE` | `state.phase` (`ACTIVE` / `IDLE`) |
+| `TIMER` | `state.timer_expires_at` |
+| `TIMEOUT` | `timeout_seconds` |
+| `TO_TYPE` (`ON`, `OFF`, `STAT`, `NONE`) | `role` + `policy` |
+| `ON` / `OFF` / `RESET` | `triggers` (`InbandTriggers`) |
+
+Helpers import/export: `routing_table_import.py`, `routing_table_export.py` (espejo de `bridges_export` legacy).
+
+## Comparación extremo a extremo (un frame de voz)
+
+```mermaid
+sequenceDiagram
+ participant Radio
+ participant Server
+ participant Peer as Otro system
+
+ Radio->>Server: Voz grupo TG 52090 TS2
+
+ rect rgb(40,40,50)
+ note right of Server: Legacy
+ Server->>Server: ¿Fila origen ACTIVE en BRIDGES["52090"]?
+ Server->>Server: foreach pata ACTIVE en tabla
+ Server->>Peer: to_target (reescribe LC)
+ end
+
+ rect rgb(30,50,40)
+ note right of Server: Servidor nuevo (mismas reglas)
+ Server->>Server: SubscriptionRouter.resolve()
+ Server->>Peer: forward ForwardLeg(s)
+ end
+```
+
+## Lo que **no** cambió
+
+- **Claves** de bridge (`52090`, `#reflector`, …) y tablas **multi-pata**.
+- **Guardia de fila origen** — reenviar solo desde una tabla donde **este** system es origen ACTIVE para ese contexto TG/slot.
+- **UA dinámico**, **OPTIONS estáticas**, **`GEN_STAT_BRIDGES`**, **TG 4000**, bootstrap **echo 9990**.
+- Pasadas de timer (`rule_timer`, `bridgeDebug`, …) — misma tabla lógica, implementada sobre el store en 2.x.
+
+## Ejemplo concreto
+
+**Bridge** (concepto de red): “la TG 52090 une estos systems y reenvía voz entre ellos”.
+**Subscription** (solo en código 2.x): **una pata** de esa tabla — p. ej. “MASTER-A en TG 52090, slot 2, ACTIVE, con su LC y timer”.
+
+No es bridge *o* subscription: en 2.x un bridge **es** un conjunto de subscriptions sobre el mismo canal (TG + slot).
+
+### Escenario
+
+Alguien habla en **TG 52090** y deben oírlo **MASTER-A**, **MASTER-B** y una pata **OpenBridge**. En el panel y en el aire eso es un **bridge** (tabla de la TG 52090).
+
+**Legacy (`adn-dmr-server`)** — todo en un dict global:
+
+```text
+BRIDGES["52090"] = [
+ { SYSTEM: "MASTER-A", TS: 2, ACTIVE: True, TGID: …, TIMER: … },
+ { SYSTEM: "MASTER-B", TS: 2, ACTIVE: True, TGID: … },
+ { SYSTEM: "OBP-UK", TS: 1, ACTIVE: False, TGID: … },
+]
+```
+
+Cuando llega un frame de voz:
+
+1. Buscar la fila donde **este** system es origen **ACTIVE** (mismo TG/slot).
+2. Recorrer las **demás filas ACTIVE** de la misma tabla.
+3. Por cada una, **`to_target`** → reenvío con LC reescrito.
+
+El monitor lee **el mismo dict** (pickle **`BRIDGE_SND`**).
+
+**Servidor nuevo (`adn-server` 2.x)** — misma tabla, otro formato interno:
+
+```text
+SubscriptionStore — TG 52090 / slot 2:
+ - subscription MASTER-A (ACTIVE, target_tgid, timer…)
+ - subscription MASTER-B (ACTIVE, …)
+ - subscription OBP-UK (IDLE, …)
+```
+
+Cuando llega voz, **`SubscriptionRouter.resolve()`** aplica las **mismas reglas** (origen ACTIVE, resto de patas ACTIVE) y devuelve **`ForwardLeg`** para reenviar. El monitor recibe una **vista exportada** (`BRIDGE_SND` o JSON **`routing_table`**); esa exportación **no** decide el reenvío.
+
+## Dónde leer código
+
+| Tema | Legacy | Nuevo |
+|------|--------|-------|
+| Ingreso voz | `adn-dmr-server/bridge_master.py` (`routerHBP.dmrd_received`) | `application/routing_use_cases.py` |
+| Reenvío a pata | `to_target` | `application/routing/hbp_forward.py`, `obp_forward.py` |
+| Estado tabla | `BRIDGES` global | `application/subscription/` (`store`, `router`, ops) |
+| Export monitor | `send_routing_table` / pickle | `routing_table_legacy_view.py`, informe v2 `routing_table` |
+
+Ver también: [Bridges y talkgroups](../user-guide/bridges-and-talkgroups.md), [Arquitectura](architecture.md), [Rendimiento (2.x)](performance.md), [Protocolo de informes v2](../protocols/report-v2.md#routing_table).
diff --git a/docs/es/server/development/performance.md b/docs/es/server/development/performance.md
new file mode 100644
index 0000000..41a2179
--- /dev/null
+++ b/docs/es/server/development/performance.md
@@ -0,0 +1,99 @@
+# Rendimiento (2.x)
+
+**adn-server 2.x** y **adn-monitor 2.x** incluyen varios cambios que reducen trabajo de CPU y huella de memoria frente a **adn-dmr-server** y al stack antiguo de monitor/proxy. Esta página resume **qué** mejora y **qué lo provoca**.
+
+## Resumen
+
+| Área | Efecto típico | Causa principal |
+|------|---------------|-----------------|
+| **Downlink de voz (proxy inject)** | Menos CPU con tráfico de grupo intenso | **`PeerDownlinkIndex`** — fan-out solo a peers que encajan `(slot, TG)` en lugar de escanear todos los hotspots por paquete |
+| **Origen ACTIVE en bridge** | Lookup más rápido | **Índices del `SubscriptionStore`** (`relay_tables_with_active_source`) — O(1) por `(system, slot, tgid)` frente a recorrer filas |
+| **CPU de fondo** | Menos despertares | **OPTIONS / TG estática por eventos** — eliminado el bucle legacy cada **26 s** `options_config_loop` ([Comportamiento y temporizadores](behaviour-and-timers.md)) |
+| **Ráfaga de logins** | Menos CONFIG redundante | **`ConfigPushThrottle`** — debounce adaptativo al empujar CONFIG al monitor |
+| **Informes vs voz** | La voz se bloquea menos por informes | **`BoundedReportQueue`** — snapshots coalescidos, drenado acotado por tick |
+| **Cable servidor → monitor** | Menos serializar/enviar | **Informe v2** JSON (`routing_table`, `topology`, `voice_event`) en lugar de pickle periódico de `CONFIG`/`BRIDGES` ([Protocolo de informes v2](../protocols/report-v2.md)) |
+| **Procesos (RAM)** | Un proceso Python en lugar de dos | **`PROXY` integrado** en `adn-server.py` — sin proceso **adn-proxy** aparte ([Proxy hotspot](../user-guide/hotspot-proxy.md)) |
+| **RAM / WS del monitor** | Estado de panel más compacto | **Wire slim `dashboard_state`**, `clean_sys_dict`, fingerprints WS más ligeros ([Arquitectura del monitor](../../monitor/architecture.md)) |
+
+## Servidor: índice de downlink inject-only
+
+La mayor ganancia de **CPU** en muchas redes ADN está en el camino **MASTER inject-only** (`PROXY` en modo inject-only).
+
+**Legacy:** `send_peers` recorre **todos los peers registrados** por cada paquete de downlink → coste **O(peers × paquetes/s)**.
+
+**2.x:** `PeerDownlinkIndex` precalcula candidatos desde **OPTIONS** (TG estáticas) y estado **UA** de cada peer. Por cada frame de voz de grupo solo se consideran peers que **podrían** querer ese `(slot, TG)`; cada candidato sigue pasando `peer_should_receive_group_voice`.
+
+```text
+Legacy: cada DMRD → probar los N peers
+2.x: cada DMRD → lookup en índice → probar k peers (k ≪ N en proxies cargados)
+```
+
+El parse de OPTIONS se **guarda en caché por peer** (`_CACHED_OPTIONS_STATIC`): si el blob OPTIONS no cambió, se reutilizan las TG estáticas ya parseadas en lugar de volver a interpretarlo en cada paquete.
+
+| Código | Rol |
+|--------|-----|
+| `application/routing/peer_downlink_index.py` | Construcción del índice y `(slot, tgid) → candidatos` |
+| `infrastructure/twisted_adapters/udp_hbp.py` | `_iter_downlink_peers`, `send_peers` |
+| `tests/infrastructure/test_peer_downlink_fanout.py` | Tests de fan-out inject-only |
+
+**Cuándo se nota:** proxy con **decenas o cientos** de hotspots y voz de grupo continua. En una conferencia pequeña con pocos peers, la diferencia es pequeña.
+
+## Servidor: índices de enrutado
+
+En cada frame de voz de grupo el servidor debe encontrar tablas donde **este system es origen ACTIVE**.
+
+**Legacy:** recorrer filas dentro de `BRIDGES[clave]`.
+
+**2.x:** `InMemorySubscriptionStore.relay_tables_with_active_source()` usa el índice **`_source_tables`** — lookup por `(system, slot, dst_tgid)` sin recorrer todas las patas.
+
+Está en la implementación del store; es un **índice algorítmico**, no una opción de configuración aparte.
+
+| Código | Rol |
+|--------|-----|
+| `infrastructure/subscription_store.py` | `_source_tables`, `_by_table`, `_active_target_counts` |
+| `application/subscription/router.py` | `SubscriptionRouter.resolve()` |
+
+## Servidor: menos trabajo periódico y en tormenta de logins
+
+| Cambio | Qué evita |
+|--------|-----------|
+| **Sin bucle OPTIONS 26 s** | Timer cada 26 s en todos los systems cuando RPTO/arranque/reload ya refrescan bridges estáticos |
+| **`ConfigPushThrottle`** | Inundar al monitor con snapshots CONFIG cuando muchos peers conectan en pocos segundos (debounce ~0,3 s → ~2 s en ráfaga) |
+| **`BoundedReportQueue`** | Encode pickle/JSON y envío TCP en el hot path de voz; coalesce de snapshots config/bridge duplicados |
+
+## Servidor: informes y despliegue
+
+- **Informe v2** — JSON estructurado sustituye snapshots pickle opacos de bridge/config en el cable hacia **monitor 2.x**. Ver [Monitor e informes](../user-guide/monitoring.md) y [Protocolo de informes v2](../protocols/report-v2.md).
+- **Proxy integrado** — `PROXY` **in-process**; quitar **adn-proxy** standalone ahorra **RAM** base (un intérprete, config compartida) y simplifica operación.
+
+## Monitor (adn-monitor 2.x)
+
+Empareja **adn-server 2.x** con **adn-monitor 2.x** para las mejoras del lado informes:
+
+| Cambio | Efecto |
+|--------|--------|
+| **Wire slim / `dashboard_state`** | El monitor ingiere JSON compacto en lugar de duplicar árboles pickle v1 |
+| **`clean_sys_dict`** | Expulsión periódica de entradas obsoletas en memoria (tope de crecimiento en paneles largos) |
+| **Caché lastheard, fingerprints WS ligeros** | Menos trabajo por refresco del dashboard |
+| **Stack FastAPI unificado** | Eliminados API PHP y proceso **proxy** standalone del monitor |
+
+Detalle: [Arquitectura del monitor](../../monitor/architecture.md).
+
+## Cuándo se nota la diferencia
+
+| Despliegue | CPU | RAM |
+|------------|-----|-----|
+| Pocos masters, sin proxy inject, tráfico bajo | Poca | Poca |
+| **Proxy inject-only, muchos hotspots, TG activa** | **Clara** (índice downlink) | Moderada (un proceso servidor vs servidor+proxy) |
+| Monitor largo + informe v2 | Moderada (menos serializar en cable) | **Más clara** en monitor (estado slim, `clean_sys_dict`) |
+
+Crypto, AMBE y MAC OpenBridge siguen dominando en tramos OBP cargados — optimizar la tabla de bridge no elimina ese coste.
+
+## Lecturas relacionadas
+
+- [Arquitectura](architecture.md) — capas y entrypoint
+- [BRIDGES vs Subscriptions](bridges-vs-subscriptions.md) — modelo de enrutado (no es feature de rendimiento)
+- [Comportamiento y temporizadores](behaviour-and-timers.md) — OPTIONS por eventos vs bucle 26 s legacy
+- [Proxy hotspot](../user-guide/hotspot-proxy.md) — `PROXY` integrado / inject-only
+- [Protocolo de informes v2](../protocols/report-v2.md) — cable JSON al monitor
+- Notas de versión: `CHANGELOG.md` en la raíz del repositorio (`Performance` en **2.0.0-rc.1**).
diff --git a/docs/es/server/protocols/report-v2.md b/docs/es/server/protocols/report-v2.md
index 404d614..46c40b1 100644
--- a/docs/es/server/protocols/report-v2.md
+++ b/docs/es/server/protocols/report-v2.md
@@ -28,6 +28,22 @@ Sustituir instantáneas al monitor que hoy usan **pickle** (`CONFIG_SND`, `BRIDG
Los opcodes `0x10`–`0x13` están reservados en esta fase; pueden ajustarse antes de P1-002.
+```mermaid
+flowchart TB
+ subgraph v1 [Informe v1 — adn-server 1.0.x + monitor 1.0.x]
+ H1[HELLO protocol 1] --> C1[CONFIG_SND pickle]
+ C1 --> B1[BRIDGE_SND pickle]
+ B1 --> V1[BRDG_EVENT CSV]
+ end
+
+ subgraph v2 [Informe v2 — adn-server 2.x + monitor 2.x]
+ H2[HELLO report_protocol 2] --> T2[TOPOLOGY_SND JSON]
+ T2 --> R2[ROUTING_TABLE_SND JSON]
+ R2 --> V2[VOICE_EVENT_SND JSON]
+ R2 -.-> D2[DELTA_SND opcional]
+ end
+```
+
## Handshake (`hello`)
Al conectar, el servidor envía **`HELLO` (`0xFF`)** primero. Clientes v2 miran `report_protocol`:
diff --git a/docs/es/server/user-guide/bridges-and-talkgroups.md b/docs/es/server/user-guide/bridges-and-talkgroups.md
index afda545..6c8fb65 100644
--- a/docs/es/server/user-guide/bridges-and-talkgroups.md
+++ b/docs/es/server/user-guide/bridges-and-talkgroups.md
@@ -12,11 +12,37 @@ La tabla de bridges asocia **claves de talkgroup** (cadenas, p. ej. `"26811"`, `
El router recorre `BRIDGES` buscando una fila **ACTIVE** que coincida con el **sistema de origen actual**, **slot** y **TG de destino** antes de reenviar (`dmrd_received` → `to_target`).
+En **adn-server 2.x** las mismas reglas están en **`SubscriptionStore`** / **`SubscriptionRouter`**; `BRIDGES` es solo forma de export para el monitor. Ver [BRIDGES vs Subscriptions](../development/bridges-vs-subscriptions.md).
+
## Dinámico frente a estático
- Los bridges **activados por usuario** se crean cuando alguien pulsa una TG sin fila previa (sujeto a `DEFAULT_UA_TIMER` y opciones).
- Las TG **estáticas** y bridges **STAT** se crean desde flujos **OPTIONS** / `make_static_tg` / `GEN_STAT_BRIDGES`.
+## Persistencia TG dinámicos (MariaDB) {#persistencia-tg-dinamicos-mariadb}
+
+Desde **2.0.0-rc.3**, los TG dinámicos activados por usuario de cada hotspot pueden **persistirse en MariaDB** (`peer_dynamic_tgs`) para sobrevivir a **desconexión/reconexión** sin volver a pulsar el TG.
+
+| Evento | Comportamiento del servidor |
+|--------|----------------------------|
+| **Cabecera de voz de grupo** (nuevo TG dinámico en un slot) | Registra sesión UA en memoria y **upsert asíncrono** en `peer_dynamic_tgs`. |
+| **RPTC** (login OK del hotspot) | **Restaura** filas de ese peer/system en memoria y re-sincroniza bridges (`ensure_dynamic_relay`). |
+| **TG 4000** | Borra **todos** los slots dinámicos de ese peer (memoria + BD). Ver [Números especiales — TG 4000](special-numbers.md#tg--id-4000--desactivar-bridges-dinamicos). |
+| **Desconexión del hotspot** | Solo limpia el **espejo** por peer; las filas persistidas y mapas globales `_PEER_UA_*` se mantienen hasta expiración o TG 4000. |
+| **Purga periódica** | Cada **60 s**, filas **SINGLE=1** expiradas se eliminan de BD y memoria. |
+
+Peers **SINGLE=0** acumulan varios TG dinámicos por slot (`_PEER_UA_MULTI_TGS`). **SINGLE=1** guarda un TG exclusivo por slot con temporizador.
+
+**TG 4000** nunca se almacena como sesión dinámica (solo comando de reset).
+
+Requiere **`DATABASE`** en `adn-server.yaml` — ver [Configuración](configuration.md#database-mariadb).
+
+## Downlink cross-slot de TG estáticas (inject-only)
+
+En MASTER **inject-only** ( **`PROXY`** integrado), el downlink de voz de grupo respeta **TG estáticas listadas en OPTIONS de TS1 o TS2**, aunque el **slot en cable** sea otro. Equivale al comportamiento legacy REPEAT para hotspots que listan un TG en un slot y transmiten en otro.
+
+El servidor **no** reescribe el slot del DMRD entrante; filtra **a qué peers reenvía** el paquete repetido con `peer_should_receive_group_voice` y el índice de downlink.
+
## Guardia de fila de origen e iteración segura
El reenvío solo se permite cuando el sistema actual tiene una **fila de origen ACTIVE** que coincide con ese contexto TG/slot. Esto evita reenviar desde filas presentes pero no elegibles como patas de origen.
diff --git a/docs/es/server/user-guide/configuration.md b/docs/es/server/user-guide/configuration.md
index 7262cce..c1041df 100644
--- a/docs/es/server/user-guide/configuration.md
+++ b/docs/es/server/user-guide/configuration.md
@@ -29,9 +29,9 @@ kill -HUP $(pidof adn-server.py) # o: systemctl reload adn-server
Unidad de ejemplo: **`examples/systemd/adn-server.service`** (copiar a `/etc/systemd/system/`; incluye `ExecReload` para `systemctl reload`).
-**Se recarga:** `GLOBAL`, `REPORTS`, `ALIASES`, **`LOGGER.LOG_LEVEL`** (sin reiniciar el proceso), **`PROXY`** (timeouts, debug, listas de bloqueo — no bind ni destino), **`SELF_SERVICE`** (fusionado; activar/desactivar bucles BD requiere reinicio), parámetros por system, **systems nuevos/eliminados** (incluida expansión/colapso `GENERATOR` y OBP nuevos), y cambios de IP/puerto (solo reinicia el listener de ese system).
+**Se recarga:** `GLOBAL`, `REPORTS`, `ALIASES`, **`LOGGER.LOG_LEVEL`** (sin reiniciar el proceso), **`PROXY`** (timeouts, debug, listas de bloqueo — no bind ni destino), **`SELF_SERVICE`** (flags PBKDF2 fusionados; activar/desactivar bucles BD requiere reinicio), parámetros por system, **systems nuevos/eliminados** (incluida expansión/colapso `GENERATOR` y OBP nuevos), y cambios de IP/puerto (solo reinicia el listener de ese system).
-**No se recarga:** `adn-voice.yaml` (loop aparte cada 15 s), código Python, ficheros de alias (recarga periódica). La tabla **BRIDGES** no se reconstruye — reinicia si cambiaste reglas de bridge que exijan reset completo. **`PROXY.LISTEN_PORT`**, **`LISTEN_IP`** y **`TARGET_SYSTEM`** requieren **reinicio completo** para aplicarse.
+**No se recarga:** **`DATABASE`** (pool MariaDB y bootstrap `peer_dynamic_tgs`), `adn-voice.yaml` (loop aparte cada 15 s), código Python, ficheros de alias (recarga periódica). La tabla **BRIDGES** no se reconstruye — reinicia si cambiaste reglas de bridge que exijan reset completo. **`PROXY.LISTEN_PORT`**, **`LISTEN_IP`** y **`TARGET_SYSTEM`** requieren **reinicio completo** para aplicarse.
**Secretos:** no versionar passphrases reales, URLs de seguridad ni `user_passwords.json` / `encryption_key.secret`. Usa placeholders en plantillas y mantén producción en local.
@@ -169,7 +169,7 @@ Para el modelo conceptual (ACTIVE, TS, TGID, timeouts): [Bridges y talkgroups](b
## `REPORTS`
-Canal TCP de informes para **adn-monitor** (o paneles compatibles).
+Canal TCP de informes para **adn-monitor** (o paneles compatibles). **adn-server 2.x** habla **solo informe v2**; los paneles legacy que esperan **v1** necesitan el opcional [report-proxy](report-proxy.md) (paquete aparte).
| Clave | Significado |
|-------|-------------|
@@ -178,7 +178,7 @@ Canal TCP de informes para **adn-monitor** (o paneles compatibles).
| **REPORT_PORT** | Puerto local en el que el **servidor escucha** clientes de informes. |
| **REPORT_CLIENTS** | Lista separada por comas o lista de IPs de clientes permitidos (ver ejemplo). |
-Detalle: [Monitor e informes](monitoring.md).
+Detalle: [Monitor e informes](monitoring.md). Monitores legacy v1: [Proxy de informes](report-proxy.md).
---
@@ -198,9 +198,38 @@ Se arranca siempre que exista un bloque **`PROXY`** (ver `adn-server.example.yam
---
+## `DATABASE` (MariaDB)
+
+**Obligatorio** en configs típicas de servidor de conferencia: cualquier despliegue con **`PROXY`**, o al menos un system **`MASTER`** / **`OPENBRIDGE`**. **No** es obligatorio en flotas **solo echo** (`adn-server.py --echo`).
+
+| Clave | Significado |
+|-------|-------------|
+| **DB_SERVER** | Host MariaDB/MySQL. |
+| **DB_USERNAME** / **DB_PASSWORD** | Credenciales. |
+| **DB_NAME** | Nombre de la base (a menudo la misma que **adn-monitor**, p. ej. `hbmon`). |
+| **DB_PORT** | Puerto TCP (por defecto **3306**). |
+
+**Un solo pool** compartido para:
+
+- **Persistencia de TG dinámicos** — tabla **`peer_dynamic_tgs`** (TG activados por usuario por hotspot entre reconexiones). El servidor **crea la tabla al arranque** si falta (migración **`004_peer_dynamic_tgs`**, mismo esquema que adn-monitor).
+- **Self-service integrado** — tabla **`Clients`** con **`SELF_SERVICE.USE_SELFSERVICE: true`**.
+
+El arranque aborta con log claro si MariaDB no responde o **`DATABASE`** está incompleto. Instala **`mysqlclient`** (`pip install -e ".[selfservice]"` lo incluye).
+
+**Recarga en caliente:** cambiar **`DATABASE`** exige **reinicio completo** del proceso.
+
+Detalle: [Bridges y talkgroups — persistencia TG dinámicos](bridges-and-talkgroups.md#persistencia-tg-dinamicos-mariadb).
+
+---
+
## `SELF_SERVICE` (MySQL / opciones del panel)
-Opcional; requiere `pip install -e ".[selfservice]"` con **`USE_SELFSERVICE: true`**. Usa la misma tabla **`Clients`** y parámetros PBKDF2 que **adn-monitor**. Las claves coinciden con la documentación del monitor — ver [Self-service](../../monitor/self-service.md) y [Proxy hotspot](hotspot-proxy.md#claves-self_service).
+Opcional; requiere `pip install -e ".[selfservice]"` con **`USE_SELFSERVICE: true`**. Usa el bloque **`DATABASE`** anterior (sin claves DB separadas en **`SELF_SERVICE`**). Los parámetros PBKDF2 deben **coincidir** con **adn-monitor**. Ver [Self-service](../../monitor/self-service.md) y [Proxy hotspot](hotspot-proxy.md#claves-self_service).
+
+| Clave | Significado |
+|-------|-------------|
+| **USE_SELFSERVICE** | Activa sincronización de opciones desde el panel (`true` / `false`). |
+| **PBKDF2_SALT** / **PBKDF2_ITERATIONS** | Deben coincidir con **`adn-monitor.yaml`** / herramienta de contraseñas. |
---
diff --git a/docs/es/server/user-guide/hotspot-proxy.md b/docs/es/server/user-guide/hotspot-proxy.md
index 6614ca0..457ebde 100644
--- a/docs/es/server/user-guide/hotspot-proxy.md
+++ b/docs/es/server/user-guide/hotspot-proxy.md
@@ -14,6 +14,13 @@ La configuración está en **`adn-server.yaml`**, bloques **`PROXY`** y opcional
El proxy integrado usa **fan-in**: los hotspots solo necesitan **`PROXY.LISTEN_PORT`** (p. ej. **62031**). El **MASTER** destino es **solo inyección** — **no** abre su propio puerto UDP para ese system (sin rango de puertos por hotspot en el host del servidor).
+```mermaid
+flowchart LR
+ HS1[Hotspot A] -->|UDP HBP| LP[PROXY LISTEN_PORT]
+ HS2[Hotspot B] -->|UDP HBP| LP
+ LP -->|inyecta| MASTER[TARGET_SYSTEM MASTER]
+```
+
---
## Dependencia opcional (self-service)
@@ -72,9 +79,10 @@ Misma semántica que **`adn-monitor.yaml`** — tabla **`Clients`** compartida,
| Clave | Rol |
|-------|-----|
| **USE_SELFSERVICE** | Activa sincronización de opciones con MySQL (`true` / `false`). |
-| **DB_SERVER**, **DB_USERNAME**, **DB_PASSWORD**, **DB_NAME**, **DB_PORT** | Conexión MySQL. |
| **PBKDF2_SALT**, **PBKDF2_ITERATIONS** | Deben **coincidir** con **`adn-monitor.yaml`** para el hash de contraseñas. |
+La conexión MariaDB está en el bloque **`DATABASE`** (compartido con persistencia de TG dinámicos) — ver [Configuración](configuration.md#database-mariadb).
+
Al arrancar el servidor registra **`(SELF_SERVICE) Database connection test: OK`** y **`(SELF_SERVICE) Enabled`** si el pool conecta. El self-service es **asíncrono**; el reenvío de voz no se bloquea por latencia de BD.
Detalle del flujo en el panel: [Self-service](../../monitor/self-service.md).
diff --git a/docs/es/server/user-guide/introduction.md b/docs/es/server/user-guide/introduction.md
index 645a660..a9a5758 100644
--- a/docs/es/server/user-guide/introduction.md
+++ b/docs/es/server/user-guide/introduction.md
@@ -17,8 +17,8 @@ Enrutado, temporizadores, control de bucle OpenBridge y manejo de protocolo est
| Subsistema | Rol |
|------------|-----|
-| **Bridge router** | Tabla `BRIDGES`: qué sistemas reenvían qué TG en qué slot; bridges dinámicos; bridges estáticos/stat. |
-| **Protocolo HBP** | Autenticación, ingreso/salida DMRD, repetición a peers, filtros TG. |
+| **Bridge router** | Tabla `BRIDGES`: qué sistemas reenvían qué TG en qué slot; bridges dinámicos; bridges estáticos/stat; **restauración MariaDB de TG dinámicos** al reconectar. |
+| **Protocolo HBP** | Autenticación, ingreso/salida DMRD, repetición a peers, filtros TG, seguimiento de **sesión UA por peer**. |
| **OpenBridge** | Ingreso DMRE, límite de saltos, control de bucle (`min(1ST)`), BCSQ/BCKA si están habilitados. |
| **Voz** | Ficheros AMBE, anuncios programados, tubería TTS, reproducción bajo demanda (TG 9991–9999). |
| **Informes** | Canal TCP netstring hacia **adn-monitor** (y paneles compatibles): config, estado de bridges, eventos de llamada (informe v2 JSON). |
@@ -28,12 +28,14 @@ Enrutado, temporizadores, control de bucle OpenBridge y manejo de protocolo est
- **Echo / playback** — `adn-server.py --echo` con `adn-echo.yaml` mínimo; ver [Echo](echo.md).
- **Proxy hotspot integrado** — `PROXY` en **`adn-server.yaml`**; ver [Proxy hotspot](hotspot-proxy.md).
+- **Proxy de informes (paneles legacy)** — **[ADN-report-proxy](https://github.com/ce5rpy/ADN-report-proxy)** opcional para que **adn-server 2.x** alimente monitores antiguos estilo HBMonitor / FDMR (wire v1); ver [Proxy de informes](report-proxy.md). No se usa con **adn-monitor 2.x**.
## Siguientes pasos
-- [Configuración](configuration.md) — ficheros, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACL, informes, **`PROXY`**, **`SELF_SERVICE`**, alias, fusión de voz.
+- [Configuración](configuration.md) — ficheros, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACL, **`DATABASE`**, informes, **`PROXY`**, **`SELF_SERVICE`**, alias, fusión de voz.
- [Bridges y talkgroups](bridges-and-talkgroups.md) — cómo funciona `BRIDGES`.
- [Números especiales](special-numbers.md) — TG 4000, servicios de información, eco.
- [Proxy hotspot](hotspot-proxy.md) — **`PROXY`** / **`SELF_SERVICE`** integrados en `adn-server.yaml`.
- [ADN Monitor](../../monitor/index.md) — panel, `adn-monitor.yaml`, UI self-service (repo aparte, desplegado con el servidor).
+- [Rendimiento (2.x)](../development/performance.md) — mejoras de CPU/RAM en esta versión y qué las provoca.
- [Créditos y licencia](attribution.md) — ADN → FreeDMR → hblink3, licencia.
diff --git a/docs/es/server/user-guide/monitoring.md b/docs/es/server/user-guide/monitoring.md
index 0eca217..3d072c9 100644
--- a/docs/es/server/user-guide/monitoring.md
+++ b/docs/es/server/user-guide/monitoring.md
@@ -12,12 +12,26 @@ Cuando **`REPORTS`** está habilitado en la config del servidor, el **ADN DMR Pe
**Acoplamiento de versiones:** **servidor 1.0.x + monitor 1.0.x** = report v1 (tags). **servidor 2.x** emite **solo report v2** — requiere **monitor 2.x**. Sin wire `dual`; monitor 1.0.x no decodifica este servidor.
+### Paneles legacy (report-proxy)
+
+Si mantienes un **panel antiguo** cuyo backend monitor solo habla **informe v1** (pickle/CSV, sin HELLO v2), **no puede** conectarse a **adn-server 2.x** en `REPORTS.REPORT_PORT`. Usa el opcional **[ADN-report-proxy](https://github.com/ce5rpy/ADN-report-proxy)** para traducir **v2 → v1**: el proxy se conecta al servidor; el monitor legacy se conecta al proxy. **adn-monitor 2.x** **no** necesita este proxy — conéctalo directamente al servidor.
+
+Ver [Proxy de informes (paneles legacy)](report-proxy.md) para topología, `REPORT_CLIENTS`, puertos y orden de arranque.
+
Las pilas antiguas (**legado** estilo `adn-dmr-server`) pueden **omitir** HELLO. **adn-monitor** espera hasta **`ADN_CONNECTION.HELLO_TIMEOUT_MS`** (ver [Configuración del monitor](../../monitor/configuration.md#adn_connection)); si no llega HELLO, asume informes **legacy**.
El **monitor** decodifica estos mensajes, actualiza **CTABLE** / **BTABLE** y (con MySQL configurado) persiste Last Heard / estadísticas.
**Pila completa:** [Descripción general del ADN Monitor](../../monitor/index.md) (monitor FastAPI, WebSocket, self-service).
+```mermaid
+flowchart LR
+ SRV[adn-server\nREPORTS.REPORT_PORT] -->|TCP netstring| ING[ingest adn-monitor]
+ ING --> STATE[CTABLE / BTABLE]
+ STATE --> WS[WebSocket /ws]
+ WS --> UI[panel React]
+```
+
### Líneas de log del canal de informes (logger `adn-monitor`)
Python usa el nombre de logger **`adn-monitor`** (ver **`LOGGER.LOG_FILE`** en `adn-monitor.yaml`). **INFO** típicos del cliente TCP de informes:
@@ -40,6 +54,19 @@ En **WARNING**: JSON HELLO inválido (`(REPORT) HELLO payload not valid JSON`),
El panel muestra el estado **operativo** desde **START** (canónico); el **log del Monitor** muestra **INGRESS** más **START** para depurar duplicados en malla.
+### Chips UA dinámicos (OPTIONS del hotspot)
+
+El monitor rastrea TG **activados por usuario** por hotspot para los chips índigo del panel:
+
+| OPTIONS del peer | Fuente en el monitor |
+|----------------|----------------------|
+| **SINGLE=1** | **`UA_SESSIONS`** en **CONFIG_SND** / `dashboard_state` (el servidor es fuente de verdad). |
+| **SINGLE=0** | Eventos de voz (`BRDG_EVENT` / `voice_event`) — varios dinámicos por slot hasta limpiar. |
+
+**TG 4000** limpia el estado UA con **`GROUP VOICE,INGRESS,RX`** y destino **4000** (el servidor lo envía porque la ruta de voz corta antes y no emite un **START** normal). El monitor **no** debe registrar **4000** como TG dinámico.
+
+**Emparejamiento de versiones:** **adn-server 2.0.0-rc.3** + **adn-monitor 2.0.0-rc.4** para persistencia de TG dinámicos y sincronización TG 4000 en el monitor.
+
## Rotación de logs (logrotate)
Tras que **logrotate** renombre o mueva el fichero de log (patrón habitual: **`create`** — el fichero antiguo rota y aparece uno **nuevo vacío** en la ruta configurada), el proceso puede seguir con el descriptor abierto sobre el **inodo anterior**. Los logs parecen “no escribirse” en la ruta actual hasta que el proceso **reabra** los `FileHandler`.
diff --git a/docs/es/server/user-guide/report-proxy.md b/docs/es/server/user-guide/report-proxy.md
new file mode 100644
index 0000000..76bdc81
--- /dev/null
+++ b/docs/es/server/user-guide/report-proxy.md
@@ -0,0 +1,100 @@
+# Proxy de informes (paneles legacy)
+
+**ADN DMR Peer Server 2.x** emite **informe wire v2** (JSON por TCP). **adn-monitor 2.x** entiende ese protocolo y se conecta **directamente** al servidor — no hace falta ningún componente extra.
+
+Algunos **stacks de panel legacy** siguen trayendo su propio backend `dashboard.py` / `monitor.py` y solo hablan **informe wire v1** (pickle `CONFIG_SND` / `BRIDGE_SND`, CSV `BRDG_EVENT`). Esos monitores **no pueden** conectarse a **adn-server 2.x** en el puerto de informes.
+
+El paquete opcional **[ADN-report-proxy](https://github.com/ce5rpy/ADN-report-proxy)** queda en medio: se conecta **upstream** al servidor real (v2), escucha **downstream** donde el monitor legacy espera el servidor (v1) y traduce **v2 → v1**.
+
+| Stack | Servidor upstream | ¿Funciona sin proxy? |
+|-------|-------------------|----------------------|
+| **adn-monitor 2.x** (React) | **adn-server 2.x** | Sí — conectar a `REPORTS.REPORT_PORT` |
+| Panel legacy + monitor incluido (v1) | **adn-dmr-server** (v1) | Sí — directo al puerto de informes del servidor |
+| Panel legacy + monitor incluido (v1) | **adn-server 2.x** (v2) | **No** — usar **report-proxy** |
+
+Objetivos legacy típicos: forks antiguos de **ADN-Dashboard**, despliegues **HBMonitor** / **FDMR Monitor** que aún ejecutan un proceso monitor Python contra `dashboard.cfg` / `monitor.cfg`.
+
+## Topología
+
+```text
+┌───────────────────┐
+│ adn-server │
+│ ESCUCHA :4321 │
+└─────────▲─────────┘
+ │
+ │ TCP v2 JSON
+ │ (report-proxy es CLIENTE)
+ │
+┌─────────┴─────────┐
+│ report-proxy │
+│ ESCUCHA :4322 │
+└─────────▲─────────┘
+ │
+ │ TCP v1 pickle
+ │ (panel legacy es CLIENTE)
+ │
+┌─────────┴─────────┐
+│ panel legacy │
+│ monitor.py │
+└───────────────────┘
+```
+
+| Componente | Rol | Puerto por defecto | Config | Clave |
+|------------|-----|-------------------|--------|-------|
+| **adn-server** | Escucha clientes de informes | **4321** | `adn-server.yaml` | `REPORTS.REPORT_PORT` |
+| **report-proxy** | Se conecta al servidor | 4321 | `report-proxy.yaml` | `UPSTREAM.PORT` |
+| **report-proxy** | Escucha al monitor legacy | **4322** | `report-proxy.yaml` | `LISTEN.PORT` |
+| **Panel legacy** | Se conecta al proxy | **4322** | `dashboard.cfg` | `SERVER_PORT` |
+
+**No** apuntes el panel legacy al **4321** — es el puerto v2 del servidor.
+
+**No** pongas `UPSTREAM.PORT` en **4322** — es el puerto de escucha del propio proxy.
+
+## Lado servidor (`adn-server.yaml`)
+
+Los informes deben estar activos y la **IP del host del proxy** debe estar en la lista permitida:
+
+```yaml
+REPORTS:
+ REPORT: true
+ REPORT_INTERVAL: 60
+ REPORT_PORT: 4321
+ REPORT_CLIENTS: "127.0.0.1" # IP de la máquina donde corre report-proxy
+```
+
+Si el proxy corre en otro host, usa la **IP de ese host** en `REPORT_CLIENTS`, no solo `127.0.0.1`. Ver [Configuración](configuration.md#reports) para todas las claves de `REPORTS`.
+
+## Proxy y panel legacy
+
+Instala y ejecuta el proxy desde el repositorio **[ADN-report-proxy](https://github.com/ce5rpy/ADN-report-proxy)** (`report-proxy.yaml`, `python3 report-proxy.py -c report-proxy.yaml`). Apunta `UPSTREAM` al `REPORT_PORT` del servidor y `LISTEN` al puerto que usa el monitor legacy (a menudo **4322**).
+
+En `dashboard.cfg` / `monitor.cfg` legacy:
+
+```ini
+[SERVER CONNECTION]
+SERVER_IP = 127.0.0.1
+SERVER_PORT = 4322
+```
+
+`SERVER_IP` es el host donde **report-proxy** escucha, no necesariamente el host de adn-server.
+
+**Orden de arranque:** adn-server → report-proxy → backend monitor legacy.
+
+Pasos completos, ejemplos multi-host, comprobaciones y errores frecuentes: **[README de ADN-report-proxy](https://github.com/ce5rpy/ADN-report-proxy#configuration-legacy-dashboard--adn-server-2x)**.
+
+## Traducción de wire (resumen)
+
+| Upstream (v2 desde adn-server) | Downstream (v1 al monitor legacy) |
+|--------------------------------|-----------------------------------|
+| `HELLO` (`report_protocol: 2`) | `HELLO` (`protocol: 1`) |
+| `STATE_SND` / `dashboard_state` | `CONFIG_SND` (pickle) |
+| `ROUTING_TABLE_SND` | `BRIDGE_SND` (pickle) |
+| `TOPOLOGY_SND` | `CONFIG_SND` (pickle) |
+| `VOICE_EVENT_SND` | `BRDG_EVENT` (CSV) |
+
+Detalle del esquema v2: [Protocolo de informes v2 (JSON)](../protocols/report-v2.md).
+
+## Ver también
+
+- [Monitor e informes](monitoring.md) — canal de informes, emparejamiento con **adn-monitor**, líneas de log.
+- [Descripción general de ADN Monitor](../../monitor/index.md) — panel recomendado para **adn-server 2.x** (sin proxy).
diff --git a/docs/es/server/user-guide/special-numbers.md b/docs/es/server/user-guide/special-numbers.md
index b74bec8..ccabdf4 100644
--- a/docs/es/server/user-guide/special-numbers.md
+++ b/docs/es/server/user-guide/special-numbers.md
@@ -56,17 +56,22 @@ La activación/desactivación in-band de bridges se aplica sobre **voice termina
- En bridges reflector (`#...`), el manejo in-band solo se evalúa cuando el destino es **TG 9**.
- Por eso los mensajes de reflector y el cableado de marcado usan TG 9, mientras que llamadas privadas no disparan esa lógica de temporizadores de bridge.
-## TG / ID 4000 — desactivar bridges dinámicos
+## TG / ID 4000 — desactivar bridges dinámicos {#tg--id-4000--desactivar-bridges-dinamicos}
-**Propósito:** borrar **bridges dinámicos activados por usuario** para el sistema que recibe la llamada.
+**Propósito:** Borrar el estado **activado por usuario (dinámico)** del hotspot que pulsa **4000**. **TG 4000 no es** un talkgroup que deba monitorizarse ni persistirse — es un **comando de reset**.
-**Comportamiento:**
+**Comportamiento (cabecera de voz de grupo):**
+
+- Limpia sesiones UA del peer en memoria (**todos los slots** de ese peer).
+- Borra filas correspondientes en **`peer_dynamic_tgs`** (MariaDB).
+- Limpia campos RX obsoletos en **STATUS** para que un **RPTO** posterior no re-sembré el TG antiguo.
+- Ejecuta **desactivación in-band** de bridges en el slot (como legacy).
+- Envía **`GROUP VOICE,INGRESS,RX,…,4000`** al monitor (no **START**) para que los chips **SINGLE=0** se limpien **sin** encender TX en vivo.
+- En MASTER **inject-only**, empuja **CONFIG_SND** actualizado al monitor.
-- Implementado para tráfico de **grupo** con destino **4000** (y comprobaciones relacionadas en el router).
-- Se ejecuta **antes** que la ACL normal de TG en la ruta OpenBridge para que el comando no quede bloqueado por listas permitidas.
-- Invoca **`deactivate_all_dynamic_bridges`**: desactiva filas dinámicas que no sean stat ni reflector.
+**Inject-only frente a global:** Con **`PROXY`** integrado, el reset es **por peer** (solo los dinámicos de ese hotspot). Sin filtro inject-only, sigue aplicándose legacy **`deactivate_all_dynamic_bridges`** a todo el system.
-Úsalo cuando los operadores necesiten **reiniciar** el enrutado dinámico sin reiniciar el servidor.
+**TG 4000 no debe aparecer** como chip UA dinámico en el monitor ni en `peer_dynamic_tgs`.
### Impacto de `SINGLE_MODE` en la lógica de desactivación
@@ -93,6 +98,8 @@ El **audio** se envía con **ID de fuente 5000** y **TG de destino 9** en el flu
**Propósito:** las filas de bridge para **eco** suelen usar **9990** con el sistema **ECHO** (ver `BRIDGES` y opciones en tu YAML).
+**`SINGLE=1`:** pulsar **9990** **no** crea sesión de escucha exclusiva (igual que **4000**). El downlink del eco vuelve siempre al hotspot llamante aunque otra TG tenga el bloqueo SINGLE. Ver [Proxy hotspot](hotspot-proxy.md#comportamiento-con-varios-hotspots).
+
**Nota:** Un **echo independiente** también está disponible como proceso aparte — [Echo](echo.md).
## Llamada privada al ID 4000
diff --git a/mkdocs.es.yml b/mkdocs.es.yml
index 81c53d4..f5e97fa 100644
--- a/mkdocs.es.yml
+++ b/mkdocs.es.yml
@@ -32,10 +32,15 @@ theme:
plugins:
- search
+ - mermaid2
markdown_extensions:
- attr_list
- - pymdownx.superfences
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:mermaid2.fence_mermaid_custom
- pymdownx.tabbed:
alternate_style: true
- admonition
@@ -56,6 +61,7 @@ nav:
- Llamadas privadas: server/user-guide/private-calls.md
- Voz, anuncios y TTS: server/user-guide/voice-and-tts.md
- Monitor e informes: server/user-guide/monitoring.md
+ - Proxy de informes (paneles legacy): server/user-guide/report-proxy.md
- Proxy hotspot (integrado): server/user-guide/hotspot-proxy.md
- Echo (reproducción): server/user-guide/echo.md
- Créditos y licencia: server/user-guide/attribution.md
@@ -66,6 +72,8 @@ nav:
- Protocolo de informes v2 (JSON): server/protocols/report-v2.md
- Desarrollo:
- Arquitectura: server/development/architecture.md
+ - Rendimiento (2.x): server/development/performance.md
+ - BRIDGES vs Subscriptions: server/development/bridges-vs-subscriptions.md
- Comportamiento y temporizadores: server/development/behaviour-and-timers.md
- Contribuir:
- Traducciones: server/contributing/translations.md
diff --git a/mkdocs.yml b/mkdocs.yml
index eecd9fc..f817ca8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -32,10 +32,15 @@ theme:
plugins:
- search
+ - mermaid2
markdown_extensions:
- attr_list
- - pymdownx.superfences
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:mermaid2.fence_mermaid_custom
- pymdownx.tabbed:
alternate_style: true
- admonition
@@ -56,6 +61,7 @@ nav:
- Private calls: server/user-guide/private-calls.md
- Voice, announcements, and TTS: server/user-guide/voice-and-tts.md
- Monitoring and reports: server/user-guide/monitoring.md
+ - Report proxy (legacy dashboards): server/user-guide/report-proxy.md
- Hotspot proxy (integrated): server/user-guide/hotspot-proxy.md
- Echo (playback): server/user-guide/echo.md
- Credits & license: server/user-guide/attribution.md
@@ -66,6 +72,8 @@ nav:
- Report protocol v2 (JSON): server/protocols/report-v2.md
- Development:
- Architecture: server/development/architecture.md
+ - Performance (2.x): server/development/performance.md
+ - BRIDGES vs Subscriptions: server/development/bridges-vs-subscriptions.md
- Behaviour and timers: server/development/behaviour-and-timers.md
- Testing: server/development/testing.md
- Contributing:
diff --git a/pyproject.toml b/pyproject.toml
index b3b2770..ce686db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "adn-server"
-version = "2.0.0-rc.3"
+version = "2.0.0-rc.4"
description = "ADN DMR Peer Server"
readme = "README.md"
license = { text = "GPL-3.0-or-later" }
@@ -24,7 +24,7 @@ dependencies = [
mqtt = ["paho-mqtt>=2.0"]
selfservice = ["mysqlclient>=2.0"]
dev = ["pytest>=7.0", "jsonschema>=4.0", "paho-mqtt>=2.0"]
-docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "pymdown-extensions>=10.3"]
+docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "pymdown-extensions>=10.3", "mkdocs-mermaid2-plugin>=1.1"]
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/src/adn_server/__init__.py b/src/adn_server/__init__.py
index 4411afb..ca855fa 100644
--- a/src/adn_server/__init__.py
+++ b/src/adn_server/__init__.py
@@ -37,4 +37,4 @@
"""ADN DMR Peer Server — conference bridge (rewrite of bridge_master)."""
-__version__ = "2.0.0-rc.3"
+__version__ = "2.0.0-rc.4"
diff --git a/src/adn_server/application/dynamic_tg_use_cases.py b/src/adn_server/application/dynamic_tg_use_cases.py
index 324aac4..62213f4 100644
--- a/src/adn_server/application/dynamic_tg_use_cases.py
+++ b/src/adn_server/application/dynamic_tg_use_cases.py
@@ -113,7 +113,7 @@ class DynamicTgUseCases:
]
tgids = restore_peer_ua_entries_to_memory(sys_cfg, peer_id, active, now=now)
if self._on_restored is not None:
- self._on_restored(peer_id, system_name, sys_cfg, active, now)
+ self._on_restored(peer_id, system_name, sys_cfg, active, now=now)
if tgids:
logger.info(
"(DYNAMIC_TG) Restored %s TG(s) for peer %s on %s: %s",
diff --git a/src/adn_server/application/report/monitor_topology.py b/src/adn_server/application/report/monitor_topology.py
index 35a9beb..7f8e437 100644
--- a/src/adn_server/application/report/monitor_topology.py
+++ b/src/adn_server/application/report/monitor_topology.py
@@ -31,7 +31,7 @@ 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.routing.helpers import is_special_tg, peer_should_receive_group_voice
from adn_server.application.proxy.deployment import is_proxy_inject_only, proxy_target_system
from adn_server.domain.value_objects import bytes_4, int_id
@@ -246,7 +246,12 @@ def _peers_receiving_tgid(
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."""
+ """Echo/service downlink TX: field 5 may be 9990 or hotspot id; field 8 is 9990–9999."""
+ tgid_slot = _voice_event_tgid_slot(parts)
+ if tgid_slot is not None:
+ tgid, _ = tgid_slot
+ if is_special_tg(str(tgid)):
+ return _peer_key_from_voice_csv(parts, peers)
if len(parts) <= 5:
return None
try:
@@ -320,13 +325,19 @@ def remap_inject_proxy_voice_events(
trx = parts[2].strip() if len(parts) > 2 else ""
if trx == "TX":
+ tgid_slot = _voice_event_tgid_slot(parts)
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:
+ tx_parts = list(parts)
+ if tgid_slot is not None:
+ tgid, _ = tgid_slot
+ if is_special_tg(str(tgid)):
+ tx_parts[5] = str(tgid)
return [
_remap_voice_event_to_slot(
- parts, target=target, slot=slot, peer_key=echo_peer
+ tx_parts, target=target, slot=slot, peer_key=echo_peer
)
]
try:
diff --git a/src/adn_server/application/routing/helpers.py b/src/adn_server/application/routing/helpers.py
index 0f87294..ec24fd0 100644
--- a/src/adn_server/application/routing/helpers.py
+++ b/src/adn_server/application/routing/helpers.py
@@ -104,8 +104,14 @@ def tg4000_reset_on_vhead(int_dst_id: int, frame_type: int, dtype_vseq: int) ->
def is_ua_session_tgid(tgid: int) -> bool:
- """True when a keyed TG may be stored as a user-activated dynamic session."""
- return int(tgid) > 0 and int(tgid) != 4000
+ """True when a keyed TG may be stored as a user-activated dynamic session.
+
+ Excludes TG 4000 (reset command) and service/echo 9990–9999 (no SINGLE lock).
+ """
+ t = int(tgid)
+ if t <= 0 or t == 4000:
+ return False
+ return not is_special_tg(str(t))
def obp_target_bcsq_quenches_stream(
diff --git a/src/adn_server/application/routing_use_cases.py b/src/adn_server/application/routing_use_cases.py
index 906bc4c..14455f6 100644
--- a/src/adn_server/application/routing_use_cases.py
+++ b/src/adn_server/application/routing_use_cases.py
@@ -417,6 +417,11 @@ class RoutingUseCases(
bridge_match_slot=bridge_match_slot,
dst_int=dst_int,
)
+ _tx_report_peer = int_id(peer_id)
+ if not source_is_obp:
+ _tx_report_peer = int_id(
+ resolve_voice_peer_id(peer_id, rf_src, system_name, systems_cfg)
+ )
forwarded = []
_leg_iter: list[tuple[str, dict[str, Any]]] = [
(
@@ -496,7 +501,7 @@ class RoutingUseCases(
)
self._send_routing_event(
"GROUP VOICE,START,TX,{},{},{},{},{},{}".format(
- entry["SYSTEM"], int_id(stream_id), int_id(peer_id), int_id(rf_src), entry.get("TS", 1), int_id(target_tgid)
+ entry["SYSTEM"], int_id(stream_id), _tx_report_peer, int_id(rf_src), entry.get("TS", 1), int_id(target_tgid)
)
)
if "EMB_LC" not in _target_status[stream_id]:
@@ -533,7 +538,7 @@ class RoutingUseCases(
call_duration = pkt_time - _target_status[stream_id].get("START", pkt_time)
self._send_routing_event(
"GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format(
- entry["SYSTEM"], int_id(stream_id), int_id(peer_id), int_id(rf_src), entry.get("TS", 1), int_id(target_tgid), call_duration
+ entry["SYSTEM"], int_id(stream_id), _tx_report_peer, int_id(rf_src), entry.get("TS", 1), int_id(target_tgid), call_duration
)
)
elif dtype_vseq in (1, 2, 3, 4):
@@ -624,7 +629,7 @@ class RoutingUseCases(
"GROUP VOICE,START,TX,{},{},{},{},{},{}".format(
entry["SYSTEM"],
int_id(stream_id),
- int_id(peer_id),
+ _tx_report_peer,
int_id(rf_src),
entry_ts,
int_id(entry_tgid_b),
@@ -648,11 +653,18 @@ class RoutingUseCases(
dmrbits = _ts_st["TX_T_LC"][0:98] + dmrbits[98:166] + _ts_st["TX_T_LC"][98:197]
call_duration = pkt_time - _ts_st.get("TX_START", pkt_time)
_end_peer = _ts_st.get("TX_PEER", peer_id)
+ _end_report_peer = int_id(_end_peer)
+ if not source_is_obp:
+ _end_report_peer = int_id(
+ resolve_voice_peer_id(
+ _end_peer, rf_src, system_name, systems_cfg
+ )
+ )
self._send_routing_event(
"GROUP VOICE,END,TX,{},{},{},{},{},{},{:.2f}".format(
entry["SYSTEM"],
int_id(stream_id),
- int_id(_end_peer),
+ _end_report_peer,
int_id(rf_src),
entry_ts,
int_id(entry_tgid_b),
diff --git a/tests/application/test_dynamic_tg_persist.py b/tests/application/test_dynamic_tg_persist.py
index 09e191e..989dfce 100644
--- a/tests/application/test_dynamic_tg_persist.py
+++ b/tests/application/test_dynamic_tg_persist.py
@@ -26,6 +26,7 @@ from adn_server.application.dynamic_tg_use_cases import DynamicTgUseCases
from adn_server.application.routing.helpers import register_peer_ua_session
from adn_server.domain.dynamic_tg import DynamicTgEntry
from tests.application.test_peer_single_downlink import _peer_id, _sys_cfg
+from twisted.internet import defer
class _CaptureStore:
@@ -66,3 +67,48 @@ def test_persist_after_register_stores_absolute_expires_from_memory() -> None:
entry = store.replaced[0]
assert entry.expires_at == now + 60.0 * 60.0
assert entry.single_mode is True
+
+
+def test_restore_peer_passes_now_as_keyword_to_on_restored() -> None:
+ """sync_restored_dynamic_tgs declares ``now`` keyword-only; restore must match."""
+ import time
+
+ peer_id = _peer_id()
+ sys_cfg = _sys_cfg()
+ now = time.time()
+ entry = DynamicTgEntry(
+ int_id=730039101,
+ system_name="MASTER-A",
+ slot=2,
+ tgid=7304,
+ single_mode=True,
+ expires_at=now + 3600.0,
+ updated_at=now,
+ )
+ seen: dict[str, float] = {}
+
+ def on_restored(
+ _peer_id: bytes,
+ _system_name: str,
+ _sys_cfg: dict,
+ entries: list[DynamicTgEntry],
+ *,
+ now: float,
+ ) -> None:
+ seen["now"] = now
+ seen["count"] = float(len(entries))
+
+ class _Store:
+ def load_peer(self, int_id: int, system_name: str):
+ return defer.succeed([entry])
+
+ def purge_expired(self, now: float) -> None:
+ pass
+
+ uc = DynamicTgUseCases(_Store(), on_restored=on_restored)
+ out: list[list[int]] = []
+ d = uc.restore_peer(peer_id, "MASTER-A", sys_cfg)
+ d.addCallback(lambda tgids: out.append(tgids))
+ assert out == [[7304]]
+ assert seen["count"] == 1.0
+ assert isinstance(seen.get("now"), float)
diff --git a/tests/application/test_monitor_topology.py b/tests/application/test_monitor_topology.py
index 3770174..74061b1 100644
--- a/tests/application/test_monitor_topology.py
+++ b/tests/application/test_monitor_topology.py
@@ -142,6 +142,13 @@ def test_expand_inject_proxy_emits_all_virtual_masters() -> None:
{"parts": {3: "SYSTEM-4", 5: "9990"}},
id="tx_echo_keeps_9990_for_hotspot_rx",
),
+ pytest.param(
+ "GROUP VOICE,START,TX,SYSTEM,4100887026,730039101,730039101,2,9990",
+ [(730039101,)],
+ {730039101: 4},
+ {"parts": {3: "SYSTEM-4", 5: "9990"}},
+ id="tx_echo_peer_id_in_field5_dst_9990",
+ ),
pytest.param(
"GROUP VOICE,START,RX,SYSTEM,4100887026,73003,7300392,2,9990",
[(730039101,)],
diff --git a/tests/application/test_peer_single_downlink.py b/tests/application/test_peer_single_downlink.py
index 7de9885..6d24376 100644
--- a/tests/application/test_peer_single_downlink.py
+++ b/tests/application/test_peer_single_downlink.py
@@ -388,6 +388,27 @@ def test_register_peer_ua_session_ignores_4000() -> None:
assert peer_single_exclusive_tgid(peer_single, 2, sys_cfg, peer_id=peer_id, now=now) is None
+def test_register_peer_ua_session_ignores_echo_9990() -> None:
+ """Echo TG 9990 must not create SINGLE=1 exclusive listen lock."""
+ peer_id = _peer_id()
+ sys_cfg = _sys_cfg()
+ now = 1_000_000.0
+ peer = {"OPTIONS": b"TS2=730444;SINGLE=1;TIMER=5;"}
+ register_peer_ua_session(peer, peer_id, 2, 730444, sys_cfg, now=now)
+ register_peer_ua_session(peer, peer_id, 2, 9990, sys_cfg, now=now)
+ assert peer_single_exclusive_tgid(peer, 2, sys_cfg, peer_id=peer_id, now=now) == 730444
+
+
+def test_register_peer_ua_session_ignores_service_999x() -> None:
+ """On-demand / service 9991–9999 are not UA sessions (same class as echo 9990)."""
+ peer_id = _peer_id()
+ sys_cfg = _sys_cfg()
+ now = 1_000_000.0
+ peer = {"OPTIONS": b"TS2=730444;SINGLE=1;TIMER=5;"}
+ register_peer_ua_session(peer, peer_id, 2, 9999, sys_cfg, now=now)
+ assert peer_single_exclusive_tgid(peer, 2, sys_cfg, peer_id=peer_id, now=now) is None
+
+
def test_new_tx_replaces_single_session_tg() -> None:
peer = {"OPTIONS": b"TS2=730,7305;SINGLE=1;TIMER=5;"}
sys_cfg = _sys_cfg()