Mesh: én sentral fordeler til flere soner
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>
This commit is contained in:
parent
eb27a162d5
commit
c4a15799dd
17 changed files with 1486 additions and 23 deletions
414
mesh.py
Normal file
414
mesh.py
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
#!/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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue