Merge pull request #106 from pyopower/feat/announcements-plugin
refactor(voice): scheduled announcements, TTS and beacons as a plugindevelop
commit
bec05936f9
@ -0,0 +1,23 @@
|
||||
# voice-announcements
|
||||
|
||||
Scheduled announcements, TTS announcements and voice beacons on talkgroups. This used to be part of the core (`VoiceUseCases`) and now is a plugin that speaks through `ServerContext.send_dmrd`.
|
||||
|
||||
**Nothing changes for sysops.** The items are still configured in `adn-voice.yaml` (`VOICE.ANNOUNCEMENTS`, `VOICE.TTS_ANNOUNCEMENTS`, `AUDIO_PATH`, `TTS_*`), with the same keys and behaviour, and changes are picked up within 15 s. The plugin is enabled by default. The server grants it exactly the talkgroups and DMR IDs those items use, unless `PLUGINS.send.voice-announcements` says otherwise.
|
||||
|
||||
## Behaviour
|
||||
|
||||
It is the same as the core announcements it replaces:
|
||||
|
||||
- **Schedule:** `MODE: interval` plays every `INTERVAL` s. `MODE: hourly` plays once in the first minute of each hour.
|
||||
- **Talkgroups:** one playback per talkgroup at a time; others wait their turn, 1.5 s apart.
|
||||
- **Slot:** the slot the server chooses (`voice_slot_for_tg`) is where the TG is a dynamic session or has an active bridge on the MASTER, then TS2, then TS1. While every slot is busy, it retries every 5 s (up to 60 times).
|
||||
- **Routing:** like any announcement, through the TG's bridges (OpenBridge included) and to the MASTER's hotspots.
|
||||
- **Pacing and collisions:** 58 ms per frame. It stops when a radio takes the slot.
|
||||
- **TTS:** `.txt` → speech → `.ambe`, encoded in a worker thread and cached, as before.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd plugins/voice-announcements
|
||||
PYTHONPATH=../../src python3 -m pytest tests/ -q
|
||||
```
|
||||
@ -0,0 +1,7 @@
|
||||
# Scheduled announcements, TTS and voice beacons (moved out of the core).
|
||||
# The items themselves stay where they were: VOICE.ANNOUNCEMENTS and
|
||||
# VOICE.TTS_ANNOUNCEMENTS in adn-voice.yaml (re-read every 15 s).
|
||||
enabled: true
|
||||
|
||||
# How often to follow changes in adn-voice.yaml, in seconds.
|
||||
reconcile_s: 15
|
||||
@ -0,0 +1,210 @@
|
||||
# ADN DMR Peer Server plugin - voice-announcements announcer
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Plays scheduled announcements and TTS on their talkgroups through ``send_dmrd``.
|
||||
|
||||
Everything runs on the reactor (``call_later``); only TTS encoding goes to a thread.
|
||||
Behaviour follows the core announcements it replaces: one playback per talkgroup at a
|
||||
time, retries while the talkgroup or every slot is busy, 58 ms per frame, and the
|
||||
playback stops as soon as the server refuses a frame (a radio took the slot).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from adn_server.domain import bytes_3
|
||||
from adn_server.domain.mesh_engine import server_id_bytes
|
||||
|
||||
from ..domain.schedule import FILE, Item, enabled_items, hourly_due
|
||||
from ..domain.tg_queue import TalkgroupQueue
|
||||
from ..infrastructure.tts_engine import ensure_tts_ambe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRAME_INTERVAL_S = 0.058
|
||||
START_DELAY_S = 0.5
|
||||
GAP_BETWEEN_PLAYBACKS_S = 1.5
|
||||
TG_BUSY_RETRY_S = 3.0
|
||||
SLOT_BUSY_RETRY_S = 5.0
|
||||
MAX_RETRIES = 60
|
||||
|
||||
|
||||
class Announcer:
|
||||
def __init__(
|
||||
self,
|
||||
ctx: Any,
|
||||
voice: Any,
|
||||
tts: Callable[[dict[str, Any], dict[str, Any], str], str | None] | None = None,
|
||||
now: Callable[[], datetime] = datetime.now,
|
||||
) -> None:
|
||||
self._ctx = ctx
|
||||
self._voice = voice
|
||||
self._tts = tts if tts is not None else ensure_tts_ambe
|
||||
self._now = now
|
||||
self._items: dict[tuple[str, int], Item] = {}
|
||||
self._timers: dict[tuple[str, int], Any] = {}
|
||||
self._running: set[tuple[str, int]] = set()
|
||||
self._last_hour: dict[tuple[str, int], int] = {}
|
||||
self._queue: TalkgroupQueue[tuple[Item, list]] = TalkgroupQueue()
|
||||
self._stopped = False
|
||||
|
||||
# --- schedule -------------------------------------------------------------
|
||||
|
||||
def reconcile(self) -> None:
|
||||
"""Start, restart or stop item timers to match the current ``VOICE`` config."""
|
||||
wanted = {item.key: item for item in enabled_items(self._ctx.config)}
|
||||
for key in list(self._timers):
|
||||
if key not in wanted or wanted[key] != self._items.get(key):
|
||||
self._cancel(key)
|
||||
logger.info("(VOICE-RELOAD) %s stopped", self._items[key].label)
|
||||
del self._items[key]
|
||||
for key, item in wanted.items():
|
||||
if key in self._timers:
|
||||
continue
|
||||
self._items[key] = item
|
||||
self._timers[key] = self._ctx.call_later(item.tick_s, self._tick, key)
|
||||
logger.info(
|
||||
"(VOICE-RELOAD) %s enabled - mode: %s, file: %s, TG: %s", item.label, item.mode, item.file, item.tg
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._stopped = True
|
||||
for key in list(self._timers):
|
||||
self._cancel(key)
|
||||
|
||||
def _cancel(self, key: tuple[str, int]) -> None:
|
||||
timer = self._timers.pop(key, None)
|
||||
try:
|
||||
if timer is not None and timer.active():
|
||||
timer.cancel()
|
||||
except Exception: # already fired
|
||||
pass
|
||||
|
||||
def _tick(self, key: tuple[str, int]) -> None:
|
||||
item = self._items.get(key)
|
||||
if item is None or self._stopped:
|
||||
return
|
||||
self._timers[key] = self._ctx.call_later(item.tick_s, self._tick, key)
|
||||
self.fire(item)
|
||||
|
||||
# --- one playback ---------------------------------------------------------
|
||||
|
||||
def fire(self, item: Item, retry: int = 0) -> None:
|
||||
if item.key in self._running:
|
||||
if retry == 0:
|
||||
logger.debug("(%s) Previous playback still running, skipping", item.label)
|
||||
return
|
||||
if item.mode == "hourly" and retry == 0 and not hourly_due(self._now(), self._last_hour.get(item.key)):
|
||||
return
|
||||
if self._queue.on_air(item.tg) and retry < MAX_RETRIES:
|
||||
if retry == 0:
|
||||
logger.debug("(%s) Same TG %s already broadcasting, deferring", item.label, item.tg)
|
||||
self._ctx.call_later(TG_BUSY_RETRY_S + item.index * 0.5, self.fire, item, retry + 1)
|
||||
return
|
||||
self._running.add(item.key)
|
||||
if item.mode == "hourly":
|
||||
self._last_hour[item.key] = self._now().hour
|
||||
if item.kind == FILE:
|
||||
self._load(item)
|
||||
return
|
||||
logger.info("(%s) Starting TTS conversion in background thread for %s", item.label, item.file)
|
||||
d = self._ctx.defer_to_thread(self._tts, self._ctx.config, item.raw, self._audio_path())
|
||||
d.addCallback(self._tts_ready, item)
|
||||
d.addErrback(self._tts_failed, item)
|
||||
|
||||
def _tts_ready(self, ambe_path: str | None, item: Item) -> None:
|
||||
if not ambe_path:
|
||||
logger.warning("(%s) No AMBE file available for TTS announcement %s", item.label, item.file)
|
||||
self._running.discard(item.key)
|
||||
return
|
||||
self._load(item)
|
||||
|
||||
def _tts_failed(self, failure: Any, item: Item) -> None:
|
||||
self._running.discard(item.key)
|
||||
message = failure.getErrorMessage() if hasattr(failure, "getErrorMessage") else str(failure)
|
||||
logger.error("(%s) TTS conversion error: %s", item.label, message)
|
||||
|
||||
def _load(self, item: Item) -> None:
|
||||
name = item.file[:-5] if item.file.endswith(".ambe") else item.file
|
||||
try:
|
||||
ambe = self._voice.read_single_file(self._audio_path(), item.language, name)
|
||||
except Exception as e:
|
||||
ambe = None
|
||||
logger.warning("(%s) Cannot read AMBE file %s/ondemand/%s.ambe: %s", item.label, item.language, name, e)
|
||||
if not ambe:
|
||||
logger.warning("(%s) AMBE file empty or not found: %s/ondemand/%s.ambe", item.label, item.language, name)
|
||||
self._running.discard(item.key)
|
||||
return
|
||||
if self._queue.request(item.tg, (item, ambe)):
|
||||
self._begin(item, ambe)
|
||||
else:
|
||||
logger.info("(%s) Same TG %s still on air; deferring next playback (pending %s)", item.label, item.tg, len(self._queue))
|
||||
|
||||
def _begin(self, item: Item, ambe: list, retry: int = 0) -> None:
|
||||
"""Holds the TG in the queue while it waits for a free slot."""
|
||||
if self._stopped:
|
||||
return
|
||||
slot_for_tg = getattr(self._ctx, "voice_slot_for_tg", None)
|
||||
slot = slot_for_tg(item.tg) if slot_for_tg is not None else None
|
||||
if slot is None:
|
||||
if slot_for_tg is not None and retry < MAX_RETRIES:
|
||||
if retry == 0:
|
||||
logger.info("(%s) All target slots busy (QSO active), waiting for QSO to finish...", item.label)
|
||||
self._ctx.call_later(SLOT_BUSY_RETRY_S, self._begin, item, ambe, retry + 1)
|
||||
return
|
||||
logger.warning("(%s) No slot to speak TG %s on; not played", item.label, item.tg)
|
||||
self._finish(item)
|
||||
return
|
||||
pkts = list(self._voice.pkt_gen(bytes_3(item.dmr_id), bytes_3(item.tg), self._server_id(), 1 if slot == 2 else 0, [ambe]))
|
||||
logger.info("(%s) Playing %s to TG %s on TS%s (%s packets)", item.label, item.file, item.tg, slot, len(pkts))
|
||||
self._ctx.call_later(START_DELAY_S, self._play, item, pkts, 0, None)
|
||||
|
||||
def _play(self, item: Item, pkts: list[bytes], idx: int, next_time: float | None) -> None:
|
||||
if self._stopped:
|
||||
return
|
||||
if idx >= len(pkts):
|
||||
logger.info("(%s) Broadcast complete: %s packets", item.label, len(pkts))
|
||||
self._finish(item)
|
||||
return
|
||||
if not self._ctx.send_dmrd(pkts[idx]):
|
||||
logger.info("(%s) Broadcast stopped at packet %s/%s: slot taken (QSO)", item.label, idx, len(pkts))
|
||||
self._finish(item)
|
||||
return
|
||||
next_time = (time.time() if next_time is None else next_time) + FRAME_INTERVAL_S
|
||||
self._ctx.call_later(max(0.001, next_time - time.time()), self._play, item, pkts, idx + 1, next_time)
|
||||
|
||||
def _finish(self, item: Item) -> None:
|
||||
self._running.discard(item.key)
|
||||
nxt = self._queue.finished(item.tg)
|
||||
if nxt is not None:
|
||||
self._ctx.call_later(GAP_BETWEEN_PLAYBACKS_S, self._begin, *nxt)
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
def _audio_path(self) -> str:
|
||||
return os.path.join(self._ctx.project_root, (self._ctx.config.get("VOICE") or {}).get("AUDIO_PATH", "Audio"))
|
||||
|
||||
def _server_id(self) -> bytes:
|
||||
return server_id_bytes(self._ctx.config.get("GLOBAL", {}).get("SERVER_ID"))[:4]
|
||||
@ -0,0 +1,80 @@
|
||||
# ADN DMR Peer Server plugin - voice-announcements adapter
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""ServerPlugin adapter: scheduled announcements, TTS and voice beacons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from adn_server.infrastructure.voice import DefaultVoiceProvider
|
||||
|
||||
from .announcer import Announcer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VoiceAnnouncementsPlugin:
|
||||
name = "voice-announcements"
|
||||
events = () # listens to nothing: it only speaks
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._announcer: Announcer | None = None
|
||||
self._ctx: Any = None
|
||||
self._reconcile_s = 15.0
|
||||
self._timer: Any = None
|
||||
|
||||
def on_load(self, bus: Any, config: dict[str, Any], server_ctx: Any) -> None:
|
||||
del bus
|
||||
self._ctx = server_ctx
|
||||
self._reconcile_s = float(config.get("reconcile_s", 15))
|
||||
if server_ctx.send_dmrd is None or getattr(server_ctx, "voice_slot_for_tg", None) is None:
|
||||
logger.warning(
|
||||
"(VOICE) %s is not allowed to send group voice: add it to PLUGINS.send with "
|
||||
"allowed_src_ids and group_voice_tgs; announcements will not play",
|
||||
self.name,
|
||||
)
|
||||
return
|
||||
self._announcer = Announcer(server_ctx, DefaultVoiceProvider())
|
||||
self._reconcile()
|
||||
|
||||
def _reconcile(self) -> None:
|
||||
if self._announcer is None:
|
||||
return
|
||||
self._announcer.reconcile()
|
||||
# The core re-reads adn-voice.yaml every 15 s into config["VOICE"]; follow it.
|
||||
self._timer = self._ctx.call_later(self._reconcile_s, self._reconcile)
|
||||
|
||||
def on_reload(self, config: dict[str, Any]) -> None:
|
||||
self._reconcile_s = float(config.get("reconcile_s", self._reconcile_s))
|
||||
|
||||
def on_shutdown(self) -> None:
|
||||
if self._announcer is not None:
|
||||
self._announcer.shutdown()
|
||||
self._announcer = None
|
||||
try:
|
||||
if self._timer is not None and self._timer.active():
|
||||
self._timer.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_event(self, event: object) -> None:
|
||||
pass
|
||||
@ -0,0 +1,101 @@
|
||||
# ADN DMR Peer Server plugin - voice-announcements schedule
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Scheduled announcements and TTS items, read from the server's ``VOICE`` section.
|
||||
|
||||
The same keys the core used (adn-voice.yaml), so existing installs keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from adn_server.application.server_voice import announcement_item_dmr_id
|
||||
|
||||
FILE = "file"
|
||||
TTS = "tts"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Item:
|
||||
kind: str # FILE (pre-recorded .ambe) or TTS (.txt spoken, cached as .ambe)
|
||||
index: int
|
||||
file: str
|
||||
tg: int
|
||||
language: str
|
||||
mode: str # "interval" or "hourly"
|
||||
interval_s: float
|
||||
dmr_id: int
|
||||
raw: dict[str, Any] # the item as configured: TTS settings live here too
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"{'ANNOUNCEMENT' if self.kind == FILE else 'TTS'}-{self.index + 1}"
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, int]:
|
||||
return (self.kind, self.index)
|
||||
|
||||
@property
|
||||
def tick_s(self) -> float:
|
||||
"""How often the item's timer fires: hourly items check the clock every 30 s."""
|
||||
return 30.0 if self.mode == "hourly" else self.interval_s
|
||||
|
||||
|
||||
def enabled_items(config: dict[str, Any]) -> list[Item]:
|
||||
"""Enabled, playable items of ``VOICE.ANNOUNCEMENTS`` and ``VOICE.TTS_ANNOUNCEMENTS``."""
|
||||
voice = config.get("VOICE") or {}
|
||||
items: list[Item] = []
|
||||
for kind, section in ((FILE, "ANNOUNCEMENTS"), (TTS, "TTS_ANNOUNCEMENTS")):
|
||||
entries = voice.get(section) or []
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for index, raw in enumerate(entries):
|
||||
if not isinstance(raw, dict) or not raw.get("ENABLED"):
|
||||
continue
|
||||
try:
|
||||
tg = int(raw.get("TG", 0))
|
||||
interval = float(raw.get("INTERVAL", 60))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
file = str(raw.get("FILE") or "").strip()
|
||||
if not file or not tg or interval <= 0:
|
||||
continue
|
||||
items.append(
|
||||
Item(
|
||||
kind=kind,
|
||||
index=index,
|
||||
file=file,
|
||||
tg=tg,
|
||||
language=str(raw.get("LANGUAGE", "en_GB")),
|
||||
mode=str(raw.get("MODE", "interval")),
|
||||
interval_s=interval,
|
||||
dmr_id=announcement_item_dmr_id(raw, config),
|
||||
raw=dict(raw),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def hourly_due(now: datetime, last_hour: int | None) -> bool:
|
||||
"""Hourly items play once, in the first minute of each hour."""
|
||||
return now.minute == 0 and last_hour != now.hour
|
||||
@ -0,0 +1,57 @@
|
||||
# ADN DMR Peer Server plugin - voice-announcements talkgroup queue
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""One playback at a time per talkgroup; the rest wait their turn, in order."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class TalkgroupQueue(Generic[T]):
|
||||
def __init__(self) -> None:
|
||||
self._on_air: set[int] = set()
|
||||
self._waiting: list[tuple[int, T]] = []
|
||||
|
||||
def on_air(self, tg: int) -> bool:
|
||||
return tg in self._on_air
|
||||
|
||||
def request(self, tg: int, job: T) -> bool:
|
||||
"""True: start ``job`` now (its TG is free). False: queued behind the one on air."""
|
||||
if tg in self._on_air:
|
||||
self._waiting.append((tg, job))
|
||||
return False
|
||||
self._on_air.add(tg)
|
||||
return True
|
||||
|
||||
def finished(self, tg: int) -> T | None:
|
||||
"""Free ``tg``; the next waiting job whose TG is free, now marked on air."""
|
||||
self._on_air.discard(tg)
|
||||
for i, (waiting_tg, job) in enumerate(self._waiting):
|
||||
if waiting_tg not in self._on_air:
|
||||
del self._waiting[i]
|
||||
self._on_air.add(waiting_tg)
|
||||
return job
|
||||
return None
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._waiting)
|
||||
@ -0,0 +1,28 @@
|
||||
# ADN DMR Peer Server plugin - example test path
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
@ -0,0 +1,258 @@
|
||||
# ADN DMR Peer Server plugin - voice-announcements tests
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Scheduling, per-TG queue, busy slots, QSO cut, hourly and TTS — on a simulated reactor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import itertools
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from plugin.application import announcer as ann
|
||||
from plugin.application.announcer import Announcer
|
||||
from plugin.application.plugin_impl import VoiceAnnouncementsPlugin
|
||||
|
||||
|
||||
class Reactor:
|
||||
"""call_later on a fake clock; the announcer's time.time follows it."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.now = 1000.0
|
||||
self._heap: list = []
|
||||
self._seq = itertools.count()
|
||||
|
||||
def call_later(self, delay, fn, *args):
|
||||
handle = SimpleNamespace(cancelled=False, called=False)
|
||||
handle.active = lambda h=handle: not (h.cancelled or h.called)
|
||||
handle.cancel = lambda h=handle: setattr(h, "cancelled", True)
|
||||
heapq.heappush(self._heap, (self.now + delay, next(self._seq), handle, fn, args))
|
||||
return handle
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
end = self.now + seconds
|
||||
while self._heap and self._heap[0][0] <= end:
|
||||
when, _, handle, fn, args = heapq.heappop(self._heap)
|
||||
self.now = when
|
||||
if not handle.cancelled:
|
||||
handle.called = True
|
||||
fn(*args)
|
||||
self.now = end
|
||||
|
||||
|
||||
class Voice:
|
||||
frames = 5
|
||||
|
||||
def read_single_file(self, audio_path, lang, name):
|
||||
return [["ambe"]] if name != "missing" else []
|
||||
|
||||
def pkt_gen(self, src, dst, peer, slot_bit, phrase):
|
||||
return [b"DMRD" + bytes([n]) + src + dst + peer + bytes([slot_bit << 7]) for n in range(self.frames)]
|
||||
|
||||
def ensure_tts_ambe(self, config, item, audio_path):
|
||||
return "/tmp/x.ambe"
|
||||
|
||||
|
||||
class Deferred:
|
||||
def __init__(self, result):
|
||||
self.result = result
|
||||
|
||||
def addCallback(self, fn, *a):
|
||||
if not isinstance(self.result, Exception):
|
||||
fn(self.result, *a)
|
||||
return self
|
||||
|
||||
def addErrback(self, fn, *a):
|
||||
if isinstance(self.result, Exception):
|
||||
fn(self.result, *a)
|
||||
return self
|
||||
|
||||
|
||||
def _ctx(reactor, voice_cfg, *, slots=None, refuse_at=None):
|
||||
sent: list[bytes] = []
|
||||
slots = slots if slots is not None else [2]
|
||||
|
||||
def send(pkt):
|
||||
if refuse_at is not None and len(sent) == refuse_at:
|
||||
return False
|
||||
sent.append(pkt)
|
||||
return True
|
||||
|
||||
def slot_for_tg(tg):
|
||||
return slots.pop(0) if len(slots) > 1 else slots[0]
|
||||
|
||||
ctx = SimpleNamespace(
|
||||
config={"GLOBAL": {"SERVER_ID": 2131}, "VOICE": voice_cfg},
|
||||
project_root="/tmp", call_later=reactor.call_later, send_dmrd=send, voice_slot_for_tg=slot_for_tg,
|
||||
defer_to_thread=lambda fn, *a: Deferred(fn(*a)),
|
||||
)
|
||||
return ctx, sent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reactor(monkeypatch):
|
||||
r = Reactor()
|
||||
monkeypatch.setattr(ann.time, "time", lambda: r.now)
|
||||
return r
|
||||
|
||||
|
||||
def _ann(file="beacon", tg=213, interval=60, **kw):
|
||||
return {"ENABLED": True, "FILE": file, "TG": tg, "MODE": "interval", "INTERVAL": interval, "LANGUAGE": "es_ES", **kw}
|
||||
|
||||
|
||||
def test_an_interval_announcement_plays_every_interval(reactor) -> None:
|
||||
ctx, sent = _ctx(reactor, {"ANNOUNCEMENTS": [_ann(DMR_ID=2130035)]})
|
||||
a = Announcer(ctx, Voice())
|
||||
a.reconcile()
|
||||
reactor.advance(59)
|
||||
assert sent == []
|
||||
reactor.advance(2)
|
||||
assert len(sent) == Voice.frames
|
||||
assert sent[0][5:8] == (2130035).to_bytes(3, "big") and sent[0][8:11] == (213).to_bytes(3, "big")
|
||||
assert sent[0][-1] == 0x80 # TS2, as voice_slot_for_tg said
|
||||
reactor.advance(60)
|
||||
assert len(sent) == 2 * Voice.frames
|
||||
|
||||
|
||||
def test_frames_are_paced_58_ms_apart(reactor) -> None:
|
||||
ctx, _ = _ctx(reactor, {"ANNOUNCEMENTS": [_ann()]})
|
||||
times: list[float] = []
|
||||
send = ctx.send_dmrd
|
||||
ctx.send_dmrd = lambda pkt: times.append(reactor.now) or send(pkt)
|
||||
Announcer(ctx, Voice()).reconcile()
|
||||
reactor.advance(61)
|
||||
gaps = [round(b - a, 3) for a, b in zip(times, times[1:])]
|
||||
assert gaps == [0.058] * (Voice.frames - 1)
|
||||
|
||||
|
||||
def test_a_refused_frame_stops_the_playback(reactor) -> None:
|
||||
ctx, sent = _ctx(reactor, {"ANNOUNCEMENTS": [_ann()]}, refuse_at=2)
|
||||
a = Announcer(ctx, Voice())
|
||||
a.reconcile()
|
||||
reactor.advance(61)
|
||||
assert len(sent) == 2
|
||||
assert not a._running # free to play at the next interval
|
||||
|
||||
|
||||
def test_two_items_on_one_tg_take_turns(reactor) -> None:
|
||||
ctx, sent = _ctx(reactor, {"ANNOUNCEMENTS": [_ann("one"), _ann("two")]})
|
||||
Announcer(ctx, Voice()).reconcile()
|
||||
reactor.advance(60.6) # both fired at 60 s; the first is playing
|
||||
assert 0 < len(sent) <= Voice.frames
|
||||
reactor.advance(10)
|
||||
assert len(sent) == 2 * Voice.frames
|
||||
|
||||
|
||||
def test_busy_slots_are_retried_every_5_s(reactor) -> None:
|
||||
ctx, sent = _ctx(reactor, {"ANNOUNCEMENTS": [_ann()]}, slots=[None, None, 1])
|
||||
Announcer(ctx, Voice()).reconcile()
|
||||
reactor.advance(61)
|
||||
assert sent == []
|
||||
reactor.advance(10.6)
|
||||
assert len(sent) == Voice.frames and sent[0][-1] == 0x00 # TS1 once free
|
||||
|
||||
|
||||
def test_hourly_plays_once_in_the_first_minute_of_the_hour(reactor) -> None:
|
||||
clock = {"t": datetime(2026, 9, 24, 21, 59, 10)}
|
||||
ctx, sent = _ctx(reactor, {"ANNOUNCEMENTS": [_ann(MODE="hourly")]})
|
||||
Announcer(ctx, Voice(), now=lambda: clock["t"]).reconcile()
|
||||
reactor.advance(31)
|
||||
assert sent == []
|
||||
clock["t"] = datetime(2026, 9, 24, 22, 0, 5)
|
||||
reactor.advance(31)
|
||||
assert len(sent) == Voice.frames
|
||||
clock["t"] = datetime(2026, 9, 24, 22, 0, 40)
|
||||
reactor.advance(31)
|
||||
assert len(sent) == Voice.frames # not twice in the same hour
|
||||
|
||||
|
||||
def test_tts_is_encoded_in_a_thread_then_played(reactor) -> None:
|
||||
ctx, sent = _ctx(reactor, {"TTS_ANNOUNCEMENTS": [_ann("texto1")]})
|
||||
calls: list = []
|
||||
real = ctx.defer_to_thread
|
||||
ctx.defer_to_thread = lambda fn, *a: calls.append(fn.__name__) or real(fn, *a)
|
||||
Announcer(ctx, Voice(), tts=Voice().ensure_tts_ambe).reconcile()
|
||||
reactor.advance(61)
|
||||
assert calls == ["ensure_tts_ambe"] and len(sent) == Voice.frames
|
||||
|
||||
|
||||
def test_a_config_change_restarts_and_disabling_stops(reactor) -> None:
|
||||
voice = {"ANNOUNCEMENTS": [_ann(interval=60)]}
|
||||
ctx, sent = _ctx(reactor, voice)
|
||||
a = Announcer(ctx, Voice())
|
||||
a.reconcile()
|
||||
voice["ANNOUNCEMENTS"][0] = _ann(interval=10)
|
||||
a.reconcile()
|
||||
reactor.advance(11)
|
||||
assert len(sent) == Voice.frames
|
||||
voice["ANNOUNCEMENTS"][0]["ENABLED"] = False
|
||||
a.reconcile()
|
||||
reactor.advance(100)
|
||||
assert len(sent) == Voice.frames
|
||||
|
||||
|
||||
def test_a_missing_file_is_skipped(reactor) -> None:
|
||||
ctx, sent = _ctx(reactor, {"ANNOUNCEMENTS": [_ann("missing")]})
|
||||
a = Announcer(ctx, Voice())
|
||||
a.reconcile()
|
||||
reactor.advance(61)
|
||||
assert sent == [] and not a._running
|
||||
|
||||
|
||||
def test_without_a_send_grant_the_plugin_does_nothing(reactor) -> None:
|
||||
ctx, _ = _ctx(reactor, {"ANNOUNCEMENTS": [_ann()]})
|
||||
ctx.send_dmrd = None
|
||||
plugin = VoiceAnnouncementsPlugin()
|
||||
plugin.on_load(None, {}, ctx)
|
||||
assert plugin._announcer is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", [None, RuntimeError("gTTS down")])
|
||||
def test_a_failed_tts_conversion_frees_the_item(reactor, result) -> None:
|
||||
ctx, sent = _ctx(reactor, {"TTS_ANNOUNCEMENTS": [_ann("texto1")]})
|
||||
ctx.defer_to_thread = lambda fn, *a: Deferred(result)
|
||||
a = Announcer(ctx, Voice(), tts=lambda *a: None)
|
||||
a.reconcile()
|
||||
reactor.advance(61)
|
||||
assert sent == [] and not a._running
|
||||
|
||||
|
||||
def test_an_item_without_dmr_id_speaks_as_the_server_voice_id(reactor) -> None:
|
||||
from adn_server.application.server_voice import server_voice_dmr_id
|
||||
|
||||
ctx, sent = _ctx(reactor, {"ANNOUNCEMENTS": [_ann()]})
|
||||
Announcer(ctx, Voice()).reconcile()
|
||||
reactor.advance(61)
|
||||
assert int.from_bytes(sent[0][5:8], "big") == server_voice_dmr_id(ctx.config)
|
||||
|
||||
|
||||
def test_the_server_grants_the_plugin_what_voice_configures() -> None:
|
||||
from adn_server.application.plugins.domain.send import send_permission
|
||||
|
||||
config = {"VOICE": {"ANNOUNCEMENTS": [_ann(tg=213, DMR_ID=2130035)], "TTS_ANNOUNCEMENTS": [_ann(tg=9, ENABLED=False)]}}
|
||||
permission = send_permission(config, "voice-announcements")
|
||||
assert permission.group_voice_tgs == {213, 9} # disabled items too: enabling needs no restart
|
||||
assert 2130035 in permission.allowed_src_ids
|
||||
config["PLUGINS"] = {"send": {"voice-announcements": {"allowed_src_ids": [1], "group_voice_tgs": [9]}}}
|
||||
assert send_permission(config, "voice-announcements").group_voice_tgs == {9} # an explicit entry wins
|
||||
config["PLUGINS"] = {"master_kill": True}
|
||||
assert send_permission(config, "voice-announcements") is None
|
||||
@ -1,85 +0,0 @@
|
||||
# ADN DMR Peer Server - tests voice announcement anticollision
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Voice announcement anti-collision."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.voice_helpers import make_voice_uc, voice_master_scenario
|
||||
|
||||
from adn_server.application.server_voice import DEFAULT_SERVER_VOICE_ID
|
||||
from adn_server.domain import HBPF_SLT_VHEAD, HBPF_SLT_VTERM, bytes_3
|
||||
|
||||
|
||||
def test_build_targets_skips_busy_qso_slot() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD
|
||||
master.STATUS[2]["TX_TYPE"] = HBPF_SLT_VTERM
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
targets, busy = uc._build_announcement_targets(91, "91", "ANN-TEST")
|
||||
|
||||
assert targets == []
|
||||
assert busy == 1
|
||||
|
||||
|
||||
def test_build_targets_includes_idle_slot() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VTERM
|
||||
master.STATUS[2]["TX_TYPE"] = HBPF_SLT_VTERM
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
targets, busy = uc._build_announcement_targets(91, "91", "ANN-TEST")
|
||||
|
||||
assert busy == 0
|
||||
assert len(targets) == 1
|
||||
assert targets[0]["name"] == "MASTER-A"
|
||||
assert targets[0]["ts"] == 2
|
||||
|
||||
|
||||
def test_broadcast_aborts_when_qso_starts_mid_transmission() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VTERM
|
||||
master.STATUS[2]["TX_TYPE"] = HBPF_SLT_VTERM
|
||||
uc = make_voice_uc(scenario, master)
|
||||
targets = [{"sys_obj": master, "name": "MASTER-A", "slot": master.STATUS[2], "ts": 2}]
|
||||
pkts = {1: [b"\x00" * 55], 2: [b"\x00" * 55]}
|
||||
source = bytes_3(DEFAULT_SERVER_VOICE_ID)
|
||||
dst = bytes_3(91)
|
||||
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD
|
||||
uc._announcement_send_broadcast(targets, pkts, 0, source, dst, 91, 0, "ANN-TEST", None)
|
||||
|
||||
assert uc._announcement_running[0] is False
|
||||
assert master.sent == []
|
||||
|
||||
|
||||
def test_mark_slots_busy_stamps_server_voice_tx_row() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
uc = make_voice_uc(scenario, master)
|
||||
slot = master.STATUS[2]
|
||||
slot["TX_TYPE"] = HBPF_SLT_VTERM
|
||||
targets = [{"sys_obj": master, "name": "MASTER-A", "slot": slot, "ts": 2}]
|
||||
|
||||
uc._mark_slots_busy(targets)
|
||||
|
||||
assert slot["TX_TYPE"] == HBPF_SLT_VHEAD
|
||||
assert int.from_bytes(slot["TX_RFS"], "big") == DEFAULT_SERVER_VOICE_ID
|
||||
assert slot["TX_TIME"] > 0
|
||||
@ -1,74 +0,0 @@
|
||||
# ADN DMR Peer Server - tests voice broadcast queue
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Voice broadcast queue (same-TG serialization)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.voice_helpers import make_voice_uc, voice_master_scenario
|
||||
|
||||
from adn_server.application.server_voice import DEFAULT_SERVER_VOICE_ID
|
||||
from adn_server.domain import bytes_3
|
||||
|
||||
|
||||
def test_enqueue_broadcast_queues_second_same_tg() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
uc = make_voice_uc(scenario, master)
|
||||
targets = [{"sys_obj": master, "name": "MASTER-A", "slot": master.STATUS[2], "ts": 2}]
|
||||
pkts = {1: [b"\x00" * 55], 2: [b"\x00" * 55]}
|
||||
source = bytes_3(DEFAULT_SERVER_VOICE_ID)
|
||||
dst = bytes_3(91)
|
||||
|
||||
uc._broadcast_active_tgs.add("91")
|
||||
uc._enqueue_broadcast("ann", targets, pkts, source, dst, 91, 0, "ANN-2")
|
||||
|
||||
assert len(uc._broadcast_queue) == 1
|
||||
assert uc._scheduled == []
|
||||
|
||||
|
||||
def test_broadcast_queue_drains_after_first_finishes() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
uc = make_voice_uc(scenario, master)
|
||||
targets = [{"sys_obj": master, "name": "MASTER-A", "slot": master.STATUS[2], "ts": 2}]
|
||||
pkts = {1: [b"\x00" * 55], 2: [b"\x00" * 55]}
|
||||
source = bytes_3(DEFAULT_SERVER_VOICE_ID)
|
||||
dst = bytes_3(91)
|
||||
|
||||
uc._broadcast_active_tgs.add("91")
|
||||
uc._broadcast_queue.append(
|
||||
{
|
||||
"type": "ann",
|
||||
"targets": targets,
|
||||
"pkts_by_ts": pkts,
|
||||
"source_id": source,
|
||||
"dst_id": dst,
|
||||
"tg": 91,
|
||||
"num": 1,
|
||||
"label": "ANN-2",
|
||||
}
|
||||
)
|
||||
uc._broadcast_finished(91)
|
||||
assert len(uc._scheduled) == 1
|
||||
uc._scheduled.pop(0)[1][0]()
|
||||
|
||||
assert "91" in uc._broadcast_active_tgs
|
||||
assert uc._broadcast_queue == []
|
||||
assert len(uc._scheduled) == 1
|
||||
assert uc._scheduled[0][0] == 0.5
|
||||
@ -1,122 +0,0 @@
|
||||
# ADN DMR Peer Server - tests voice inject PTT peer resolution
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Synthetic announcement PTT uses SERVER_ID, not a hotspot peer id."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.scenarios import obp_bridge_scenario
|
||||
from tests.harness.voice_helpers import FakeMasterForVoice, make_voice_uc
|
||||
|
||||
from adn_server.domain import bytes_3, bytes_4, int_id
|
||||
|
||||
|
||||
def test_inject_packet_peer_is_always_server_id() -> None:
|
||||
scenario = obp_bridge_scenario("OBP-CL", tg=730600)
|
||||
scenario.config["PROXY"] = {"TARGET_SYSTEM": "MASTER-A"}
|
||||
peer = bytes_4(730039210)
|
||||
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
|
||||
sys_cfg["PEERS"] = {peer: {"CONNECTION": "YES", "OPTIONS": b"TS2=730500;SINGLE=1;"}}
|
||||
pk = bytes_4(730039210)
|
||||
sys_cfg.setdefault("_PEER_UA_MULTI_TGS", {}).setdefault(pk, {})[2] = {730600}
|
||||
master = FakeMasterForVoice("MASTER-A")
|
||||
master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2, "RX_STREAM_ID": b"\x00" * 4}
|
||||
scenario.protocols["MASTER-A"] = master
|
||||
uc = make_voice_uc(scenario, master)
|
||||
targets = [{"ts": 2}]
|
||||
|
||||
peer_field = uc._announcement_packet_peer(730600, targets, bytes_4(730039101))
|
||||
|
||||
assert int_id(peer_field) == int_id(uc._global_server_id_bytes())
|
||||
assert int_id(peer_field) != 730039210
|
||||
assert int_id(peer_field) != 730039101
|
||||
|
||||
|
||||
def test_inject_emits_master_tx_voice_events_for_monitor() -> None:
|
||||
scenario = obp_bridge_scenario("OBP-CL", tg=730600)
|
||||
master = FakeMasterForVoice("MASTER-A")
|
||||
uc = make_voice_uc(scenario, master)
|
||||
events: list[str] = []
|
||||
uc._send_routing_event = events.append # type: ignore[method-assign]
|
||||
stream_id = b"\xab\xcd\xef\x01"
|
||||
rf_src = bytes_3(1000001)
|
||||
pkts_by_ts = {
|
||||
2: [
|
||||
b"DMRD" + b"\x00" * 1 + rf_src + bytes_3(730600) + b"\x00" * 3 + b"\x80" + stream_id,
|
||||
],
|
||||
}
|
||||
targets = [{"name": "MASTER-A", "ts": 2, "slot": master.STATUS[2]}]
|
||||
|
||||
uc._maybe_begin_legacy_voice_report(targets, pkts_by_ts, 730600)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].startswith("GROUP VOICE,START,TX,MASTER-A,")
|
||||
assert ",2,730600" in events[0]
|
||||
assert uc._voice_report_state is not None
|
||||
|
||||
|
||||
def test_inject_ptt_prefers_dynamic_ua_slot() -> None:
|
||||
scenario = obp_bridge_scenario("OBP-CL", tg=730600)
|
||||
scenario.config["PROXY"] = {"TARGET_SYSTEM": "MASTER-A"}
|
||||
peer = bytes_4(730039210)
|
||||
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
|
||||
sys_cfg["PEERS"] = {peer: {"CONNECTION": "YES", "OPTIONS": b"TS2=730500;"}}
|
||||
pk = bytes_4(730039210)
|
||||
sys_cfg.setdefault("_PEER_UA_MULTI_TGS", {}).setdefault(pk, {})[2] = {730600}
|
||||
master = FakeMasterForVoice("MASTER-A")
|
||||
master.STATUS[2] = {
|
||||
"RX_TYPE": 1,
|
||||
"TX_TYPE": 1,
|
||||
"RX_TGID": bytes_3(730600),
|
||||
"TX_TGID": bytes_3(730600),
|
||||
"RX_STREAM_ID": b"\x01" * 4,
|
||||
}
|
||||
master.STATUS[1] = {"RX_TYPE": 2, "TX_TYPE": 2, "RX_STREAM_ID": b"\x00" * 4}
|
||||
scenario.protocols["MASTER-A"] = master
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
targets, busy = uc._build_inject_ptt_targets("TTS-2", 730600)
|
||||
|
||||
assert busy == 0
|
||||
assert len(targets) == 1
|
||||
assert targets[0]["ts"] == 2
|
||||
|
||||
|
||||
def test_inject_ptt_prefers_active_bridge_slot_for_static_tg() -> None:
|
||||
scenario = obp_bridge_scenario("OBP-CL", tg=730500)
|
||||
scenario.config["PROXY"] = {"TARGET_SYSTEM": "MASTER-A"}
|
||||
peer = bytes_4(730039210)
|
||||
sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"]
|
||||
sys_cfg["PEERS"] = {peer: {"CONNECTION": "YES", "OPTIONS": b"TS2=730500;"}}
|
||||
master = FakeMasterForVoice("MASTER-A")
|
||||
master.STATUS[2] = {
|
||||
"RX_TYPE": 1,
|
||||
"TX_TYPE": 1,
|
||||
"TX_TGID": bytes_3(730500),
|
||||
"RX_STREAM_ID": b"\x01" * 4,
|
||||
}
|
||||
master.STATUS[1] = {"RX_TYPE": 2, "TX_TYPE": 2, "RX_STREAM_ID": b"\x00" * 4}
|
||||
scenario.protocols["MASTER-A"] = master
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
targets, busy = uc._build_inject_ptt_targets("TTS-1", 730500)
|
||||
|
||||
assert busy == 0
|
||||
assert targets[0]["ts"] == 2
|
||||
@ -1,92 +0,0 @@
|
||||
# ADN DMR Peer Server - tests voice scheduled announcement
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Scheduled file announcements (AMBE on disk)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.harness.voice_helpers import (
|
||||
drain_call_later,
|
||||
make_voice_uc,
|
||||
voice_announcement_config,
|
||||
voice_master_scenario,
|
||||
)
|
||||
|
||||
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM
|
||||
|
||||
|
||||
def test_scheduled_announcement_starts_broadcast_on_idle_slot() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_announcement_config(scenario)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_announcement(0)
|
||||
|
||||
assert uc._announcement_running[0] is True
|
||||
assert "91" in uc._broadcast_active_tgs
|
||||
assert master.STATUS[2]["TX_TYPE"] == HBPF_SLT_VHEAD
|
||||
assert len(uc._scheduled) == 1
|
||||
delay, _scheduled = uc._scheduled[0]
|
||||
assert delay == 0.5
|
||||
drain_call_later(uc)
|
||||
assert len(master.sent) == 3
|
||||
assert len(scenario.capture.for_system("MASTER-B")) == 3
|
||||
|
||||
|
||||
def test_scheduled_announcement_retries_when_slot_busy() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_announcement_config(scenario)
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_announcement(0)
|
||||
|
||||
assert uc._announcement_running.get(0) is not True
|
||||
assert len(uc._scheduled) == 1
|
||||
assert master.sent == []
|
||||
delay, (_fn, args) = uc._scheduled[0]
|
||||
assert delay == 5.0
|
||||
assert args == (0, 1)
|
||||
|
||||
|
||||
def test_scheduled_announcement_skips_when_disabled() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_announcement_config(scenario, enabled=False)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_announcement(0)
|
||||
|
||||
assert uc._announcement_running.get(0) is not True
|
||||
assert uc._scheduled == []
|
||||
|
||||
|
||||
def test_scheduled_announcement_sends_packets_to_master() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_announcement_config(scenario)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_announcement(0)
|
||||
drain_call_later(uc)
|
||||
|
||||
assert uc._announcement_running[0] is False
|
||||
assert "91" not in uc._broadcast_active_tgs
|
||||
assert len(master.sent) == 3
|
||||
assert len(scenario.capture.for_system("MASTER-B")) == 3
|
||||
assert master.STATUS[2]["TX_TYPE"] == HBPF_SLT_VTERM
|
||||
@ -1,185 +0,0 @@
|
||||
# ADN DMR Peer Server - tests voice scheduled tts
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Scheduled TTS announcements and conversion callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.harness.scenarios import obp_bridge_scenario
|
||||
from tests.harness.voice_helpers import (
|
||||
FakeMasterForVoice,
|
||||
drain_call_later,
|
||||
make_voice_uc,
|
||||
voice_master_scenario,
|
||||
voice_tts_config,
|
||||
)
|
||||
|
||||
from adn_server.application.voice_use_cases import VoiceUseCases
|
||||
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD
|
||||
|
||||
|
||||
def test_scheduled_tts_sync_path_enqueues_broadcast() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_tts_config(scenario)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc.scheduled_tts_announcement(0)
|
||||
|
||||
assert uc._broadcast_active_tgs == {"91"}
|
||||
assert len(uc._scheduled) == 1
|
||||
assert getattr(uc._scheduled[0][1][0], "__name__", "") == "_tts_send_broadcast"
|
||||
|
||||
|
||||
def test_inject_tts_starts_without_active_bridge_and_forwards_obp() -> None:
|
||||
"""Synthetic PTT must not require a pre-armed bridge row (UA relay created on inject)."""
|
||||
scenario = obp_bridge_scenario("OBP-CL", tg=730500)
|
||||
scenario.config["PROXY"] = {"TARGET_SYSTEM": "MASTER-A"}
|
||||
scenario.seed_routing_table({})
|
||||
master = FakeMasterForVoice("MASTER-A")
|
||||
master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2, "RX_STREAM_ID": b"\x00" * 4}
|
||||
scenario.protocols["MASTER-A"] = master
|
||||
voice_tts_config(scenario, tg=730500, file_number="730500")
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
uc._tts_conversion_done("/tmp/fake.ambe", 0, "730500", 730500, "en_GB", "interval", "TTS-1")
|
||||
|
||||
assert uc._broadcast_active_tgs == {"730500"}
|
||||
drain_call_later(uc)
|
||||
assert len(scenario.capture.for_system("OBP-CL")) == 3
|
||||
assert len(master.sent) == 3
|
||||
|
||||
|
||||
def test_tts_conversion_error_clears_running_flag() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
uc = make_voice_uc(scenario, master)
|
||||
uc._tts_running[0] = True
|
||||
|
||||
uc._tts_conversion_error(RuntimeError("TTS failed"), 0, "TTS-1")
|
||||
|
||||
assert uc._tts_running[0] is False
|
||||
|
||||
|
||||
def test_tts_conversion_done_without_ambe_clears_running() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
uc = make_voice_uc(scenario, master)
|
||||
uc._tts_running[0] = True
|
||||
|
||||
uc._tts_conversion_done(None, 0, "msg.ambe", 91, "en_GB", "interval", "TTS-1")
|
||||
|
||||
assert uc._tts_running[0] is False
|
||||
assert uc._scheduled == []
|
||||
|
||||
|
||||
def test_tts_conversion_done_retries_when_slot_busy() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
scenario.seed_routing_table({})
|
||||
master.STATUS[1]["RX_TYPE"] = HBPF_SLT_VHEAD
|
||||
master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD
|
||||
uc = make_voice_uc(scenario, master)
|
||||
uc._tts_running[0] = True
|
||||
|
||||
uc._tts_conversion_done("/tmp/fake.ambe", 0, "msg.ambe", 91, "en_GB", "interval", "TTS-1")
|
||||
|
||||
assert uc._tts_running[0] is True
|
||||
assert len(uc._scheduled) == 1
|
||||
delay, (fn, args) = uc._scheduled[0]
|
||||
assert delay == 5.0
|
||||
assert getattr(fn, "__name__", "") == "_tts_conversion_done"
|
||||
assert args[-1] == 1
|
||||
|
||||
|
||||
def test_scheduled_tts_skips_outside_top_of_hour() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
scenario.config["VOICE"] = {
|
||||
"TTS_ANNOUNCEMENTS": [
|
||||
{
|
||||
"ENABLED": True,
|
||||
"TG": 91,
|
||||
"FILE": "hourly.ambe",
|
||||
"LANGUAGE": "en_GB",
|
||||
"MODE": "hourly",
|
||||
}
|
||||
]
|
||||
}
|
||||
uc = make_voice_uc(scenario, master)
|
||||
|
||||
with patch("adn_server.application.voice_use_cases.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = datetime(2026, 5, 24, 10, 30)
|
||||
uc.scheduled_tts_announcement(0)
|
||||
|
||||
assert uc._tts_running.get(0) is not True
|
||||
assert uc._scheduled == []
|
||||
|
||||
|
||||
def test_scheduled_tts_defers_when_same_tg_already_broadcasting() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_tts_config(scenario)
|
||||
uc = make_voice_uc(scenario, master)
|
||||
uc._broadcast_active_tgs.add("91")
|
||||
|
||||
uc.scheduled_tts_announcement(0)
|
||||
|
||||
assert uc._tts_running.get(0) is not True
|
||||
assert len(uc._scheduled) == 1
|
||||
delay, (fn, args) = uc._scheduled[0]
|
||||
assert delay == 3.0
|
||||
assert getattr(fn, "__name__", "") == "scheduled_tts_announcement"
|
||||
assert args == (0, 1)
|
||||
|
||||
|
||||
def test_scheduled_tts_sync_exception_clears_running() -> None:
|
||||
scenario, master = voice_master_scenario()
|
||||
voice_tts_config(scenario)
|
||||
|
||||
class FailingProvider:
|
||||
def ensure_tts_ambe(self, config, item, audio_path):
|
||||
del config, item, audio_path
|
||||
raise OSError("disk full")
|
||||
|
||||
def read_single_file(self, *args):
|
||||
return [b"\x00" * 7]
|
||||
|
||||
def pkt_gen(self, *args, **kwargs):
|
||||
return iter([])
|
||||
|
||||
def get_ambe_words(self, *args):
|
||||
return {}
|
||||
|
||||
scheduled: list[tuple[float, tuple]] = []
|
||||
|
||||
def call_later(delay, fn, *args):
|
||||
scheduled.append((delay, (fn, args)))
|
||||
return MagicMock()
|
||||
|
||||
uc = VoiceUseCases(
|
||||
FailingProvider(),
|
||||
scenario.config,
|
||||
get_protocols=lambda: {"MASTER-A": master},
|
||||
routing_table_for_report=scenario.routing.routing_table_for_report,
|
||||
call_later=call_later,
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
|
||||
uc.scheduled_tts_announcement(0)
|
||||
|
||||
assert uc._tts_running[0] is False
|
||||
@ -1,138 +0,0 @@
|
||||
# ADN DMR Peer Server - tests voice voice config reload
|
||||
#
|
||||
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||
#
|
||||
###############################################################################
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software Foundation,
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
"""Voice config reload and announcement LoopingCall management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.harness.voice_helpers import FakeVoiceProvider, voice_master_scenario
|
||||
|
||||
from adn_server.application.voice_use_cases import VoiceUseCases
|
||||
|
||||
|
||||
def _reload_uc(scenario, *, start_looping_call) -> VoiceUseCases:
|
||||
return VoiceUseCases(
|
||||
FakeVoiceProvider(),
|
||||
scenario.config,
|
||||
start_looping_call=start_looping_call,
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
|
||||
|
||||
def test_apply_voice_config_starts_enabled_announcement_loop() -> None:
|
||||
scenario, _ = voice_master_scenario()
|
||||
scenario.config["VOICE"] = {
|
||||
"ANNOUNCEMENTS": [
|
||||
{
|
||||
"ENABLED": True,
|
||||
"TG": 91,
|
||||
"FILE": "welcome",
|
||||
"LANGUAGE": "en_GB",
|
||||
"MODE": "interval",
|
||||
"INTERVAL": 120,
|
||||
}
|
||||
],
|
||||
}
|
||||
started: list[tuple[float, object]] = []
|
||||
|
||||
def start_looping_call(fn, interval, _now):
|
||||
started.append((interval, fn))
|
||||
handle = MagicMock(running=True)
|
||||
handle.stop = MagicMock()
|
||||
return handle
|
||||
|
||||
uc = _reload_uc(scenario, start_looping_call=start_looping_call)
|
||||
uc.apply_voice_config()
|
||||
|
||||
assert len(started) == 1
|
||||
assert started[0][0] == 120.0
|
||||
assert 0 in uc._ann_tasks
|
||||
|
||||
|
||||
def test_apply_voice_config_stops_removed_announcement() -> None:
|
||||
scenario, _ = voice_master_scenario()
|
||||
scenario.config["VOICE"] = {"ANNOUNCEMENTS": [{"ENABLED": False, "TG": 91, "FILE": "x"}]}
|
||||
stop_mock = MagicMock()
|
||||
uc = VoiceUseCases(
|
||||
FakeVoiceProvider(),
|
||||
scenario.config,
|
||||
start_looping_call=lambda *_a: MagicMock(running=True, stop=stop_mock),
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
uc._ann_tasks[0] = MagicMock(running=True, stop=stop_mock)
|
||||
|
||||
uc.apply_voice_config()
|
||||
|
||||
assert 0 not in uc._ann_tasks
|
||||
stop_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_apply_voice_config_starts_tts_loop() -> None:
|
||||
scenario, _ = voice_master_scenario()
|
||||
scenario.config["VOICE"] = {
|
||||
"TTS_ANNOUNCEMENTS": [
|
||||
{
|
||||
"ENABLED": True,
|
||||
"TG": 91,
|
||||
"FILE": "hourly.ambe",
|
||||
"LANGUAGE": "en_GB",
|
||||
"MODE": "hourly",
|
||||
}
|
||||
],
|
||||
}
|
||||
started: list[float] = []
|
||||
|
||||
def start_looping_call(_fn, interval, _now):
|
||||
started.append(interval)
|
||||
return MagicMock(running=True, stop=MagicMock())
|
||||
|
||||
uc = _reload_uc(scenario, start_looping_call=start_looping_call)
|
||||
uc.apply_voice_config()
|
||||
|
||||
assert started == [30.0]
|
||||
assert 0 in uc._tts_tasks
|
||||
|
||||
|
||||
def test_apply_voice_config_legacy_yaml_without_dmr_id() -> None:
|
||||
"""Legacy adn-voice.yaml without DMR_ID must reload without error."""
|
||||
scenario, _ = voice_master_scenario()
|
||||
scenario.config["VOICE"] = {
|
||||
"TTS_ANNOUNCEMENTS": [
|
||||
{
|
||||
"ENABLED": True,
|
||||
"TG": 730500,
|
||||
"FILE": "730500",
|
||||
"LANGUAGE": "es_ES",
|
||||
"MODE": "interval",
|
||||
"INTERVAL": 60,
|
||||
}
|
||||
],
|
||||
}
|
||||
uc = VoiceUseCases(
|
||||
FakeVoiceProvider(),
|
||||
scenario.config,
|
||||
start_looping_call=lambda *_a: MagicMock(running=True, stop=MagicMock()),
|
||||
audio_path="/tmp/audio",
|
||||
)
|
||||
uc.apply_voice_config()
|
||||
|
||||
assert 0 in uc._tts_tasks
|
||||
Loading…
Reference in new issue