Flere anlegg – hovedscene, resepsjon, crew – og ett sted å sende fra, med avkryssing for hvilke soner meldingen skal til. Sentralen er en vanlig announcer med [zones] i config. En sone er en vanlig announcer med ANNOUNCER_MESH_SECRET. Samme image, ingen mode, og en sone kan brukes alene hvis sentralen faller ut. Hver avhengighet med C-kode har aarch64-wheel i uv.lock, så det samme bildet går på en Raspberry Pi uten å kompilere noe. Sentralen syntetiserer én gang og sender ferdig WAV (POST /api/play, multipart). Sonen trenger da verken nett, edge-tts eller Pipers 670 MB for det den får – Item.wav er hele endringen i avspillingen. Køene forblir per sone: dispatcheren har egen tråd og egen kø, leverer til hver sone i hver sin tråd med timeout, og går videre. En opptatt hovedscene holder ikke igjen resepsjonen, og en sone som er nede feiler alene – synlig per sone i UI og revisjonslogg. Auth gjenbrukes: ny leverandør mesh på header X-Mesh-Key (ikke Bearer – den leses av JWT-leverandøren, som avviser alt som ikke er et JWT før neste får prøve), med én handling, play. To regler som fulgte med: - play følger aldri med «alt åpent». Uten det kunne hvem som helst POSTe en WAV til en åpen sone og få den spilt. Testen for åpen modus fanget det. - En legitimasjon som vises fram og er feil gir 401 også der gjester ellers har lov. Før slapp en feil PIN i åpen modus stille gjennom som gjest. mesh teller ikke som innlogging for folk: en sone med bare nøkkelen har fortsatt åpen side lokalt. Planlagte sendinger husker sonene sine og fordeles gjennom dispatcheren; planen ender som sent hvis minst én sone tok imot, med «hovedscene: kø #3, crew: nede» som note. Hodeløs Pi: install.sh kjører loginctl enable-linger, ellers finnes ikke lydsocketen når ingen logger inn. docs/mesh.md tar også raspi-config for jack framfor HDMI. 38 nye tester, 148 totalt. Fordelingen testes uten nett med stubbet transport; selve HTTP-en testes mot en levende werkzeug-server, så den håndrullede multiparten møter Flask sin parser. Verifisert i compose med sentral og to soner: til alle, bare til én, og én sone stoppet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
195 lines
7.5 KiB
Python
195 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
||
"""Planlagte sendinger: «send denne kl. 18:00».
|
||
|
||
Dette er den første delen av appen som *må* ligge på disk. Kø og historikk
|
||
har alltid bare levd i minnet, og det har gått bra fordi en melding spilles
|
||
med en gang. En melding planlagt til 18:00 må overleve at containeren
|
||
startes om 17:45, så den lagres i en JSON-fil som skrives atomisk.
|
||
|
||
Ingen database. Dette er en håndfull meldinger på et arrangement, og en fil
|
||
man kan lese med `cat` er verdt mer her enn indekser.
|
||
|
||
Modulen vet ingenting om Flask eller om avspilling. Den lagrer, og den svarer
|
||
på «hva skal sendes nå?». announcer.py eier tråden som spør.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import secrets
|
||
import threading
|
||
import time
|
||
from dataclasses import asdict, dataclass, field
|
||
from pathlib import Path
|
||
|
||
log = logging.getLogger("announcer.schedule")
|
||
|
||
# Hvor lenge etter planlagt tid vi fortsatt sender. Dette er slingringsmonn
|
||
# for at tråden våkner med noen sekunders mellomrom og for en kjapp omstart –
|
||
# ikke en policy om å ta igjen etterslep. Var maskina av en time, skal
|
||
# «turneringen starter om ti minutter» ikke ut på anlegget.
|
||
DEFAULT_GRACE = 120.0
|
||
|
||
# Hvor langt fram man får planlegge. Fanger opp tastefeil i årstall.
|
||
DEFAULT_MAX_AHEAD = 30 * 86400.0
|
||
|
||
# Hvor mange ferdige (sendt/hoppet over/avbrutt) vi beholder for visning.
|
||
KEEP_DONE = 50
|
||
|
||
# pending -> queued (lagt i kø) -> sent | failed | stopped
|
||
# -> missed (forfalt mens maskina var av) -> cancelled (avbrutt)
|
||
STATUSES = ("pending", "queued", "sent", "failed", "stopped", "missed", "cancelled")
|
||
|
||
|
||
@dataclass
|
||
class Scheduled:
|
||
id: str
|
||
at: float # epoch, absolutt – derfor ingen tidssone
|
||
text: str
|
||
voice: str
|
||
speed: float
|
||
chime: str
|
||
user: str = ""
|
||
subject: str = ""
|
||
method: str = "none"
|
||
created: float = field(default_factory=time.time)
|
||
status: str = "pending" # se STATUSES
|
||
note: str = ""
|
||
zones: list[str] = field(default_factory=list) # tom = lokalt / alle
|
||
|
||
def public(self) -> dict:
|
||
d = asdict(self)
|
||
d.pop("subject", None)
|
||
return d
|
||
|
||
|
||
class Schedule:
|
||
"""Trådsikker liste av planlagte meldinger, lagret i én JSON-fil."""
|
||
|
||
def __init__(self, path: Path, *, grace: float = DEFAULT_GRACE,
|
||
max_ahead: float = DEFAULT_MAX_AHEAD) -> None:
|
||
self.path = Path(path)
|
||
self.grace = float(grace)
|
||
self.max_ahead = float(max_ahead)
|
||
self._lock = threading.Lock()
|
||
self.items: list[Scheduled] = []
|
||
self._load()
|
||
|
||
# -- disk --------------------------------------------------------------
|
||
def _load(self) -> None:
|
||
if not self.path.is_file():
|
||
return
|
||
try:
|
||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError) as e:
|
||
# En ødelagt fil skal ikke hindre appen fra å starte; da er det
|
||
# bedre å miste planene og si det i loggen.
|
||
log.warning("kunne ikke lese %s (%s) – starter med tom liste", self.path, e)
|
||
return
|
||
known = {f for f in Scheduled.__dataclass_fields__}
|
||
for entry in raw.get("items", []):
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
try:
|
||
self.items.append(Scheduled(**{k: v for k, v in entry.items() if k in known}))
|
||
except TypeError as e:
|
||
log.warning("hopper over ugyldig oppføring (%s)", e)
|
||
log.info("leste %d planlagte sendinger fra %s", len(self.items), self.path)
|
||
|
||
def _save(self) -> None:
|
||
"""Skriv til side og flytt på plass, så fila aldri er halvskrevet."""
|
||
try:
|
||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = self.path.with_suffix(".tmp")
|
||
tmp.write_text(
|
||
json.dumps({"items": [asdict(i) for i in self.items]},
|
||
ensure_ascii=False, indent=1),
|
||
encoding="utf-8",
|
||
)
|
||
tmp.replace(self.path)
|
||
except OSError as e:
|
||
log.error("fikk ikke lagret %s: %s", self.path, e)
|
||
|
||
def _trim(self) -> None:
|
||
done = [i for i in self.items if i.status != "pending"]
|
||
if len(done) > KEEP_DONE:
|
||
drop = {id(i) for i in sorted(done, key=lambda i: i.at)[:len(done) - KEEP_DONE]}
|
||
self.items = [i for i in self.items if id(i) not in drop]
|
||
|
||
# -- endringer ---------------------------------------------------------
|
||
def add(self, *, at: float, text: str, voice: str, speed: float, chime: str,
|
||
user: str = "", subject: str = "", method: str = "none",
|
||
zones: list[str] | None = None) -> Scheduled:
|
||
"""Legger til en melding. Kaster ValueError på et ubrukelig tidspunkt."""
|
||
at = float(at)
|
||
now = time.time()
|
||
if at < now - self.grace:
|
||
raise ValueError("Tidspunktet er passert")
|
||
if at > now + self.max_ahead:
|
||
raise ValueError(f"Mer enn {int(self.max_ahead / 86400)} dager fram i tid")
|
||
item = Scheduled(
|
||
id=secrets.token_hex(4), at=at, text=text, voice=voice, speed=speed,
|
||
chime=chime, user=user, subject=subject, method=method,
|
||
zones=[str(z) for z in (zones or [])],
|
||
)
|
||
with self._lock:
|
||
self.items.append(item)
|
||
self._trim()
|
||
self._save()
|
||
return item
|
||
|
||
def cancel(self, sched_id: str) -> Scheduled | None:
|
||
with self._lock:
|
||
for i in self.items:
|
||
if i.id == sched_id and i.status == "pending":
|
||
i.status = "cancelled"
|
||
self._trim()
|
||
self._save()
|
||
return i
|
||
return None
|
||
|
||
def get(self, sched_id: str) -> Scheduled | None:
|
||
with self._lock:
|
||
return next((i for i in self.items if i.id == sched_id), None)
|
||
|
||
def mark(self, sched_id: str, status: str, note: str = "") -> None:
|
||
assert status in STATUSES, status
|
||
with self._lock:
|
||
for i in self.items:
|
||
if i.id == sched_id:
|
||
i.status = status
|
||
i.note = note
|
||
# Også her, ikke bare i add(): ellers vokser lista av
|
||
# ferdige ubegrenset så lenge ingen legger til noe nytt.
|
||
self._trim()
|
||
self._save()
|
||
return
|
||
|
||
# -- oppslag -----------------------------------------------------------
|
||
def due(self, now: float | None = None) -> tuple[list[Scheduled], list[Scheduled]]:
|
||
"""Returnerer (skal sendes nå, for sent – hopp over).
|
||
|
||
Kaller ikke _save(); announcer.py markerer dem etterpå, så en melding
|
||
ikke kan bli stående som «sendt» uten at den faktisk ble lagt i kø.
|
||
"""
|
||
now = time.time() if now is None else now
|
||
send: list[Scheduled] = []
|
||
missed: list[Scheduled] = []
|
||
with self._lock:
|
||
for i in self.items:
|
||
if i.status != "pending" or i.at > now:
|
||
continue
|
||
(send if now - i.at <= self.grace else missed).append(i)
|
||
return send, missed
|
||
|
||
def snapshot(self) -> dict:
|
||
with self._lock:
|
||
pending = sorted((i for i in self.items if i.status == "pending"),
|
||
key=lambda i: i.at)
|
||
done = sorted((i for i in self.items if i.status != "pending"),
|
||
key=lambda i: i.at, reverse=True)[:15]
|
||
return {
|
||
"pending": [i.public() for i in pending],
|
||
"done": [i.public() for i in done],
|
||
}
|