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.
Replace internal bridge terminology with routing (RoutingUseCases, AclRouter,
routing_table export). SubscriptionStore remains runtime authority with O(1)
indexes for router and downlink filters. Fix STATIC TG parity on OPTIONS/RPTO,
parrot in-band edge cases, and remove per-packet routing_table export from the
hot path that caused high CPU under multi-hotspot OBP load.
Require subscription_store in BridgeUseCases and route timer, OPTIONS,
static TG, and OBP mutations through store ops with export-only BRIDGES shim.
Fix dashboard YAML static TG fallback and sole-hotspot monitor remap for
dynamic UA when a bridge leg is active.
Overlay Talker Alias in repeated DMRD bursts so WPSD/MMDVMHost can display
aliases on RX, buffer TA from OBP voice, and inject the template at VHEAD
when the source sends no TA.
Add opt-in REPORTS.MQTT with retained shared state and live voice_event
topics, topology static TG in v2 payloads, dashboard_state builder, and
SIGHUP reload.
Add TALKER_ALIAS_TEXT_FORMAT: single or comma-separated encodings for
embedded LC (e.g. utf8,iso8 for Motorola and Hytera). Example template
defaults to both formats; runtime default remains utf8 when omitted.
- both: passthrough when source sends TA (DMRA or embedded voice), inject
template otherwise; OBP sources inject immediately at VHEAD
- Always rewrite destination group embedded LC on bridge forward (legacy
parity; fixes OBP TG mismatch packet loss introduced by TA branch)
- Fix dmr_utils3 encode_emblc index bug for lossless injected TA text
- Log decoded TA from voice; strict MMDVM DMRA wire layout
- chore: ruff per-file E402 ignore for main/parrot entrypoints
Reload GLOBAL, REPORTS, ALIASES and SYSTEMS without full restart:
add/remove UDP listeners (OBP, GENERATOR expansion), update bind
addresses, preserve PEERS/STATS and active streams on unchanged ports.
Inject Talker Alias on HBP bridge/repeat via standalone DMRA packets
and embedded LC in forwarded DMRD voice (FLCO 4–7) for MMDVMHost/
DMRGateway. UTF-8 encoding (format 2) and LC/TA superframe alternation
for the full stream; clear embed state on VTERM.
When LOGGER.ENABLED is false, setup_logging attaches only a NullHandler
so legacy configs without the key keep current behavior. Document the
option in EN/ES configuration guides and example YAML templates.
Add reopen_file_handlers() and register SIGUSR2 after logging setup in
main and parrot. Document create/postrotate/kill -USR2 in monitoring
guide (EN/ES). SIGUSR2 only reopens file handlers; does not reload YAML.