- ENDPOINT must be https:// (http only for localhost); a 3xx is a
failure and is never followed, so the bearer token cannot travel in
cleartext or reach another host.
- The loop follows REPORT_INTERVAL after a config reload (the
LoopingCall's interval is updated on each tick).
- adn-server.example.yaml: the section, commented out, with
placeholders.
- Without the integrated proxy, hbp_ports skips MASTERs bound to
loopback (internal, e.g. a local gateway's master).
- Tests: reload (new token and interval, removing the section stops the
reports), https rule, redirect not followed, loopback masters, peers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
Optional section ADN_PANEL in adn-server.yaml, added by hand by the
sysop with the token generated for the server in the ADN panel; it is
not part of the config template. Without ENDPOINT and TOKEN nothing is
sent.
Every REPORT_INTERVAL (default 300 s, minimum 60; first one a minute
after start) the server POSTs to <ENDPOINT>/ingest/server-report:
version, server ID, uptime, the HBP port hotspots connect to, the ODTP
port (ODTP_PORT, since the gateway is its own service), the OBP proxy
port, connected peers, OpenBridge links with their keepalive state and
the plugins loaded. No IP addresses, passphrases or user IDs.
- The payload is built on the reactor from the dashboard state; the
POST runs in a thread with a 10 s timeout, so a slow or unreachable
panel never delays traffic.
- A failure is logged once (401/403 hint at the token) and again when
it recovers; the token is never logged.
- Settings are read on every tick and ADN_PANEL follows config reloads,
so a new token or removing the section applies without a restart.
- Config validation for the section; docs in en/es (monitoring guide).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jYm3g9skKGroRdrfwJsCG
On the inject-only proxy MASTER (or any MASTER with MAX_PEERS > 1) the hourly
voice ident never played. Now VOICE_IDENT in the YAML allows it and each
hotspot that asks with VOICE=1 (or IDENT=1) in its OPTIONS gets its own ident
on TS2, sent to that hotspot only, with its callsign, LANG and VOICETG/IDENTTG,
once it has been quiet for 30 s. On a shared MASTER a hotspot's OPTIONS no
longer change VOICE_IDENT or the ident TG for the others. An empty
OVERRIDE_IDENT_TG now means all-call instead of failing the ident.
The config normalizer, the settings lookup and the HBP embedded-LC buffer
all defaulted a missing key to false. Default it to true, matching the
example config; an explicit false still disables it.
An empty SUB_MAP_FILE already loaded sub_map.pkl, but the periodic save,
SIGHUP, kill switch and shutdown all skipped writing, so routes learned
since startup were lost on restart. Write to the same path the map is
loaded from; docs now say empty means sub_map.pkl.
With SUB_MAP_FILE empty the server loads sub_map.pkl at startup but never
writes it, so private-call routes were lost on every restart for installs
based on the example. Set it to sub_map.pkl and correct the docs, which
claimed an empty value fell back to a default name.
Unit data on a shared MASTER, TG 4000 on a multi-peer MASTER, the
announcements' quiet-TG wait and tg_quiet_for, connected_at in topology;
monitor MAP section, dashboard config reload, SERVER_STATUS_URL and the
WebSocket compressed once per broadcast. English and Spanish.
start_utc / end_utc were HH:MM:SS.s only. A probe runs for days, so a
filter or a --summary by hour mixed the same hour of different days: on
2131 an on-air A/B test picked up yesterday's announcement at the same
time and looked like two TG 213 streams reaching the hotspot at once.
- Rows now carry YYYY-MM-DDTHH:MM:SS.s (UTC).
- --summary groups by date and hour (YYYY-MM-DDTHH); rows written before
this change (no date) still group by HH.
- Tests: a row carries its date; the summary keeps the same hour of two
days apart and still reads old rows. Both fail without the change.
- Docs (en/es) give the format.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
merge_top_level_config never copied OBP_PROXY, so a reload kept DEBUG as at start and the
'bind change ignored' warning could never fire. DEBUG now applies on SIGHUP; LISTEN_PORT,
LISTEN_IP and BIND_LEGACY_PORTS still need a restart and log the warning. Docs updated.
Both languages: MASTER yaml statics on a shared MASTER, per-stream RX on one slot, voice
sequence handling, bridge reset on the last peer, OpenBridge DNS anchoring and shared
passphrases, OBP_PROXY/ALIASES keys and reload rules, TGID_ACL on OPENBRIDGE, report v2
topology fields, plugins (voice_slot_for_tg, voice-announcements, slot sharing), server
prompts on TS2, TG 4000 leg release and voice reply, mesh_* modules.
LOOP_GUARD now defaults to true: an echo coming back through a transcoding bridge is
dropped unless a system sets log (write a *LoopGuard* line and let it through) or false.
The example yaml explains what the guard catches that stream-ID loop control cannot.
A MASTER without the key now answers a hotspot's TG / ID 4000 with its link status;
TG4000_VOICE: false turns it off. Example yaml and docs (en/es) updated.
The follow-up agreed in #132:
- A private call to ID 4000 is answered with a private call from ID 4000
to the caller (pkt_gen(private=True): LC FLCO 3, DMRD unit bit), the
shape hotspot configs map back to the private contact (PCRewrite
...,84000,...,4000,...). A group 4000 keeps the group reply on TG 4000.
send_prompt_to_peer() gates and tracks a private frame as TG 4000 on
its slot, so routed group voice does not start under it.
- "linked to" + the statics the hotspot still has on that slot (its
OPTIONS and the MASTER's TS1/TS2_STATIC, both lists when simplex, up
to four), else "linked to N" with DEFAULT_REFLECTOR, else "not linked".
- The worker thread only loads the words and builds the frames; playback
is paced on the reactor with callLater (1 s after the over, then one
frame per 58 ms from a fixed start, no drift), stopping at the first
refused frame.
- On a shared MASTER, a dynamic leg only the sender held (no other
connected hotspot has that TG on that slot as a dynamic session, an
OPTIONS static or a YAML static) is set IDLE right away instead of
waiting for its timer (release_dynamic_leg / release_dynamic_leg_store,
reflector and static legs never touched).
Tests: 16 in tests/voice/test_tg4000_voice_reply.py, 12 of them fail on
develop. Docs (en/es): special numbers, configuration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
Users get no feedback after keying TG 4000. With TG4000_VOICE: true on a
MASTER, the terminator of a hotspot's 4000 over (group or private call)
queues one reply to that hotspot only, about a second later:
- The words the disconnected prompt already says, in the system's
ANNOUNCEMENT_LANGUAGE: "not linked", or "linked to N" with a
DEFAULT_REFLECTOR (shared now as _link_status_words).
- A group call from the server voice ID to TG 4000 on the slot the 4000
came in on: the radio is still on that contact.
- Sent frame by frame on the reactor through the new
HBPProtocol.send_prompt_to_peer(): the hotspot did not subscribe to
TG 4000, so the OPTIONS filter does not apply, but its own slot gate
does; the reply stops if it gets busy with another stream on that
slot or leaves. MASTER slot rows are not touched, so nobody else on a
shared MASTER is held off, and it never goes to OpenBridge.
- One reply per over (stream ID); none if the terminator is lost.
Validator: bool, MASTER only. Default false; example yaml and docs
(en/es: configuration, special numbers) show it.
Refs #131.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
Logs in to a MASTER like a hotspot (RPTL/RPTK/RPTC/RPTO), subscribes to
talkgroups through OPTIONS and never transmits. One CSV row per group
voice stream: start/end UTC, slot, TG, source, stream ID, frames, the
longest silence between two frames, and whether it ended with a
terminator. --summary prints a per-hour/TG table.
Matched by stream ID with the server's *CALL END* (Loss / Max gap), it
tells whether a cut or silence was already in the call when it came in
over the mesh or appeared on the way to the hotspot. On 2131 it is what
found the bridge reset (#123) and the mesh announcement hold (#124).
- Standard library only; password from ADN_PROBE_PASSWORD, never argv.
- A fresh socket per login attempt and a growing back-off, so a server
restart or a refused login cannot turn it into a login flood.
- Tests: the login against a fake master that checks the key with the
server's own _calc_hash, a refused password, stream rows with their
longest silence, private calls ignored.
- Docs (en/es): Monitoring and reports, "What reaches a hotspot".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
A remote caller's next over may reach this server first over another
OpenBridge link, so within the hold window it looked like an echo (8 of
3262 call starts on 2131, all OBP -> OBP re-keys). The mesh is now one
ingress: a real loop is still caught where the caller is local (HBP vs a
bridge peer, or HBP vs the mesh), and so is a local bridge peer echoing a
remote caller (the mesh vs HBP).
Show GLOBAL.LOOP_GUARD_HOLD and SYSTEM LOOP_GUARD with their defaults in
adn-server.example.yaml.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
- playback_use_cases: _PLAYBACK_DELAY_S becomes public PLAYBACK_DELAY_S
(now used outside the module); the two echo tests that import it follow.
- loop_guard: MAX_LOOP_GUARD_HOLD_S = PLAYBACK_DELAY_S, so changing the
parrot's delay moves the cap with it. playback_use_cases only imports
from domain: no cycle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
- The parrot replays an over 2.0 s after it ends (_PLAYBACK_DELAY_S),
under whatever system/TG name the sysop chose. A bridge echo starts
while the original is still on air, so the hold only has to cover very
short overs: default 1.0 s, and the validator rejects 2.0 s or more.
The runtime falls back to the default for anything out of range.
- LOOP_GUARD: false stays as an extra option.
- Test: a replay starting 2.0 s after the over ends is not flagged even
with LOOP_GUARD: true on that system.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
- application/routing/loop_guard.py: when a group voice stream starts,
the same rf_src on the same TG from another ingress (system + peer; an
OBP link is one ingress), with another stream ID, while the original is
active or ended < GLOBAL.LOOP_GUARD_HOLD (2 s), is a loop echo.
Stream-ID LoopControl cannot see it (bridges start a new stream ID) and
the TG-busy check lets the same caller through on purpose.
- Bridges that relay to another TG are left alone (same TG required).
- Per system LOOP_GUARD: "log" (default, one *LoopGuard* line per echo),
true (drop), false (neither checked nor recorded: the ECHO parrot).
- Called from HBP and OBP ingress and from the MASTER local REPEAT, one
verdict per stream cached by stream ID; bound streams never reach it.
Server voice IDs and plugin/announcement frames are exempt.
- Validator: LOOP_GUARD true/false/"log", LOOP_GUARD_HOLD > 0.
- Docs: configuration.md and behaviour-and-timers.md (en/es); the example
ECHO sets LOOP_GUARD: false.
Closes#118
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
- When SUB_MAP (or the 6/7-digit hotspot-ID fallback) points the
destination at the very peer the frame came from, the two radios are on
the same hotspot and hear each other on RF; sending it back made the
hotspot transmit over their exchange and the SMS ACK was lost. Applies
to every peer: a duplex repeater already retransmits its own RF.
- Plugin frames carry SERVER_ID as peer, so they are never skipped.
- The same-system private call log line is the normal path, not an
error: logger.error -> logger.debug.
Closes#116
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
- SUB_MAP_LEARN: false on a MASTER or PEER keeps traffic entering through
it from updating SUB_MAP, so a beacon, a bridge or the parrot never
becomes where a subscriber is reached for private calls and unit data.
Default true everywhere, no name-based default.
- One function, learn_sub_map, decides and writes for the three write
sites (MASTER ingress, PEER ingress, _pvt_call_received).
- OpenBridge always learns; the validator rejects the key there and
checks it is a boolean elsewhere.
- Entries that already point at a system with false are dropped after
the config is loaded and after every SIGHUP reload (SUB_MAP is persisted).
- Docs: configuration.md and private-calls.md (en/es); the example ECHO
sets it explicitly.
Closes#109
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnjAx7G27d9q88j9PFLj11
As soon as any plugin was loaded, every voice frame built a full
CallLegContext and event (~13 us) and scheduled its own callLater
(~2-7 us), even for plugins that never look at voice.
- A plugin may declare `events` (the event classes it handles). The bus
keeps the union and `wants()` / `wants_any()`; the voice and data
bridges check it before building anything, and emit() only calls
plugins that take that event. Undeclared plugins and internal
handlers still get everything.
- emit_deferred batches: one call_later per reactor tick, same order.
- The group context's per-stream constant part (IDs, mode, proxy flag,
aliases) is resolved once per stream; each event still gets its own
`extra` dict.
- Fix: _plugin_started / _plugin_ended grew by one entry per call
forever; END now drops the START key and ended keys are capped.
Per voice frame, group voice to 6 OBP + a MASTER (48 us with no plugin):
any plugin 66.0 -> 59.4 us; a data-only plugin 49.4 us.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
They are now the voice-announcements plugin (previous commit). Removed
from the core:
- VoiceUseCases: scheduled_announcement, scheduled_tts_announcement, the
broadcast queue, target and slot selection, slot marking, legacy
monitor reports and apply_voice_config (~900 lines). What stays:
voice ident, on-demand 999x files and the disconnected prompt, which
answer a radio rather than follow a schedule.
- inject_announcement_ptt and its bootstrap wiring; plugin frames enter
through inject_plugin_dmrd.
- The TTS engine moves into the plugin (plugin/infrastructure), with its
tests; VoiceProvider.ensure_tts_ambe is gone; tts_ambe.py was a dead
stub.
Nothing to change for sysops:
- the plugin is versioned and enabled by default, and still reads
VOICE.ANNOUNCEMENTS / TTS_ANNOUNCEMENTS from adn-voice.yaml;
- without a PLUGINS.send entry the server grants voice-announcements
exactly the TGs and DMR IDs those items use (disabled ones included,
so enabling one needs no restart); an explicit entry wins;
- the manager now always hands voice_slot_for_tg with send_dmrd; both
check the talkgroup grant on every call.
Tests: the announcement tests of the core are replaced by the plugin's
(schedule, per-TG queue, busy slots, QSO cut, hourly, TTS and its
failures, default DMR ID, derived grant) and by routing tests of plugin
voice ported from test_announcement_ptt_inject (OBP fan-out, no
misattribution to a real peer, dynamic/bridged slot choice, no
pre-armed bridge needed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #104: master_kill and revoking PLUGINS.send did not take
effect on SIGHUP, because merge_top_level_config never re-read PLUGINS.
Going through the real path showed it goes further: YamlConfigLoader
builds the config from a fixed list of sections and dropped PLUGINS
altogether, so the documented block (directory, master_kill, overrides)
was never read, at startup either. Both predate this PR.
- YamlConfigLoader keeps PLUGINS when present (like OBP_PROXY).
- merge_top_level_config replaces PLUGINS on every reload, absent
included: removing the block removes everything in it.
- Tests through prepare_reload_config + merge_top_level_config +
swap_runtime_config and a ConfigProxy, as the server wires them:
entry removed, master_kill, whole section removed, a TG granted; and
one with the real loader on a real adn-server.yaml, boot and reload.
Also from the review:
- parse_dmrd_header uses call_attributes(); PluginIngress uses
server_id_bytes().
- A max_frames_per_s that is not a finite positive number grants nothing.
- The allowlist is documented as a guard against buggy plugins, not a
sandbox: a plugin runs in-process with the live config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First step to move scheduled announcements, TTS and voice beacons out of
the core into a plugin: a plugin granted `group_voice_tgs` in
PLUGINS.send can send group voice on those talkgroups.
- PluginIngress (application layer) enters plugin frames on the
announcement MASTER. Group voice goes through dmrd_received with
synthetic_announcement=True, exactly the path announcements use (the
TG's bridges, OpenBridge included), then to that MASTER's hotspots.
- While a plugin stream plays it holds the MASTER slot (TX_TYPE=VHEAD,
TX_STREAM_ID, TX_RFS, TX_TGID), so routed voice finds it busy; a radio
or another stream on the slot fails the frame; VTERM frees it.
- send_dmrd called on the reactor thread returns the routing result, so
a plugin knows when to stop; from a worker thread it is queued.
- Private voice stays refused. Unit data is unchanged (local only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ServerContext.send_dmrd(pkt) hands one DMRD frame to the routing core,
through the same synthetic ingress path scheduled announcements use
(inject_plugin_dmrd -> dmrd_received on the announcement MASTER, with
the SERVER_ID as peer). Only plugins listed in PLUGINS.send get it.
Guards (PluginDmrdSender, re-read from the live config on every frame):
- allowed_src_ids: a plugin can't send as a radio; required.
- max_frames_per_s: per-plugin token bucket, starts full.
- unit data only in this version (data header, rate 1/2, 3/4, CSBK).
- master_kill or removing the entry stops sending at once.
Routing of plugin frames (dmrd_received plugin_origin):
- delivered by the unit data path only: SUB_MAP / hotspot peer ID, to
the destination's exact hotspot, also on the ingress MASTER itself;
- never through the private call path, which would learn the plugin's
source in SUB_MAP (spreading replies over every hotspot of that
MASTER) and keep call state on its shared slot;
- no OpenBridge or DATA-GATEWAY fan-out;
- plugin events for them carry is_synthetic=True.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Validated against the production ADN 213 master: the calls it logged appear in
the replay, on the right bridge, with the right subscriber and talkgroup. Three
things that only show up against real traffic are now in the page, in both
languages.
Capture a port range, not a list: a peer answering from an unexpected port is
the case worth looking at and a narrow filter hides it.
A call legitimately arrives on several bridges at once on a mesh — the master
logs one because loop control, later in routing, keeps one leg and drops the
rest. The replay stops at ingress, so it shows all of them.
And `outbound` frames (what this server sent) are reported, not judged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine from phase 3 takes plain values and answers with effects, so it does
not need the server to run. ``adn-server --replay capture.pcap`` uses that: the
frames from a tcpdump capture go through the same ingress, against the
operator's own adn-server.yaml, and each one comes back with the bridge it
belongs to and either a delivery or the reason it was refused.
12:04:31 82.65.127.86:62201 OBP-FR DMRD v1 2130001 -> 214 delivered
12:04:31 85.241.222.7:62268 OBP-PT DMRE v5 2680015 -> 9 dropped (tg-filter-server) +BCSQ
12:04:32 203.0.113.9:50000 - DMRD v1 unmatched
Nothing is sent and no port is bound, so it runs beside a live master. Which
bridge a frame belongs to is decided on the evidence the server has — the port
it arrived on, then the configured peer, then whoever can verify it — which is
also the answer to "whose keepalive is this?" on a mesh where every bridge
shares one passphrase.
``--system`` narrows it to one link, ``--replay-limit`` stops early and
``--replay-summary`` prints the tally alone.
``infrastructure/pcap.py`` reads classic pcap with no dependencies: both
endiannesses, microsecond and nanosecond timestamps, Ethernet (VLAN tags
included), Linux cooked v1 and v2 (``tcpdump -i any`` writes SLL2, found while
running this against a real capture), raw IP and loopback, IPv4 and IPv6 UDP.
pcapng says which command converts it.
Docs: the OBP proxy page gains a "why did that call not cross" section in both
languages, and its RELAX_CHECKS note now says what phase 2 made true — what the
wire teaches lives in the session, TARGET_IP stays as written.
Tests: 27 new, 97% of the replay module and 87% of the pcap reader, plus an
end-to-end run of the real command against a real YAML. Full suite 1025 passed,
2 skipped (the 2 failures are this machine's and fail on develop too).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Announcement/TTS injection reuses a real peer's DMR id as rf_src, which made
resolve_voice_peer_id's rf_src fallback (and the inject-only proxy's fuzzy
peer match) misattribute the call to that peer: monitor showed it TX/red and
learned bogus dynamic TGs. Guard both resolvers with a new
synthetic_announcement flag, and add a trailing is_announcement field to
every GROUP VOICE report (CSV and JSON voice_event) for monitor-side use.
Route announcements/TTS through synthetic PTT on the proxy MASTER (SERVER_ID
peer, normal dmrd_received forwarding). Emit START/END TX report events for
inject so monitor fans out to SYSTEM-N; configurable server voice DMR_ID.
* fix: audit wave 1 server hygiene refactors
Inject call_later into PlaybackUseCases, move voice config mtime watch to
bootstrap, complete SubscriptionStore port methods, and relocate echo
routing seed to the application layer.
* fix: document dashboard_state in report-v2 schema
Add dashboard_state to report-v2.json with a two-master example fixture,
update public protocol docs for HELLO → STATE_SND connect flow, and drop
the unused TOPOLOGY_JSON HELLO feature token.
* fix: add ReportWire contract tests against report-v2 schema
Assert state_frames and bridge_event_frames output validates against
committed example fixtures and the report-v2 JSON schema.
Fix test imports for echo_seed module relocation.
* fix: align OPTIONS static validity checks across routing and report
Delegate subscription_table validation to peer_options_static_valid so empty
OPTIONS is valid and PASS-mixed strings are rejected consistently.
* fix: OBP DMRE source-server validation without ALLOW_UNREG_ID bypass
Port OPENBRIDGE.validate_id lookup for 6-7 digit source servers so OBP
ingress matches legacy production config (VALIDATE_SERVER_IDS=True).
* fix: audit items 11-13 coverage, infra tests, and warning logs
* fix: add tests/fakes shim for application test decoupling
* fix: per-stream OBP bridge TX legs for concurrent MASTER downlink
When two OBP voice streams share the same MASTER timeslot, stop
flip-flopping the flat TX row so per-peer downlink gates stay stable.
* fix: ruff lint in OBP concurrent streams downlink test
Remove dead code after return and unused start_tx_events variable.
* fix: raise UDP SO_RCVBUF on voice listeners (C-LOCAL)
Apply a 4 MB receive buffer on system and proxy UDP sockets to reduce
kernel RcvbufErrors under OBP load; size is configurable via GLOBAL.UDP_RCVBUF.
* fix: exclude byte-identical duplicates from HBP rate counter (A.2)
Check lastData before incrementing the ingress packet counter so compressed
duplicate bursts do not trigger legitimate RATE DROP on call start.
* fix: duplicate-safe TA embed phase and REPEAT VHEAD DMRA (B)
Ignore byte-identical B-E embed bursts so duplicate uplinks do not desync the
TA phase machine, and re-emit DMRA on every VHEAD on the REPEAT path.
* chore: document echo point-to-point and logged_in reconciliation (EN/ES)
Document multi-hotspot echo/service delivery via RX_PEER, the lst_seen
logged_in reconcile loop, and cross-links between user and dev guides.
Add a dedicated public page explaining routing, contention, timers,
SINGLE mode, static/dynamic TGs, silent activation, slot mapping and
OBP parity. Cross-link from behaviour-and-timers, bridges-and-talkgroups,
introduction and special-numbers, and register it in both mkdocs navs.
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.
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.