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>
414 lines
15 KiB
Python
414 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""Mesh: én sentral som fordeler utrop til flere soner.
|
||
|
||
Hver sone er en vanlig announcer med egen kø og eget anlegg. Sentralen er en
|
||
vanlig announcer som i tillegg har [zones] i config. Det er hele forskjellen;
|
||
det finnes ingen «mode», og en sone kan brukes alene hvis sentralen er nede.
|
||
|
||
Bærende valg:
|
||
|
||
- Sentralen syntetiserer én gang og sender ferdig WAV. Sonen trenger da verken
|
||
nett, edge-tts eller Pipers stemmer for å spille det den får.
|
||
- Køene forblir per sone. Er hovedscenen opptatt med en lang melding, skal
|
||
ikke en resepsjonsmelding vente. Dispatcheren fordeler og går videre; hver
|
||
sone køer selv.
|
||
- Faller én sone ut, feiler bare den. De andre får meldingen.
|
||
|
||
Modulen kjenner verken Flask eller Player. Den får funksjoner inn:
|
||
synthesize, play_local og send. Det er det som gjør den testbar uten nett.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import queue
|
||
import re
|
||
import secrets
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from dataclasses import asdict, dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Callable
|
||
|
||
log = logging.getLogger("announcer.mesh")
|
||
|
||
ZONE_NAME = re.compile(r"[A-Za-z0-9_æøåÆØÅ-]{1,32}")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Soner
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Zone:
|
||
name: str
|
||
url: str = "" # tom = sentralens eget anlegg
|
||
|
||
@property
|
||
def local(self) -> bool:
|
||
return not self.url
|
||
|
||
|
||
def zones_from_config(raw: dict[str, str]) -> dict[str, Zone]:
|
||
"""[zones] i config: navn -> "local" eller http(s)://…. Feiler høyt på
|
||
noe annet, så en skrivefeil oppdages ved oppstart og ikke ved første utrop."""
|
||
out: dict[str, Zone] = {}
|
||
for name, target in raw.items():
|
||
name = str(name).strip()
|
||
if not ZONE_NAME.fullmatch(name):
|
||
raise ValueError(f"[zones]: ugyldig sonenavn {name!r}")
|
||
target = str(target).strip()
|
||
if target.lower() == "local":
|
||
out[name] = Zone(name)
|
||
elif target.startswith(("http://", "https://")):
|
||
out[name] = Zone(name, target.rstrip("/"))
|
||
else:
|
||
raise ValueError(f'[zones] {name}: {target!r} er verken "local" eller en http-URL')
|
||
return out
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Én fordeling = én melding til N soner
|
||
# --------------------------------------------------------------------------
|
||
|
||
# sending -> queued (sonen tok imot) | unreachable (nådde ikke fram)
|
||
# | rejected (sonen sa nei, f.eks. feil nøkkel) | failed (syntese/lokalt)
|
||
ZONE_STATUSES = ("sending", "queued", "unreachable", "rejected", "failed")
|
||
|
||
|
||
@dataclass
|
||
class ZoneResult:
|
||
status: str = "sending"
|
||
item_id: int | None = None # kø-id på sonen
|
||
error: str = ""
|
||
|
||
|
||
@dataclass
|
||
class Dispatch:
|
||
id: str
|
||
text: str
|
||
voice: str
|
||
speed: float
|
||
chime: str
|
||
zones: dict[str, ZoneResult]
|
||
user: str = ""
|
||
subject: str = ""
|
||
method: str = "none"
|
||
sender: str = "" # IP-en forespørselen kom fra
|
||
created: float = field(default_factory=time.time)
|
||
schedule_id: str = ""
|
||
error: str = "" # syntesefeil, da feiler alle soner likt
|
||
|
||
@property
|
||
def pending(self) -> bool:
|
||
return any(z.status == "sending" for z in self.zones.values())
|
||
|
||
@property
|
||
def accepted(self) -> list[str]:
|
||
return [n for n, z in self.zones.items() if z.status == "queued"]
|
||
|
||
def summary(self) -> str:
|
||
"""«hovedscene: kø #3, crew: nede» – til planen og loggen."""
|
||
parts = []
|
||
for name, z in self.zones.items():
|
||
if z.status == "queued":
|
||
parts.append(f"{name}: kø #{z.item_id}")
|
||
elif z.status == "unreachable":
|
||
parts.append(f"{name}: nede")
|
||
else:
|
||
parts.append(f"{name}: {z.status}")
|
||
return ", ".join(parts)
|
||
|
||
def public(self, *, show_ip: bool = False) -> dict[str, Any]:
|
||
return {
|
||
"id": self.id,
|
||
"text": self.text,
|
||
"voice": self.voice,
|
||
"user": self.user,
|
||
"sender": self.sender if show_ip else "",
|
||
"created": self.created,
|
||
"error": self.error,
|
||
"zones": {n: asdict(z) for n, z in self.zones.items()},
|
||
}
|
||
|
||
|
||
class Dispatcher:
|
||
"""Egen tråd og egen kø, atskilt fra Player. Gjør bare fordeling.
|
||
|
||
synthesize(text, voice, speed, chime) -> Path
|
||
play_local(dispatch, path) -> int kø-id lokalt
|
||
send(zone, path, dispatch) -> (status, item_id, error)
|
||
"""
|
||
|
||
def __init__(self, zones: dict[str, Zone], *, synthesize: Callable, play_local: Callable,
|
||
send: Callable, timeout: float = 5.0, on_finished: Callable | None = None) -> None:
|
||
self.zones = zones
|
||
self.synthesize = synthesize
|
||
self.play_local = play_local
|
||
self.send = send
|
||
self.timeout = float(timeout)
|
||
self.on_finished = on_finished
|
||
self.q: queue.Queue[Dispatch] = queue.Queue()
|
||
self.history: list[Dispatch] = []
|
||
self.lock = threading.Lock()
|
||
self._thread: threading.Thread | None = None
|
||
|
||
# -- oppslag -----------------------------------------------------------
|
||
def names(self) -> list[str]:
|
||
return list(self.zones)
|
||
|
||
def resolve(self, requested: Any) -> list[str]:
|
||
"""Tom/None = alle. Ukjent navn kaster ValueError før noe sendes."""
|
||
if not requested:
|
||
return self.names()
|
||
if isinstance(requested, str):
|
||
requested = [requested]
|
||
if not isinstance(requested, (list, tuple)):
|
||
raise ValueError("zones må være en liste med sonenavn")
|
||
out: list[str] = []
|
||
for name in requested:
|
||
name = str(name).strip()
|
||
if name not in self.zones:
|
||
raise ValueError(f"Ukjent sone: {name}")
|
||
if name not in out:
|
||
out.append(name)
|
||
return out
|
||
|
||
# -- innlevering -------------------------------------------------------
|
||
def submit(self, text: str, voice: str, speed: float, chime: str, zone_names: list[str],
|
||
*, user: str = "", subject: str = "", method: str = "none", sender: str = "",
|
||
schedule_id: str = "") -> Dispatch:
|
||
d = Dispatch(
|
||
id=secrets.token_hex(4), text=text, voice=voice, speed=speed, chime=chime,
|
||
zones={n: ZoneResult() for n in zone_names},
|
||
user=user, subject=subject, method=method, sender=sender, schedule_id=schedule_id,
|
||
)
|
||
with self.lock:
|
||
self.history.append(d)
|
||
del self.history[:-50]
|
||
self.q.put(d)
|
||
return d
|
||
|
||
def snapshot(self, *, show_ip: bool = False) -> list[dict]:
|
||
with self.lock:
|
||
return [d.public(show_ip=show_ip) for d in reversed(self.history)][:15]
|
||
|
||
# -- tråden ------------------------------------------------------------
|
||
def start(self) -> None:
|
||
if self._thread is None:
|
||
self._thread = threading.Thread(target=self._loop, daemon=True, name="dispatcher")
|
||
self._thread.start()
|
||
|
||
def _loop(self) -> None:
|
||
while True:
|
||
try:
|
||
self.run_once()
|
||
except Exception: # noqa: BLE001
|
||
log.exception("dispatcheren feilet")
|
||
|
||
def run_once(self, timeout: float | None = None) -> Dispatch | None:
|
||
"""Én fordeling, fra syntese til svar fra hver sone. Returnerer den."""
|
||
try:
|
||
d = self.q.get(timeout=timeout)
|
||
except queue.Empty:
|
||
return None
|
||
try:
|
||
path = self.synthesize(d.text, d.voice, d.speed, d.chime)
|
||
except Exception as e: # noqa: BLE001
|
||
log.exception("syntese feilet for fordeling #%s", d.id)
|
||
d.error = str(e)[:200]
|
||
for z in d.zones.values():
|
||
z.status, z.error = "failed", d.error
|
||
self._finish(d)
|
||
return d
|
||
|
||
threads: list[threading.Thread] = []
|
||
for name, result in d.zones.items():
|
||
zone = self.zones[name]
|
||
if zone.local:
|
||
try:
|
||
result.item_id = self.play_local(d, path)
|
||
result.status = "queued"
|
||
except Exception as e: # noqa: BLE001
|
||
result.status, result.error = "failed", str(e)[:200]
|
||
else:
|
||
# Hver sone i sin tråd, så en som henger ikke holder igjen de
|
||
# andre lenger enn timeouten.
|
||
t = threading.Thread(target=self._send_one, args=(zone, path, d, result),
|
||
daemon=True, name=f"send-{name}")
|
||
t.start()
|
||
threads.append(t)
|
||
for t in threads:
|
||
t.join(self.timeout + 1.0)
|
||
for name, result in d.zones.items():
|
||
if result.status == "sending":
|
||
result.status, result.error = "unreachable", "svarte ikke i tide"
|
||
self._finish(d)
|
||
return d
|
||
|
||
def _send_one(self, zone: Zone, path: Path, d: Dispatch, result: ZoneResult) -> None:
|
||
try:
|
||
status, item_id, error = self.send(zone, path, d)
|
||
except Exception as e: # noqa: BLE001
|
||
status, item_id, error = "unreachable", None, str(e)[:200]
|
||
result.status, result.item_id, result.error = status, item_id, error
|
||
|
||
def _finish(self, d: Dispatch) -> None:
|
||
log.info("fordeling #%s: %s", d.id, d.summary())
|
||
if self.on_finished:
|
||
try:
|
||
self.on_finished(d)
|
||
except Exception: # noqa: BLE001
|
||
log.exception("on_finished feilet")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Transport: sentral -> sone, stdlib urllib
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def send_to_zone(zone: Zone, path: Path, d: Dispatch, *, secret: str,
|
||
timeout: float = 5.0) -> tuple[str, int | None, str]:
|
||
"""POST /api/play på sonen: multipart med fila og metadata.
|
||
|
||
Returnerer (status, kø-id på sonen, feilmelding). Kaster ikke; alt som
|
||
kan gå galt blir en status, siden kallet skjer i en tråd uten noen å
|
||
kaste til.
|
||
"""
|
||
meta = json.dumps({
|
||
"origin_id": d.id, "text": d.text, "voice": d.voice,
|
||
"user": d.user, "subject": d.subject, "method": d.method,
|
||
}, ensure_ascii=False).encode()
|
||
boundary = "----announcer-" + secrets.token_hex(8)
|
||
nl = b"\r\n"
|
||
body = b"".join([
|
||
b"--" + boundary.encode() + nl,
|
||
b'Content-Disposition: form-data; name="meta"' + nl,
|
||
b"Content-Type: application/json; charset=utf-8" + nl + nl,
|
||
meta + nl,
|
||
b"--" + boundary.encode() + nl,
|
||
b'Content-Disposition: form-data; name="wav"; filename="' + path.name.encode() + b'"' + nl,
|
||
b"Content-Type: audio/wav" + nl + nl,
|
||
path.read_bytes() + nl,
|
||
b"--" + boundary.encode() + b"--" + nl,
|
||
])
|
||
req = urllib.request.Request(
|
||
f"{zone.url}/api/play",
|
||
data=body,
|
||
headers={
|
||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||
"X-Mesh-Key": secret,
|
||
"Accept": "application/json",
|
||
},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
|
||
data = json.loads(r.read().decode())
|
||
return "queued", int(data["id"]), ""
|
||
except urllib.error.HTTPError as e:
|
||
try:
|
||
msg = json.loads(e.read().decode()).get("error", "")
|
||
except Exception: # noqa: BLE001
|
||
msg = ""
|
||
return "rejected", None, f"{e.code} {msg or e.reason}".strip()
|
||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||
reason = getattr(e, "reason", e)
|
||
return "unreachable", None, str(reason)[:120]
|
||
except (ValueError, KeyError) as e:
|
||
return "rejected", None, f"uventet svar ({e})"
|
||
|
||
|
||
def fetch_zone_status(zone: Zone, *, secret: str, timeout: float = 2.0) -> dict[str, Any]:
|
||
"""GET /api/status på sonen. Returnerer det UI-et trenger, aldri kaster."""
|
||
req = urllib.request.Request(
|
||
f"{zone.url}/api/status",
|
||
headers={"X-Mesh-Key": secret, "Accept": "application/json"},
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
|
||
d = json.loads(r.read().decode())
|
||
return {
|
||
"reachable": True,
|
||
"current": d.get("current"),
|
||
"queued": len(d.get("queued") or []),
|
||
}
|
||
except urllib.error.HTTPError as e:
|
||
return {"reachable": False, "error": f"{e.code} {e.reason}"}
|
||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||
return {"reachable": False, "error": str(getattr(e, "reason", e))[:120]}
|
||
except ValueError as e:
|
||
return {"reachable": False, "error": f"uventet svar ({e})"}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Statuspoller: én i bakgrunnen, så N nettlesere ikke gir N x soner kall
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
class ZonePoller:
|
||
"""Henter status fra hver ekstern sone med jevne mellomrom og cacher den.
|
||
|
||
Den lokale sonen slås opp ferskt ved hvert snapshot via local_status, så
|
||
eget anlegg aldri er to sekunder på etterskudd.
|
||
"""
|
||
|
||
def __init__(self, zones: dict[str, Zone], *, fetch: Callable, interval: float = 2.0,
|
||
local_status: Callable[[], dict] | None = None) -> None:
|
||
self.zones = zones
|
||
self.fetch = fetch
|
||
self.interval = float(interval)
|
||
self.local_status = local_status
|
||
self.lock = threading.Lock()
|
||
self.cache: dict[str, dict] = {
|
||
z.name: {"reachable": False, "error": "ikke spurt ennå"}
|
||
for z in zones.values() if not z.local
|
||
}
|
||
self._thread: threading.Thread | None = None
|
||
|
||
def poll_once(self) -> None:
|
||
remote = [z for z in self.zones.values() if not z.local]
|
||
results: dict[str, dict] = {}
|
||
|
||
def one(z: Zone) -> None:
|
||
try:
|
||
results[z.name] = self.fetch(z)
|
||
except Exception as e: # noqa: BLE001
|
||
results[z.name] = {"reachable": False, "error": str(e)[:120]}
|
||
|
||
threads = [threading.Thread(target=one, args=(z,), daemon=True) for z in remote]
|
||
for t in threads:
|
||
t.start()
|
||
for t in threads:
|
||
t.join(5.0)
|
||
with self.lock:
|
||
for z in remote:
|
||
self.cache[z.name] = results.get(z.name, {"reachable": False, "error": "svarte ikke"})
|
||
self.cache[z.name]["checked"] = time.time()
|
||
|
||
def snapshot(self) -> dict[str, dict]:
|
||
out: dict[str, dict] = {}
|
||
for z in self.zones.values():
|
||
if z.local:
|
||
out[z.name] = {"reachable": True, **(self.local_status() if self.local_status else {})}
|
||
else:
|
||
with self.lock:
|
||
out[z.name] = dict(self.cache.get(z.name, {"reachable": False}))
|
||
return out
|
||
|
||
def start(self) -> None:
|
||
if self._thread is None:
|
||
self._thread = threading.Thread(target=self._loop, daemon=True, name="zone-poller")
|
||
self._thread.start()
|
||
|
||
def _loop(self) -> None:
|
||
while True:
|
||
try:
|
||
self.poll_once()
|
||
except Exception: # noqa: BLE001
|
||
log.exception("sonepolleren feilet")
|
||
time.sleep(self.interval)
|