Found on a live master (2131): a plugin beacon on TG 213 reached the
OpenBridges with 2 frames out of 115. PluginIngress marked the ingress
MASTER slot as TX of the stream, while its RX fields still held the last
radio's call. From the second frame on, group_voice_tg_ingress_collision
saw TG 213 active on that slot (our own TX mark) with a stream and a
source that were not ours (the stale RX ones), took it for a QSO and
suppressed the uplink: only the MASTER's own hotspots heard the rest.
A hotspot frame doesn't have this: udp_hbp records the slot's RX state
(RX_STREAM_ID, RX_LC, RX_RFS, RX_TGID, RX_TYPE, RX_TIME…) once routing
accepts it, so the next frame is the same stream, not a new one.
PluginIngress now does exactly that instead of the TX mark. The slot
still reads busy for routed voice while the stream plays (its RX leg),
a radio or another stream still makes the next frame fail, and the
terminator or the 1 s silence sweep sets RX_TYPE back to VTERM.
Regression test: stale RX state on the slot, a whole beacon forwarded,
header and embedded LC both the beacon's (the LC itself was never
affected: routing builds each leg's LC from the target TG and rf_src).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scheduled announcements and TTS announcements of VoiceUseCases, as
an official plugin that speaks through send_dmrd. Same configuration
(VOICE.ANNOUNCEMENTS / TTS_ANNOUNCEMENTS in adn-voice.yaml, followed
every 15 s) and the same behaviour: interval or hourly, one playback
per TG at a time 1.5 s apart, retries while the TG or every slot is
busy, 58 ms per frame, stop on a refused frame, TTS encoded in a
thread. Versioned next to plugins/example.
Core side, found by an end-to-end run of the plugin:
- PluginIngress ends a plugin voice stream that has been silent for a
second without a terminator (frees the slot, monitor END);
- the default max_frames_per_s adds 18 frames/s per group voice TG, so
one stream per TG fits (a voice stream is ~17 frames/s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What a voice plugin needs from the core that it can't see itself:
- ServerContext.voice_slot_for_tg(tg): the MASTER slot to speak a TG on
now, or None while every slot is busy. Same choice announcements make
today (dynamic UA session or active bridge leg of the TG on that
MASTER first, then TS2, then TS1), ported to PluginIngress so the
announcement code can leave the core. Only for granted talkgroups.
- PluginIngress reports GROUP VOICE START/END,TX on the MASTER a plugin
stream plays on, the line announcements send today: its hotspots hear
the stream but no bridge leg reports it. A stream cut by a radio, or
never terminated, still gets its END.
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>