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

@ -18,6 +18,8 @@ import logging
import math
import os
import queue
import re
import secrets
import shutil
import struct
import subprocess
@ -31,6 +33,7 @@ from flask import Flask, Response, abort, jsonify, request, send_file
import auth
import config
import mesh
import schedule as sched
from auth import require
@ -84,6 +87,9 @@ DEFAULT_CHIME = CFG.app["default_chime"]
log = logging.getLogger("announcer")
app = Flask(__name__, static_folder=None)
# En WAV fra sentralen: et par MB for en normal melding. Grensen er romslig
# bare for å stoppe det som åpenbart ikke er lyd.
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024
AUTH = auth.install(app, CFG)
@ -335,6 +341,7 @@ class Item:
status: str = "queued"
error: str = ""
schedule_id: str = "" # satt når meldingen kom fra planleggeren
wav: Path | None = None # ferdig lyd fra sentralen (mesh); ingen syntese
@property
def active(self) -> bool:
@ -397,7 +404,8 @@ class Player:
self._thread.start()
def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str,
who: auth.Principal | None = None, schedule_id: str = "") -> Item:
who: auth.Principal | None = None, schedule_id: str = "",
wav: Path | None = None) -> Item:
with self.lock:
self.seq += 1
item = Item(
@ -406,6 +414,7 @@ class Player:
subject=who.subject if who else "",
method=who.method if who else "none",
schedule_id=schedule_id,
wav=wav,
)
self.history.append(item)
del self.history[:-50]
@ -460,7 +469,8 @@ class Player:
with self.lock:
self.current = item
try:
path = synthesize(item.text, item.voice, item.speed, item.chime)
# Fra sentralen kommer lyden ferdig; da er det ingenting å lage.
path = item.wav or synthesize(item.text, item.voice, item.speed, item.chime)
with self.lock:
if item.status != "preparing":
return item # stoppet mens lyden ble laget
@ -523,6 +533,63 @@ def _scheduled_finished(item: Item) -> None:
player = Player(on_finished=_scheduled_finished)
# --------------------------------------------------------------------------
# Mesh: sentral + soner. Finnes bare når [zones] er satt i config.
# --------------------------------------------------------------------------
try:
ZONES = mesh.zones_from_config(CFG.zones)
except ValueError as e:
raise SystemExit(f"config: {e}") from e
MESH_SECRET = str(CFG.get("mesh", "secret", "")).strip()
if ZONES and not MESH_SECRET and any(not z.local for z in ZONES.values()):
raise SystemExit("config: [zones] har eksterne soner, men ANNOUNCER_MESH_SECRET er ikke satt")
def _play_local(d: mesh.Dispatch, path: Path) -> int:
"""Sentralens eget anlegg er en sone som alle andre, bare uten HTTP."""
item = player.enqueue(
d.text, d.voice, d.speed, d.chime, d.sender,
who=auth.Principal(d.subject, d.user, (), d.method, frozenset()),
wav=path,
)
return item.id
def _dispatch_finished(d: mesh.Dispatch) -> None:
audit("dispatched", id=d.id, subject=d.subject, name=d.user, method=d.method,
ip=d.sender, text=d.text, zones={n: z.status for n, z in d.zones.items()},
error=d.error)
if d.schedule_id:
if d.accepted:
schedule.mark(d.schedule_id, "sent", d.summary())
else:
schedule.mark(d.schedule_id, "failed", d.error or d.summary())
DISPATCHER: mesh.Dispatcher | None = None
POLLER: mesh.ZonePoller | None = None
if ZONES:
_timeout = float(CFG.get("mesh", "timeout_seconds", 5))
DISPATCHER = mesh.Dispatcher(
ZONES,
synthesize=lambda *a: synthesize(*a),
play_local=_play_local,
send=lambda zone, path, d: mesh.send_to_zone(zone, path, d, secret=MESH_SECRET, timeout=_timeout),
timeout=_timeout,
on_finished=_dispatch_finished,
)
POLLER = mesh.ZonePoller(
ZONES,
fetch=lambda zone: mesh.fetch_zone_status(zone, secret=MESH_SECRET),
interval=float(CFG.get("mesh", "poll_seconds", 2)),
local_status=lambda: {
"current": player.snapshot()["current"],
"queued": len(player.snapshot()["queued"]),
},
)
def scheduler_tick() -> None:
"""Ett steg: flytter forfalte meldinger inn i den vanlige køen.
@ -538,6 +605,25 @@ def scheduler_tick() -> None:
audit("missed", id=item.id, subject=item.subject, name=item.user,
method=item.method, text=item.text, at=item.at)
for item in send:
if DISPATCHER is not None:
try:
names = DISPATCHER.resolve(item.zones)
except ValueError as e:
schedule.mark(item.id, "failed", str(e))
audit("scheduled_fire", id=item.id, error=str(e), subject=item.subject,
name=item.user, method=item.method, text=item.text, at=item.at)
continue
d = DISPATCHER.submit(
item.text, item.voice, item.speed, item.chime, names,
user=item.user, subject=item.subject, method=item.method,
sender="planlagt", schedule_id=item.id,
)
schedule.mark(item.id, "queued", "fordeles til " + ", ".join(names))
log.info("planlagt #%s fordeles som #%s til %s", item.id, d.id, ", ".join(names))
audit("scheduled_fire", id=item.id, dispatch_id=d.id, zones=names,
subject=item.subject, name=item.user, method=item.method,
text=item.text, voice=item.voice, at=item.at)
continue
queued = player.enqueue(
item.text, item.voice, item.speed, item.chime,
sender="planlagt",
@ -601,6 +687,18 @@ def who(p: auth.Principal) -> dict:
"ip": request.remote_addr or ""}
def requested_zones(data: dict) -> list[str]:
"""Sonene en forespørsel ber om. Tom liste = ikke mesh, gå i lokal kø."""
if DISPATCHER is None:
if data.get("zones"):
abort(400, "Ingen soner er satt opp på denne maskinen")
return []
try:
return DISPATCHER.resolve(data.get("zones"))
except ValueError as e:
abort(400, str(e))
def parse_request() -> tuple[str, str, float, str]:
data = request.get_json(silent=True) or {}
text = " ".join(str(data.get("text", "")).split())
@ -659,6 +757,8 @@ def client_config() -> Response:
"max_days_ahead": int(CFG.get("schedule", "max_days_ahead", 30)),
"grace_seconds": int(CFG.get("schedule", "grace_seconds", 120)),
},
# Tom liste = ingen mesh; da viser ikke sida noen avkryssing.
"zones": DISPATCHER.names() if DISPATCHER else [],
"auth": {
**AUTH.describe(),
"user": None if me.anonymous else me.public(),
@ -687,7 +787,15 @@ def preview() -> Response:
def announce() -> Response:
me = require("announce")
text, voice, speed, chime = parse_request()
zones = requested_zones(request.get_json(silent=True) or {})
sender = request.remote_addr or "?"
if zones:
d = DISPATCHER.submit(text, voice, speed, chime, zones, user=me.name,
subject=me.subject, method=me.method, sender=sender)
log.info("fordeling #%s fra %s til %s: %s", d.id, me.name or sender, ", ".join(zones), text)
audit("announce", **who(me), dispatch_id=d.id, zones=zones, text=text,
voice=voice, speed=speed, chime=chime)
return jsonify({"dispatch": d.public(show_ip=me.can("clear")), "id": d.id})
item = player.enqueue(text, voice, speed, chime, sender, me)
log.info("kø #%d fra %s (%s): %s", item.id, item.user or sender, sender, text)
audit("announce", **who(me), id=item.id, text=text, voice=voice,
@ -695,6 +803,43 @@ def announce() -> Response:
return jsonify(item.public(show_ip=me.can("clear")))
@app.post("/api/play")
def play() -> Response:
"""Sonens side av mesh: ta imot en ferdig WAV fra sentralen og køe den."""
me = require("play")
f = request.files.get("wav")
if f is None:
abort(400, "Mangler wav")
try:
meta = json.loads(request.form.get("meta") or "{}")
except ValueError:
abort(400, "Ugyldig meta")
if not isinstance(meta, dict):
abort(400, "Ugyldig meta")
origin = re.sub(r"[^A-Za-z0-9_-]", "", str(meta.get("origin_id", "")))[:32] or secrets.token_hex(4)
CACHE_DIR.mkdir(parents=True, exist_ok=True)
path = CACHE_DIR / f"mesh-{origin}.wav"
f.save(path)
try:
with wave.open(str(path)) as w:
if w.getnframes() == 0:
raise ValueError("tom fil")
except (wave.Error, EOFError, ValueError) as e:
path.unlink(missing_ok=True)
abort(400, f"Ikke en gyldig WAV ({e})")
text = " ".join(str(meta.get("text", "")).split())[:MAX_CHARS] or "(uten tekst)"
origin_who = auth.Principal(
str(meta.get("subject") or "mesh"), str(meta.get("user") or "sentral"),
(), str(meta.get("method") or "mesh"), frozenset(),
)
item = player.enqueue(text, str(meta.get("voice") or "?"), 1.0, "none",
request.remote_addr or "?", who=origin_who, wav=path)
log.info("mesh: kø #%d fra sentralen (%s): %s", item.id, origin_who.name, text)
audit("play", **who(me), id=item.id, origin_id=origin, text=text,
user=origin_who.name, user_subject=origin_who.subject)
return jsonify({"id": item.id})
@app.post("/api/schedule")
def schedule_add() -> Response:
me = require("schedule")
@ -704,15 +849,16 @@ def schedule_add() -> Response:
at = float(data["at"])
except (KeyError, TypeError, ValueError):
abort(400, "Mangler eller ugyldig tidspunkt")
zones = requested_zones(data)
try:
item = schedule.add(at=at, text=text, voice=voice, speed=speed, chime=chime,
user=me.name, subject=me.subject, method=me.method)
user=me.name, subject=me.subject, method=me.method, zones=zones)
except ValueError as e:
abort(400, str(e))
log.info("planlagt #%s kl. %s av %s: %s", item.id,
time.strftime("%Y-%m-%d %H:%M", time.localtime(at)), me.name or "anonym", text)
audit("schedule", **who(me), id=item.id, at=at, text=text, voice=voice,
speed=speed, chime=chime)
speed=speed, chime=chime, zones=zones)
return jsonify(item.public())
@ -754,11 +900,15 @@ def status() -> Response:
# Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem
# som har sendt noe, og det er ikke noe alle utenfra skal få se.
me = require("view")
return jsonify({
out = {
**player.snapshot(show_ip=me.can("clear")),
"scheduled": schedule.snapshot(),
"now": time.time(), # klienten regner «om 20 min» mot serverens klokke
})
}
if DISPATCHER is not None:
out["zones"] = POLLER.snapshot()
out["dispatches"] = DISPATCHER.snapshot(show_ip=me.can("clear"))
return jsonify(out)
@app.errorhandler(400)
@ -832,6 +982,12 @@ def main() -> None:
venter = len(schedule.snapshot()["pending"])
player.start()
start_scheduler()
if DISPATCHER is not None:
DISPATCHER.start()
POLLER.start()
log.info("mesh: sentral for %s", ", ".join(ZONES))
elif MESH_SECRET:
log.info("mesh: sone, tar imot fra sentralen")
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
args.host, args.port, methods, SINK or "standard", venter)
# waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere