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>