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:
Kent 2026-10-09 02:22:05 +02:00
parent eb27a162d5
commit c4a15799dd
17 changed files with 1486 additions and 23 deletions

View file

@ -26,7 +26,8 @@ from typing import Any, Callable
HERE = Path(__file__).resolve().parent
# Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse.
ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear")
# «play» er bare for mesh: en sentral som leverer en ferdig WAV til en sone.
ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear", "play")
DEFAULTS: dict[str, Any] = {
"app": {
@ -72,6 +73,15 @@ DEFAULTS: dict[str, Any] = {
# Hvor ofte vi ser etter forfalte sendinger.
"tick_seconds": 20,
},
# Mesh: én sentral som fordeler til flere soner. [zones] settes bare på
# sentralen (navn -> URL, eller "local" for eget anlegg); hemmeligheten
# settes på sentralen OG hver sone. Begge tomme = ingen mesh, alt som før.
"zones": {},
"mesh": {
"secret": "",
"timeout_seconds": 5,
"poll_seconds": 2,
},
"auth": {
# Tom liste = alt åpent. Se active_providers(): en leverandør er også
# aktiv når den er ferdig konfigurert, så det holder å sette
@ -117,6 +127,8 @@ DEFAULTS: dict[str, Any] = {
"anonymous": [],
"crew": ["view", "preview", "announce", "schedule"],
"admin": ["*"],
# Sentralen når den snakker med en sone. view er for statuspollingen.
"mesh": ["play", "view"],
},
}
@ -150,6 +162,7 @@ ENV: dict[str, tuple[tuple[str, str], Callable[[str], Any]]] = {
"ANNOUNCER_SECRET_KEY": (("server", "secret_key"), str),
"ANNOUNCER_PUBLIC_URL": (("server", "public_url"), str),
"ANNOUNCER_AUTH": (("auth", "providers"), _list),
"ANNOUNCER_MESH_SECRET": (("mesh", "secret"), str),
"ANNOUNCER_PIN": (("auth.pin", "pin"), str),
"ANNOUNCER_PIN_ROLE": (("auth.pin", "role"), str),
"ANNOUNCER_JWT_ISSUER": (("auth.jwt", "issuer"), str),
@ -232,7 +245,7 @@ class Config:
"""
listed = [str(p).strip().lower() for p in self.get("auth", "providers", [])]
out: list[str] = []
for name in ("pin", "jwt", "oidc"):
for name in ("mesh", "pin", "jwt", "oidc"):
if name in listed or self.configured(name):
out.append(name)
# Rekkefølgen i config vinner for de som er nevnt der.
@ -247,8 +260,16 @@ class Config:
return bool(s.get("secret") or s.get("jwks_url"))
if provider == "oidc":
return bool(s.get("client_id") and (s.get("issuer") or s.get("authorization_endpoint")))
if provider == "mesh":
return bool(str(self.get("mesh", "secret", "")).strip())
return False
@property
def zones(self) -> dict[str, str]:
"""Sonene på en sentral, i konfigurert rekkefølge. Tom = ingen mesh."""
raw = self.section("zones")
return {str(k): str(v).strip() for k, v in raw.items() if str(v).strip()}
def permissions(self, roles: tuple[str, ...]) -> frozenset[str]:
"""Rollenavn -> handlinger. "*" betyr alt."""
out: set[str] = set()