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
168
announcer.py
168
announcer.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue