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

@ -29,6 +29,10 @@ ANNOUNCER_TITLE=
# Full URL utenfra. Trengs bare for OIDC bak en omvendt proxy. # Full URL utenfra. Trengs bare for OIDC bak en omvendt proxy.
ANNOUNCER_PUBLIC_URL= ANNOUNCER_PUBLIC_URL=
# Mesh. Samme verdi på sentralen og alle soner. Tom = ingen mesh.
# Lag en: openssl rand -hex 24
ANNOUNCER_MESH_SECRET=
# --- hemmeligheter ------------------------------------------------------- # --- hemmeligheter -------------------------------------------------------
# Tom = av. Se config.example.toml og docs/crew-integration.md. # Tom = av. Se config.example.toml og docs/crew-integration.md.
ANNOUNCER_PIN= ANNOUNCER_PIN=

View file

@ -19,10 +19,12 @@ Filer:
| `auth.py` | `Principal`, leverandørene, OIDC-rutene | | `auth.py` | `Principal`, leverandørene, OIDC-rutene |
| `config.py` | `config.toml` + miljøvariabler, standardverdier | | `config.py` | `config.toml` + miljøvariabler, standardverdier |
| `schedule.py` | planlagte sendinger, lagret i `data/scheduled.json` | | `schedule.py` | planlagte sendinger, lagret i `data/scheduled.json` |
| `mesh.py` | sentral + soner: fordeling, transport, statuspolling |
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg | | `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
| `config.example.toml` | kommentert mal; kopier til `config.toml` | | `config.example.toml` | kommentert mal; kopier til `config.toml` |
| `docs/crew-integration.md` | kontrakten mot crew-panelet | | `docs/crew-integration.md` | kontrakten mot crew-panelet |
| `tests/` | `uv run pytest` – 110 tester: auth, planlegging og HTTP-laget | | `docs/mesh.md` | flere anlegg: oppsett, bruk, feiltilfeller, protokoll |
| `tests/` | `uv run pytest` – 148 tester: auth, planlegging, mesh og HTTP-laget |
| `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker | | `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker |
| `.env.example` | bruker-ID, portbinding og hemmeligheter | | `.env.example` | bruker-ID, portbinding og hemmeligheter |
| `compose.test.yaml` | testoppsett med dummy-IdP, se under | | `compose.test.yaml` | testoppsett med dummy-IdP, se under |
@ -203,6 +205,51 @@ veien for å prøve en endring kjapt; `uv run pytest` for testene. Men
det er ryddet bort. Skal noe prøves med innlogging, bruk det er ryddet bort. Skal noe prøves med innlogging, bruk
`compose.test.yaml`. `compose.test.yaml`.
## Mesh: én sentral, flere soner
Brukerdokumentasjonen står i `docs/mesh.md`. Her er det som gjelder koden.
`mesh.py` kjenner verken Flask eller `Player`. `Dispatcher` får
`synthesize`, `play_local` og `send` inn som funksjoner, og det er det som
gjør at hele fordelingen testes uten nett i `tests/test_mesh.py`. Den ekte
transporten (`send_to_zone` sin håndrullede multipart mot Flask sin parser)
prøves i `test_routes.py` mot en levende werkzeug-server – det er den
testen som fanger en feil i selve HTTP-en.
- **Sentral eller sone avgjøres av config, ikke kode.** `[zones]` satt →
`DISPATCHER` og `POLLER` finnes, `/api/announce` tar `zones`. Ellers er
begge `None` og alt går i lokal kø nøyaktig som før. `ANNOUNCER_MESH_SECRET`
satt → `MeshProvider` er aktiv og `/api/play` tar imot. Begge kan være
satt på samme maskin (sentral med eget anlegg).
- **Egen tråd og egen kø for fordeling**, atskilt fra `Player`. Hadde
fordelingen gått gjennom sentralens avspillingskø, ville en melding til
resepsjonen ventet på at hovedscenen ble ferdig. Dispatcheren
syntetiserer, leverer til hver sone i hver sin tråd (timeout per sone), og
er ferdig. Hver sone køer selv.
- **`Item.wav`** er hele endringen i avspillingen: `run_once()` bruker
`item.wav or synthesize(...)`. En sone syntetiserer aldri det den får fra
sentralen.
- **`play` følger aldri med «alt åpent».** `Authenticator.anonymous()`
trekker den fra. Uten det kunne hvem som helst POSTe en WAV til en åpen
sone og få den spilt. Og en legitimasjon som vises fram og er feil (PIN,
token, mesh-nøkkel) gir nå 401 også der gjester ellers har lov –
`require()` sjekker `error()` først. Det var ikke slik før; en feil PIN
i åpen modus slapp stille gjennom som gjest.
- **`MeshProvider` teller ikke som innlogging for folk.** `Authenticator`
skiller `providers` fra `human`; `enabled` og `/api/config`-blokka ser
bare på de menneskelige. Ellers ville en sone med bare mesh-nøkkel krevd
innlogging lokalt uten noen måte å logge inn på.
- **Planlagt sending i mesh**: `Scheduled.zones` lagres med planen.
`scheduler_tick()` fordeler via `DISPATCHER` når den finnes, og planen
ender som `sent` hvis minst én sone tok imot (`_dispatch_finished`), med
`summary()` som note – `hovedscene: kø #3, crew: nede`. Det lokale
køelementet bærer *ikke* `schedule_id` i mesh-modus; fordelingen eier
planen. Nevnes fordi det er motsatt av lokal modus.
- **Statuspolling er én tråd** (`ZonePoller`), cachet, så N nettlesere ikke
gir N × soner kall. Lokal sone slås opp ferskt per snapshot.
- **Rekkefølgen i `zones`** i en forespørsel bevares i `Dispatch.zones`,
og dermed i `summary()` og i UI-et. Testene antar det.
## Planlagte sendinger ## Planlagte sendinger
`schedule.py` er den første delen av appen som **må** ligge på disk. Kø og `schedule.py` er den første delen av appen som **må** ligge på disk. Kø og
@ -315,6 +362,14 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI
- **Én prosess.** `main()` kjører waitress med mange tråder, ikke gunicorn - **Én prosess.** `main()` kjører waitress med mange tråder, ikke gunicorn
med flere workere: `Player` og planleggeren er singletons i prosessen, og med flere workere: `Player` og planleggeren er singletons i prosessen, og
flere workere ville gitt flere køer som snakker i munnen på hverandre. flere workere ville gitt flere køer som snakker i munnen på hverandre.
- **Mesh-nøkkelen må være lik** på sentral og sone, ellers `✗ avvist` med
`401 Feil mesh-nøkkel` på fordelingen. Og sonens `BIND_IP` må gjøre den
nåbar fra sentralen – med NetBird er det NetBird-adressen som skal stå i
`[zones]`.
- **En sone uten lydsocket** (hodeløs Pi uten linger, eller feil
`PUID`) tar imot og køer, men hvert utrop ender `failed`. Det ser riktig
ut fra sentralen (`✓` = tatt imot) helt til man ser på sonens egen kø.
`install.sh` slår på `loginctl enable-linger` nettopp derfor.
- **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt - **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt
`[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt `[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt
konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din

View file

@ -18,6 +18,8 @@ import logging
import math import math
import os import os
import queue import queue
import re
import secrets
import shutil import shutil
import struct import struct
import subprocess import subprocess
@ -31,6 +33,7 @@ from flask import Flask, Response, abort, jsonify, request, send_file
import auth import auth
import config import config
import mesh
import schedule as sched import schedule as sched
from auth import require from auth import require
@ -84,6 +87,9 @@ DEFAULT_CHIME = CFG.app["default_chime"]
log = logging.getLogger("announcer") log = logging.getLogger("announcer")
app = Flask(__name__, static_folder=None) 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) AUTH = auth.install(app, CFG)
@ -335,6 +341,7 @@ class Item:
status: str = "queued" status: str = "queued"
error: str = "" error: str = ""
schedule_id: str = "" # satt når meldingen kom fra planleggeren schedule_id: str = "" # satt når meldingen kom fra planleggeren
wav: Path | None = None # ferdig lyd fra sentralen (mesh); ingen syntese
@property @property
def active(self) -> bool: def active(self) -> bool:
@ -397,7 +404,8 @@ class Player:
self._thread.start() self._thread.start()
def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str, 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: with self.lock:
self.seq += 1 self.seq += 1
item = Item( item = Item(
@ -406,6 +414,7 @@ class Player:
subject=who.subject if who else "", subject=who.subject if who else "",
method=who.method if who else "none", method=who.method if who else "none",
schedule_id=schedule_id, schedule_id=schedule_id,
wav=wav,
) )
self.history.append(item) self.history.append(item)
del self.history[:-50] del self.history[:-50]
@ -460,7 +469,8 @@ class Player:
with self.lock: with self.lock:
self.current = item self.current = item
try: 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: with self.lock:
if item.status != "preparing": if item.status != "preparing":
return item # stoppet mens lyden ble laget return item # stoppet mens lyden ble laget
@ -523,6 +533,63 @@ def _scheduled_finished(item: Item) -> None:
player = Player(on_finished=_scheduled_finished) 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: def scheduler_tick() -> None:
"""Ett steg: flytter forfalte meldinger inn i den vanlige køen. """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, audit("missed", id=item.id, subject=item.subject, name=item.user,
method=item.method, text=item.text, at=item.at) method=item.method, text=item.text, at=item.at)
for item in send: 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( queued = player.enqueue(
item.text, item.voice, item.speed, item.chime, item.text, item.voice, item.speed, item.chime,
sender="planlagt", sender="planlagt",
@ -601,6 +687,18 @@ def who(p: auth.Principal) -> dict:
"ip": request.remote_addr or ""} "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]: def parse_request() -> tuple[str, str, float, str]:
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
text = " ".join(str(data.get("text", "")).split()) 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)), "max_days_ahead": int(CFG.get("schedule", "max_days_ahead", 30)),
"grace_seconds": int(CFG.get("schedule", "grace_seconds", 120)), "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": {
**AUTH.describe(), **AUTH.describe(),
"user": None if me.anonymous else me.public(), "user": None if me.anonymous else me.public(),
@ -687,7 +787,15 @@ def preview() -> Response:
def announce() -> Response: def announce() -> Response:
me = require("announce") me = require("announce")
text, voice, speed, chime = parse_request() text, voice, speed, chime = parse_request()
zones = requested_zones(request.get_json(silent=True) or {})
sender = request.remote_addr 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) 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) 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, 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"))) 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") @app.post("/api/schedule")
def schedule_add() -> Response: def schedule_add() -> Response:
me = require("schedule") me = require("schedule")
@ -704,15 +849,16 @@ def schedule_add() -> Response:
at = float(data["at"]) at = float(data["at"])
except (KeyError, TypeError, ValueError): except (KeyError, TypeError, ValueError):
abort(400, "Mangler eller ugyldig tidspunkt") abort(400, "Mangler eller ugyldig tidspunkt")
zones = requested_zones(data)
try: try:
item = schedule.add(at=at, text=text, voice=voice, speed=speed, chime=chime, 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: except ValueError as e:
abort(400, str(e)) abort(400, str(e))
log.info("planlagt #%s kl. %s av %s: %s", item.id, 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) 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, 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()) return jsonify(item.public())
@ -754,11 +900,15 @@ def status() -> Response:
# Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem # 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. # som har sendt noe, og det er ikke noe alle utenfra skal få se.
me = require("view") me = require("view")
return jsonify({ out = {
**player.snapshot(show_ip=me.can("clear")), **player.snapshot(show_ip=me.can("clear")),
"scheduled": schedule.snapshot(), "scheduled": schedule.snapshot(),
"now": time.time(), # klienten regner «om 20 min» mot serverens klokke "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) @app.errorhandler(400)
@ -832,6 +982,12 @@ def main() -> None:
venter = len(schedule.snapshot()["pending"]) venter = len(schedule.snapshot()["pending"])
player.start() player.start()
start_scheduler() 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)", log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
args.host, args.port, methods, SINK or "standard", venter) args.host, args.port, methods, SINK or "standard", venter)
# waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere # waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere

48
auth.py
View file

@ -214,6 +214,29 @@ class PinProvider(Provider):
return {"method": "pin", "label": self.label, "hint": self.hint} return {"method": "pin", "label": self.label, "hint": self.hint}
class MeshProvider(Provider):
"""Sentralen som leverer en WAV til denne sonen.
Egen header, ikke Authorization: Bearer – JwtProvider ville avvist en
nøkkel som ikke er et JWT før neste leverandør fikk prøve.
"""
name = "mesh"
def __init__(self, cfg: Config) -> None:
self.secret = str(cfg.get("mesh", "secret", "")).strip()
self.cfg = cfg
def authenticate(self, req) -> Principal | None: # noqa: ANN001
given = req.headers.get("X-Mesh-Key", "").strip()
if not given:
return None
if not hmac.compare_digest(given, self.secret):
raise AuthError("Feil mesh-nøkkel")
roles = ("mesh",)
return Principal("mesh", "sentral", roles, "mesh", self.cfg.permissions(roles))
class JwtProvider(Provider): class JwtProvider(Provider):
name = "jwt" name = "jwt"
@ -372,7 +395,7 @@ class OidcProvider(Provider):
return {"method": "oidc", "label": self.label, "login_url": "/auth/login"} return {"method": "oidc", "label": self.label, "login_url": "/auth/login"}
PROVIDERS = {"pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider} PROVIDERS = {"mesh": MeshProvider, "pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider}
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -412,7 +435,10 @@ class Authenticator:
self.providers: list[Provider] = [ self.providers: list[Provider] = [
PROVIDERS[name](cfg) for name in cfg.active_providers() PROVIDERS[name](cfg) for name in cfg.active_providers()
] ]
self.enabled = bool(self.providers) # mesh er maskin-til-maskin og teller ikke som innlogging for folk:
# en sone som bare har mesh-nøkkel skal fortsatt ha åpen side lokalt.
self.human: list[Provider] = [p for p in self.providers if p.name != "mesh"]
self.enabled = bool(self.human)
def provider(self, name: str) -> Provider | None: def provider(self, name: str) -> Provider | None:
return next((p for p in self.providers if p.name == name), None) return next((p for p in self.providers if p.name == name), None)
@ -426,8 +452,11 @@ class Authenticator:
veggen uten innlogging.""" veggen uten innlogging."""
if not self.enabled: if not self.enabled:
# Tomt navn: da står køen og loggen som før, uten en «bruker» som # Tomt navn: da står køen og loggen som før, uten en «bruker» som
# i praksis bare betyr «hvem som helst». # i praksis bare betyr «hvem som helst». Og aldri «play»: den er
return Principal("anonymous", "", ("anonymous",), "none", frozenset(ACTIONS)) # for en sentral med mesh-nøkkel, ikke for hvem som helst som
# finner en åpen sone og vil spille lyd på den.
return Principal("anonymous", "", ("anonymous",), "none",
frozenset(ACTIONS) - {"play"})
roles = ("anonymous",) roles = ("anonymous",)
return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles)) return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles))
@ -479,7 +508,7 @@ class Authenticator:
def describe(self) -> dict[str, Any]: def describe(self) -> dict[str, Any]:
return { return {
"enabled": self.enabled, "enabled": self.enabled,
"methods": [p.describe() for p in self.providers], "methods": [p.describe() for p in self.human],
"logout_url": "/auth/logout" if self.enabled else None, "logout_url": "/auth/logout" if self.enabled else None,
} }
@ -507,16 +536,21 @@ def require(action: str) -> Principal:
hvem det er men rollen mangler handlingen. hvem det er men rollen mangler handlingen.
""" """
p = current() p = current()
if error():
# Noen viste fram en PIN, et token eller en mesh-nøkkel som ikke
# holdt. Det er en feil, ikke en anonym bruker – også på en åpen
# sone, der anonym ellers hadde fått lov.
abort(401, error())
if p.can(action): if p.can(action):
return p return p
if p.anonymous: if p.anonymous:
abort(401, error() or _login_hint()) abort(401, _login_hint())
abort(403, f"Rollen din ({', '.join(p.roles) or 'ingen'}) har ikke lov til dette.") abort(403, f"Rollen din ({', '.join(p.roles) or 'ingen'}) har ikke lov til dette.")
def _login_hint() -> str: def _login_hint() -> str:
assert _auth is not None assert _auth is not None
names = {p.name for p in _auth.providers} names = {p.name for p in _auth.human}
if names == {"pin"}: if names == {"pin"}:
return "Feil eller manglende PIN." return "Feil eller manglende PIN."
if "oidc" in names: if "oidc" in names:

View file

@ -2,8 +2,13 @@
# #
# docker compose -f compose.test.yaml up --build # docker compose -f compose.test.yaml up --build
# #
# http://localhost:8091 announcer med PIN, JWT og OIDC slått på # http://localhost:8091 sentralen: PIN, JWT og OIDC slått på, soner «her», «a», «b»
# http://localhost:9000 dummy-IdP som står for crew-panelet # http://localhost:9000 dummy-IdP som står for crew-panelet
# http://localhost:8093 sone-a (uten lyd – bare kø, så fordeling kan ses)
# http://localhost:8094 sone-b (samme)
#
# Stopp sone-b og send til alle, så ser du «nede» på den og «kø» på resten:
# docker compose -f compose.test.yaml stop sone-b
# #
# Egen fil og eget prosjektnavn, så den ikke kan forveksles med eller # Egen fil og eget prosjektnavn, så den ikke kan forveksles med eller
# forstyrre den som kjører i drift. Porten er 8091 av samme grunn. # forstyrre den som kjører i drift. Porten er 8091 av samme grunn.
@ -27,13 +32,39 @@ services:
ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte
ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker
ANNOUNCER_PUBLIC_URL: http://localhost:8091 ANNOUNCER_PUBLIC_URL: http://localhost:8091
ANNOUNCER_MESH_SECRET: mesh-test-nokkel-kun-for-docker
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000} XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
TZ: ${TZ:-Europe/Oslo} TZ: ${TZ:-Europe/Oslo}
volumes: volumes:
- .:/app - .:/app
- ./data:/data - ./data:/data
- ${XDG_RUNTIME_DIR:-/run/user/1000}:${XDG_RUNTIME_DIR:-/run/user/1000} - ${XDG_RUNTIME_DIR:-/run/user/1000}:${XDG_RUNTIME_DIR:-/run/user/1000}
depends_on: [idp] depends_on: [idp, sone-a, sone-b]
# Soner: samme image, bare mesh-nøkkelen satt. Ingen lyd montert inn, så
# avspilling feiler der – med vilje, det er fordelingen som skal ses.
# tmpfs på /data, så ingen rot-eide mapper dukker opp på verten.
sone-a: &sone
build: .
ports: ["127.0.0.1:8093:8090"]
user: "${PUID:-1000}:${PGID:-1000}"
environment: &sone-env
ANNOUNCER_CONFIG: /finnes-ikke.toml
ANNOUNCER_MESH_SECRET: mesh-test-nokkel-kun-for-docker
ANNOUNCER_VOICES: /app/voices
ANNOUNCER_TITLE: "Sone A"
TZ: ${TZ:-Europe/Oslo}
volumes:
- .:/app
tmpfs:
- /data:mode=1777
sone-b:
<<: *sone
ports: ["127.0.0.1:8094:8090"]
environment:
<<: *sone-env
ANNOUNCER_TITLE: "Sone B"
idp: idp:
build: . build: .

View file

@ -31,6 +31,7 @@ services:
ANNOUNCER_JWT_SECRET: ${ANNOUNCER_JWT_SECRET:-} ANNOUNCER_JWT_SECRET: ${ANNOUNCER_JWT_SECRET:-}
ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-} ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-}
ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-} ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-}
ANNOUNCER_MESH_SECRET: ${ANNOUNCER_MESH_SECRET:-}
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000} XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
# Planlagte sendinger lagres som absolutt tid, så TZ påvirker dem ikke. # Planlagte sendinger lagres som absolutt tid, så TZ påvirker dem ikke.
# Men uten den står loggen i UTC mens du står i CEST, og da blir det # Men uten den står loggen i UTC mens du står i CEST, og da blir det

View file

@ -25,6 +25,26 @@ quick_phrases = [
"Husk å rydde rundt plassen din. Takk!", "Husk å rydde rundt plassen din. Takk!",
] ]
# --------------------------------------------------------------------------
# Mesh: én sentral som fordeler til flere soner
# --------------------------------------------------------------------------
# Settes BARE på sentralen. Navn -> "local" (eget anlegg) eller URL til en
# sone. Sonene er vanlige announcere med ANNOUNCER_MESH_SECRET satt i .env –
# samme hemmelighet som sentralen. En sone kan brukes alene også.
# Sentralen syntetiserer én gang og sender ferdig WAV; sonen trenger
# verken nett eller stemmer for det den får derfra.
#
# [zones]
# her = "local"
# hovedscene = "http://10.20.70.11:8090"
# resepsjon = "http://10.20.70.12:8090"
# crew = "http://10.20.70.13:8090"
[mesh]
# secret settes som ANNOUNCER_MESH_SECRET i .env, ikke her
timeout_seconds = 5
poll_seconds = 2
[server] [server]
host = "0.0.0.0" host = "0.0.0.0"
port = 8090 port = 8090
@ -123,6 +143,7 @@ label = "Logg inn med crew-kontoen"
# preview forhåndslytte (synteser lyd) # preview forhåndslytte (synteser lyd)
# announce sende ut på anlegget # announce sende ut på anlegget
# schedule planlegge en sending fram i tid, og avbryte sine egne # schedule planlegge en sending fram i tid, og avbryte sine egne
# play ta imot ferdig lyd fra sentralen (mesh). Bare rollen «mesh».
# stop stoppe det som spilles # stop stoppe det som spilles
# clear tømme køen # clear tømme køen
# "*" betyr alt. # "*" betyr alt.
@ -132,3 +153,5 @@ label = "Logg inn med crew-kontoen"
anonymous = [] anonymous = []
crew = ["view", "preview", "announce", "schedule"] crew = ["view", "preview", "announce", "schedule"]
admin = ["*"] admin = ["*"]
# Sentralen, når den snakker med denne maskina som sone. Ikke endre.
mesh = ["play", "view"]

View file

@ -26,7 +26,8 @@ from typing import Any, Callable
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
# Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse. # 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] = { DEFAULTS: dict[str, Any] = {
"app": { "app": {
@ -72,6 +73,15 @@ DEFAULTS: dict[str, Any] = {
# Hvor ofte vi ser etter forfalte sendinger. # Hvor ofte vi ser etter forfalte sendinger.
"tick_seconds": 20, "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": { "auth": {
# Tom liste = alt åpent. Se active_providers(): en leverandør er også # Tom liste = alt åpent. Se active_providers(): en leverandør er også
# aktiv når den er ferdig konfigurert, så det holder å sette # aktiv når den er ferdig konfigurert, så det holder å sette
@ -117,6 +127,8 @@ DEFAULTS: dict[str, Any] = {
"anonymous": [], "anonymous": [],
"crew": ["view", "preview", "announce", "schedule"], "crew": ["view", "preview", "announce", "schedule"],
"admin": ["*"], "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_SECRET_KEY": (("server", "secret_key"), str),
"ANNOUNCER_PUBLIC_URL": (("server", "public_url"), str), "ANNOUNCER_PUBLIC_URL": (("server", "public_url"), str),
"ANNOUNCER_AUTH": (("auth", "providers"), _list), "ANNOUNCER_AUTH": (("auth", "providers"), _list),
"ANNOUNCER_MESH_SECRET": (("mesh", "secret"), str),
"ANNOUNCER_PIN": (("auth.pin", "pin"), str), "ANNOUNCER_PIN": (("auth.pin", "pin"), str),
"ANNOUNCER_PIN_ROLE": (("auth.pin", "role"), str), "ANNOUNCER_PIN_ROLE": (("auth.pin", "role"), str),
"ANNOUNCER_JWT_ISSUER": (("auth.jwt", "issuer"), 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", [])] listed = [str(p).strip().lower() for p in self.get("auth", "providers", [])]
out: list[str] = [] out: list[str] = []
for name in ("pin", "jwt", "oidc"): for name in ("mesh", "pin", "jwt", "oidc"):
if name in listed or self.configured(name): if name in listed or self.configured(name):
out.append(name) out.append(name)
# Rekkefølgen i config vinner for de som er nevnt der. # 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")) return bool(s.get("secret") or s.get("jwks_url"))
if provider == "oidc": if provider == "oidc":
return bool(s.get("client_id") and (s.get("issuer") or s.get("authorization_endpoint"))) 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 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]: def permissions(self, roles: tuple[str, ...]) -> frozenset[str]:
"""Rollenavn -> handlinger. "*" betyr alt.""" """Rollenavn -> handlinger. "*" betyr alt."""
out: set[str] = set() out: set[str] = set()

View file

@ -10,6 +10,13 @@ subtitle = "Docker-oppsett – PIN, JWT og OIDC er alle slått på"
accent = "#f5a623" accent = "#f5a623"
quick_phrases = ["Dette er en test", "Pizzaen er kommet. Hent i kantina!"] quick_phrases = ["Dette er en test", "Pizzaen er kommet. Hent i kantina!"]
# Mesh: denne er sentral for sitt eget anlegg og to soner i compose-nettet.
# Nøkkelen settes som ANNOUNCER_MESH_SECRET i compose.test.yaml.
[zones]
her = "local"
a = "http://sone-a:8090"
b = "http://sone-b:8090"
[auth] [auth]
providers = ["oidc", "jwt", "pin"] providers = ["oidc", "jwt", "pin"]

119
docs/mesh.md Normal file
View file

@ -0,0 +1,119 @@
# Mesh: én sentral, flere soner
Flere anlegg – hovedscene, resepsjon, crewområde – og ett sted å sende fra,
med avkryssing for hvilke soner meldingen skal til.
## Hvordan det virker
**Sentralen** er en vanlig announcer med `[zones]` i `data/config.toml`.
**En sone** er en vanlig announcer med `ANNOUNCER_MESH_SECRET` i `.env`.
Samme image, ingen «mode». En sone kan derfor brukes alene også: faller
sentralen ut, går crew bort til sonens egen side og roper derfra.
Sentralen syntetiserer meldingen **én gang** og sender ferdig WAV til hver
valgt sone (`POST /api/play`). Sonen trenger verken nett, edge-tts eller
Pipers stemmer for det den får derfra – en Raspberry Pi med en høyttaler
holder.
Køene er **per sone**. Er hovedscenen opptatt med en lang melding, går en
resepsjonsmelding ut med en gang. Sentralen fordeler og går videre; hver
sone køer selv. Faller én sone ut, feiler bare den – de andre får meldingen,
og UI-et viser hvilken som ikke svarte.
Soner som får samme melding starter innen ~1 s av hverandre. Fint for
adskilte rom. Lekker rommene inn i hverandre hører man det; synkron start
mot NTP er en påbygging som ikke finnes ennå.
## Sette opp
### Sentralen
```toml
# data/config.toml
[zones]
her = "local" # eget anlegg. Utelat hvis sentralen ikke har høyttalere
hovedscene = "http://10.20.70.11:8090"
resepsjon = "http://10.20.70.12:8090"
crew = "http://10.20.70.13:8090"
```
```bash
# .env
ANNOUNCER_MESH_SECRET=<openssl rand -hex 24>
```
`docker compose restart`. Loggen sier `mesh: sentral for her, hovedscene, …`.
### Hver sone
```bash
git clone https://git.1x1.no/kent/announcer.git && cd announcer
ANNOUNCER_MESH_SECRET=<samme nøkkel> ./install.sh
```
Loggen sier `mesh: sone, tar imot fra sentralen`. Ingen `[zones]` på sonen.
Sonen må nås fra sentralen på adressen i `[zones]`. Med NetBird setter
`install.sh` `BIND_IP` til sonens NetBird-adresse – bruk den i `[zones]`.
Uten NetBird: sett `BIND_IP=0.0.0.0` (eller LAN-adressen) i sonens `.env`.
### Raspberry Pi uten skjerm
To ting som ellers gir en sone som aldri sier noe:
1. **Lyd uten innlogging.** Lydsocketen ligger i `/run/user/<uid>` og finnes
bare når brukerens systemd-instans kjører – normalt fra grafisk
innlogging. En Pi i et skap har ingen som logger inn. `install.sh` kjører
`loginctl enable-linger`, som starter brukerens tjenester (PipeWire
inkludert) ved boot uansett. Sjekk: `ls /run/user/$(id -u)/pipewire-0`
etter en omstart uten å logge inn.
2. **Hvilken utgang.** En Pi med både HDMI og jack sender lyden til HDMI som
standard – altså til skjermen som ikke er der. `sudo raspi-config` →
System Options → Audio → jack (eller USB-lydkortet). Eller sett
`ANNOUNCER_SINK` i `.env` til navnet fra `pactl list short sinks`.
## I bruk
Over «Send ut» står sonene som avkryssing, alle valgt som standard; valget
huskes i nettleseren. Forhåndslytt gjelder lyden, ikke sonene – du kan bytte
sone uten å lytte på nytt.
«Anlegget»-kortet viser én linje per sone: grønn prikk og hva som spilles,
eller rød prikk og «nede». «Siste meldinger» viser hver fordeling med et
merke per sone: `hovedscene ✓`, `crew ✗ nede`.
Planlagte sendinger husker sonene sine og overlever omstart av sentralen.
## Hva som kan gå galt
| | Hva du ser | Hva det betyr |
|---|---|---|
| Sone nede ved sending | `✗ nede` på den sonen, `✓` på resten | nådde ikke fram innen 5 s. Resten gikk |
| `✗ avvist` | `401 Feil mesh-nøkkel` i merket | `ANNOUNCER_MESH_SECRET` er ulik på sentral og sone |
| Rød prikk på en sone | «nede: …» i sonelista | statuspollingen når den ikke. Sjekk adresse i `[zones]` og `BIND_IP` på sonen |
| Alle soner `✗ feilet` | feilmelding på fordelingen | syntesen feilet på sentralen (nettet nede og nevral stemme valgt). Velg en lokal stemme |
| Sonen spiller ikke, men viser `feilet` | `fant verken pw-play eller paplay`-aktig feil i sonens kø | ingen lydsocket: se «Raspberry Pi uten skjerm» |
| Sone restartet med melding i kø | meldingen borte | køen ligger i minnet, som for lokale meldinger. Akseptert |
Alt havner i sentralens `data/logs/announcements.jsonl` som `announce`
(hvem, til hvilke soner) og `dispatched` (hva hver sone svarte), og i hver
sones logg som `play`.
## Protokoll, for den som vil skrive sin egen sone
`POST /api/play`, header `X-Mesh-Key: <hemmelighet>`, multipart:
| felt | |
|---|---|
| `wav` | fila. 16-bit mono WAV, jingle og tale ferdig satt sammen |
| `meta` | JSON: `origin_id`, `text`, `voice`, `user`, `subject`, `method` |
Svar `200 {"id": <kø-id>}`. Statusen ses i `GET /api/status` med samme
header. Alt annet er 400 med `{"error": "…"}`; feil nøkkel er 401.
Egen header, ikke `Authorization: Bearer`: den leses av JWT-leverandøren,
som avviser alt som ikke er et JWT før neste leverandør får prøve.
`play` følger aldri med «alt åpent». En sone uten `config.toml` lar hvem
som helst sende lokalt, men ikke spille en fil de har med seg – det krever
nøkkelen.

View file

@ -77,6 +77,22 @@
ul.list li button.x:hover { color: var(--danger); } ul.list li button.x:hover { color: var(--danger); }
ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); } ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); }
.now .t { color: var(--muted); font-size: 13px; } .now .t { color: var(--muted); font-size: 13px; }
.zones { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; align-items: center; }
.zones .zl { color: var(--muted); font-size: 13px; text-transform: uppercase; letter-spacing: .06em; margin-right: 4px; }
.zones label { display: inline-flex; align-items: center; gap: 6px; margin: 0; text-transform: none; letter-spacing: 0;
font-size: 14px; color: var(--fg); background: var(--panel2); border: 1px solid var(--line); border-radius: 999px;
padding: 6px 12px; cursor: pointer; }
.zones label:has(input:checked) { border-color: var(--accent); }
.zones input { accent-color: var(--accent); }
ul.list li.zone { align-items: center; }
ul.list li.zone .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); flex: 0 0 8px; }
ul.list li.zone.down .dot { background: var(--danger); }
ul.list li.zone.busy .dot { animation: pulse 1.2s infinite; }
ul.list li.zone .zn { font-weight: 600; min-width: 90px; }
.badge { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line);
color: var(--muted); margin-left: 4px; white-space: nowrap; }
.badge.queued { border-color: var(--ok); color: var(--ok); }
.badge.unreachable, .badge.rejected, .badge.failed { border-color: var(--danger); color: var(--danger); }
</style> </style>
</head> </head>
<body> <body>
@ -121,6 +137,8 @@
</div> </div>
</div> </div>
<div class="zones hidden" id="zones"></div>
<div class="actions"> <div class="actions">
<button class="btn" id="btnPreview">▶ Forhåndslytt</button> <button class="btn" id="btnPreview">▶ Forhåndslytt</button>
<button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button> <button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button>
@ -148,6 +166,7 @@
</div> </div>
</div> </div>
<div class="now idle" id="now"></div> <div class="now idle" id="now"></div>
<ul class="list hidden" id="zonelist"></ul>
<ul class="list" id="queue"></ul> <ul class="list" id="queue"></ul>
</div> </div>
@ -163,10 +182,37 @@ const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#c
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce'); const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
let cfg = {}, can = [], previewedKey = null, pollTimer = null, me = null, skew = 0; let cfg = {}, can = [], previewedKey = null, pollTimer = null, me = null, skew = 0;
// Nøkkelen for «har du hørt denne?» er lyden – tekst, stemme, hastighet,
// jingle. Sonene er ikke med: å bytte sone endrer ikke det du hørte.
function key() { return JSON.stringify(payload()); } function key() { return JSON.stringify(payload()); }
function payload() { function payload() {
return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value }; return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value };
} }
function hasZones() { return !!(cfg.zones && cfg.zones.length); }
function selectedZones() {
return [...document.querySelectorAll('#zones input:checked')].map(i => i.value);
}
function sendPayload() {
const p = payload();
if (hasZones()) p.zones = selectedZones();
return p;
}
function renderZones(names) {
const box = $('#zones');
box.classList.toggle('hidden', !names.length);
if (!names.length) return;
let saved = null;
try { saved = JSON.parse(localStorage.getItem('zones') || 'null'); } catch {}
const head = document.createElement('span'); head.className = 'zl'; head.textContent = 'Til';
box.replaceChildren(head, ...names.map(name => {
const l = document.createElement('label');
const i = document.createElement('input'); i.type = 'checkbox'; i.value = name;
i.checked = !Array.isArray(saved) || saved.includes(name); // alle valgt som standard
i.onchange = () => localStorage.setItem('zones', JSON.stringify(selectedZones()));
l.append(i, document.createTextNode(name));
return l;
}));
}
function headers(json) { function headers(json) {
const h = json ? { 'Content-Type': 'application/json' } : {}; const h = json ? { 'Content-Type': 'application/json' } : {};
// PIN-en er den eneste legitimasjonen som ligger i nettleseren; OIDC og // PIN-en er den eneste legitimasjonen som ligger i nettleseren; OIDC og
@ -262,6 +308,7 @@ function applyConfig(c) {
if (a.accent) document.documentElement.style.setProperty('--accent', a.accent); if (a.accent) document.documentElement.style.setProperty('--accent', a.accent);
text.placeholder = a.message_placeholder || ''; text.placeholder = a.message_placeholder || '';
$('#max').textContent = c.max_chars; text.maxLength = c.max_chars; $('#max').textContent = c.max_chars; text.maxLength = c.max_chars;
renderZones(c.zones || []);
$('#chips').replaceChildren(...(a.quick_phrases || []).map(q => { $('#chips').replaceChildren(...(a.quick_phrases || []).map(q => {
const b = document.createElement('button'); const b = document.createElement('button');
@ -360,9 +407,10 @@ $('#btnSchedule').addEventListener('click', async () => {
const at = atEpoch(); const at = atEpoch();
if (!at) return say('Velg et tidspunkt først.', 'err'); if (!at) return say('Velg et tidspunkt først.', 'err');
if (at < serverNow()) return say('Tidspunktet er passert.', 'err'); if (at < serverNow()) return say('Tidspunktet er passert.', 'err');
if (hasZones() && !selectedZones().length) return say('Velg minst én sone.', 'err');
$('#btnSchedule').disabled = true; $('#btnSchedule').disabled = true;
try { try {
const item = await (await api('/api/schedule', { ...payload(), at })).json(); const item = await (await api('/api/schedule', { ...sendPayload(), at })).json();
say(`Planlagt ${new Date(item.at * 1000).toLocaleString('no', { say(`Planlagt ${new Date(item.at * 1000).toLocaleString('no', {
weekday: 'short', hour: '2-digit', minute: '2-digit' })}.`, 'ok'); weekday: 'short', hour: '2-digit', minute: '2-digit' })}.`, 'ok');
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null; text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
@ -409,10 +457,12 @@ btnPreview.addEventListener('click', async () => {
}); });
btnAnnounce.addEventListener('click', async () => { btnAnnounce.addEventListener('click', async () => {
if (hasZones() && !selectedZones().length) return say('Velg minst én sone.', 'err');
btnAnnounce.disabled = true; btnAnnounce.disabled = true;
try { try {
const item = await (await api('/api/announce', payload())).json(); const item = await (await api('/api/announce', sendPayload())).json();
say(`Lagt i kø som #${item.id}.`, 'ok'); if (item.dispatch) say(`Sendes til ${Object.keys(item.dispatch.zones).join(', ')}.`, 'ok');
else say(`Lagt i kø som #${item.id}.`, 'ok');
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null; text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
audio.pause(); audio.classList.remove('show'); audio.pause(); audio.classList.remove('show');
poll(); poll();
@ -462,10 +512,56 @@ function schedLi(it) {
return l; return l;
} }
const ZONE_BADGE = { queued: '✓', sending: '…', unreachable: '✗ nede', rejected: '✗ avvist', failed: '✗ feilet' };
function zoneLi(name, z) {
const l = document.createElement('li');
const busy = z.reachable && z.current;
l.className = 'zone' + (z.reachable ? (busy ? ' busy' : '') : ' down');
const dot = document.createElement('span'); dot.className = 'dot';
const zn = document.createElement('span'); zn.className = 'zn'; zn.textContent = name;
const x = document.createElement('span'); x.className = 'x';
const st = document.createElement('span'); st.className = 's';
if (!z.reachable) { x.textContent = ''; st.textContent = 'nede' + (z.error ? ' · ' + z.error : ''); }
else if (z.current) {
x.textContent = z.current.text;
st.textContent = (z.current.status === 'preparing' ? 'lager lyd' : 'spiller') + (z.queued ? ` · ${z.queued} i kø` : '');
} else { x.textContent = (cfg.app && cfg.app.idle_text) || ''; x.style.color = 'var(--muted)';
st.textContent = z.queued ? `${z.queued} i kø` : ''; }
l.append(dot, zn, x, st);
return l;
}
function dispLi(d) {
const l = document.createElement('li');
const zs = Object.values(d.zones).map(z => z.status);
l.className = zs.every(s => s === 'queued') ? 'done' : (zs.some(s => s === 'queued') ? '' : 'failed');
l.innerHTML = '<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>';
l.querySelector('.t').textContent = hhmm(d.created);
l.querySelector('.x').textContent = d.text;
l.querySelector('.by').textContent = [d.user, d.sender].filter(Boolean).join(' · ');
const s = l.querySelector('.s');
if (d.error) s.textContent = 'feilet · ' + d.error;
for (const [name, z] of Object.entries(d.zones)) {
const b = document.createElement('span'); b.className = 'badge ' + z.status;
b.textContent = name + ' ' + (ZONE_BADGE[z.status] || z.status);
if (z.error) b.title = z.error;
s.append(b);
}
return l;
}
async function poll() { async function poll() {
const now = $('#now'); const now = $('#now');
try { try {
const s = await (await req('/api/status', { headers: headers(false) })).json(); const s = await (await req('/api/status', { headers: headers(false) })).json();
// Mesh: én linje per sone i stedet for «nå spiller»-boksen, og
// fordelingene med sonemerker i stedet for den lokale historikken.
const meshMode = !!s.zones;
$('#zonelist').classList.toggle('hidden', !meshMode);
now.classList.toggle('hidden', meshMode);
if (meshMode) {
$('#zonelist').replaceChildren(...Object.entries(s.zones).map(([n, z]) => zoneLi(n, z)));
$('#history').replaceChildren(...(s.dispatches || []).map(dispLi));
}
if (s.now) skew = s.now - Date.now() / 1000; if (s.now) skew = s.now - Date.now() / 1000;
const sc = s.scheduled || { pending: [], done: [] }; const sc = s.scheduled || { pending: [], done: [] };
const rows = [...sc.pending, ...sc.done]; const rows = [...sc.pending, ...sc.done];
@ -481,7 +577,7 @@ async function poll() {
} }
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; } else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
$('#queue').replaceChildren(...s.queued.map(li)); $('#queue').replaceChildren(...s.queued.map(li));
$('#history').replaceChildren(...s.history.map(li)); if (!meshMode) $('#history').replaceChildren(...s.history.map(li));
} catch (e) { } catch (e) {
if (e.status === 401 || e.status === 403) { if (e.status === 401 || e.status === 403) {
now.className = 'now idle'; now.className = 'now idle';

View file

@ -12,6 +12,7 @@
# ANNOUNCER_PORT=8090 ./install.sh annen port # ANNOUNCER_PORT=8090 ./install.sh annen port
# ANNOUNCER_TITLE="Utrop" ./install.sh annen tittel # ANNOUNCER_TITLE="Utrop" ./install.sh annen tittel
# BIND_IP=127.0.0.1 ./install.sh bare maskina selv # BIND_IP=127.0.0.1 ./install.sh bare maskina selv
# ANNOUNCER_MESH_SECRET=… ./install.sh sone i et mesh (samme nøkkel som sentralen)
# #
# Kan kjøres på nytt; da bygges bildet om og containeren startes om. En .env # Kan kjøres på nytt; da bygges bildet om og containeren startes om. En .env
# som finnes blir ikke overskrevet. # som finnes blir ikke overskrevet.
@ -93,11 +94,22 @@ ANNOUNCER_PIN=${ANNOUNCER_PIN:-}
ANNOUNCER_JWT_SECRET=${ANNOUNCER_JWT_SECRET:-} ANNOUNCER_JWT_SECRET=${ANNOUNCER_JWT_SECRET:-}
ANNOUNCER_OIDC_CLIENT_SECRET=${ANNOUNCER_OIDC_CLIENT_SECRET:-} ANNOUNCER_OIDC_CLIENT_SECRET=${ANNOUNCER_OIDC_CLIENT_SECRET:-}
ANNOUNCER_SECRET_KEY= ANNOUNCER_SECRET_KEY=
ANNOUNCER_MESH_SECRET=${ANNOUNCER_MESH_SECRET:-}
ENV ENV
chmod 600 .env # PIN og nøkler står i klartekst chmod 600 .env # PIN og nøkler står i klartekst
echo "skrev .env (PUID=$(id -u), BIND_IP=$BIND_IP)" echo "skrev .env (PUID=$(id -u), BIND_IP=$BIND_IP)"
fi fi
step "Lyd uten innlogging"
# Lydsocketen ligger i /run/user/<uid>, som bare finnes når brukerens
# systemd-instans kjører – normalt fra grafisk innlogging. En sone i et skap
# (Raspberry Pi uten skjerm) har ingen som logger inn. Linger starter
# brukerens tjenester (PipeWire inkludert) ved boot uansett. Harmløst på en
# laptop, nødvendig på en Pi.
if command -v loginctl >/dev/null 2>&1; then
sudo loginctl enable-linger "$USER" && echo "linger på for $USER"
fi
step "Datamapper" step "Datamapper"
mkdir -p data/{voices,cache,chimes,logs,instance} mkdir -p data/{voices,cache,chimes,logs,instance}
[[ -f data/chimes/README.txt ]] || cp -n chimes/README.txt data/chimes/ 2>/dev/null || true [[ -f data/chimes/README.txt ]] || cp -n chimes/README.txt data/chimes/ 2>/dev/null || true
@ -164,6 +176,11 @@ Tips for en laptop på arrangementet:
sudo sed -i 's/^#\?HandleLidSwitch=.*/HandleLidSwitch=ignore/' /etc/systemd/logind.conf sudo sed -i 's/^#\?HandleLidSwitch=.*/HandleLidSwitch=ignore/' /etc/systemd/logind.conf
sudo systemctl restart systemd-logind sudo systemctl restart systemd-logind
Mesh (flere anlegg): sett [zones] i data/config.toml på sentralen og samme
ANNOUNCER_MESH_SECRET i .env på sentralen og hver sone. Se docs/mesh.md.
På en Pi med både HDMI og jack: velg lydutgang med sudo raspi-config
(System Options -> Audio), ellers går lyden til skjermen som ikke er der.
Drift: Drift:
docker compose logs -f følg loggen docker compose logs -f følg loggen
docker compose restart etter endringer i data/config.toml docker compose restart etter endringer i data/config.toml

414
mesh.py Normal file
View 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)

View file

@ -56,6 +56,7 @@ class Scheduled:
created: float = field(default_factory=time.time) created: float = field(default_factory=time.time)
status: str = "pending" # se STATUSES status: str = "pending" # se STATUSES
note: str = "" note: str = ""
zones: list[str] = field(default_factory=list) # tom = lokalt / alle
def public(self) -> dict: def public(self) -> dict:
d = asdict(self) d = asdict(self)
@ -118,7 +119,8 @@ class Schedule:
# -- endringer --------------------------------------------------------- # -- endringer ---------------------------------------------------------
def add(self, *, at: float, text: str, voice: str, speed: float, chime: str, def add(self, *, at: float, text: str, voice: str, speed: float, chime: str,
user: str = "", subject: str = "", method: str = "none") -> Scheduled: user: str = "", subject: str = "", method: str = "none",
zones: list[str] | None = None) -> Scheduled:
"""Legger til en melding. Kaster ValueError på et ubrukelig tidspunkt.""" """Legger til en melding. Kaster ValueError på et ubrukelig tidspunkt."""
at = float(at) at = float(at)
now = time.time() now = time.time()
@ -129,6 +131,7 @@ class Schedule:
item = Scheduled( item = Scheduled(
id=secrets.token_hex(4), at=at, text=text, voice=voice, speed=speed, id=secrets.token_hex(4), at=at, text=text, voice=voice, speed=speed,
chime=chime, user=user, subject=subject, method=method, chime=chime, user=user, subject=subject, method=method,
zones=[str(z) for z in (zones or [])],
) )
with self._lock: with self._lock:
self.items.append(item) self.items.append(item)

View file

@ -72,7 +72,21 @@ def test_uten_konfig_er_alt_apent(tmp_path):
c, a = build(tmp_path) c, a = build(tmp_path)
assert a.enabled is False assert a.enabled is False
for action in config.ACTIONS: for action in config.ACTIONS:
if action == "play":
continue
assert c.get(f"/do/{action}").status_code == 200, action assert c.get(f"/do/{action}").status_code == 200, action
# Det ene unntaket: «play» er for en sentral med mesh-nøkkel. En åpen
# sone skal ikke spille lyd for hvem som helst som finner den.
assert c.get("/do/play").status_code == 401
def test_feil_legitimasjon_er_feil_ogsa_pa_apen_sone(tmp_path):
"""En PIN som vises fram og er feil, skal avvises – ikke behandles som
gjest fordi gjester tilfeldigvis har lov."""
c, _ = build(tmp_path, '[mesh]\nsecret = "riktig"\n')
assert c.get("/do/view").status_code == 200 # gjest: lov
assert c.get("/do/view", headers={"X-Mesh-Key": "feil"}).status_code == 401 # feil nøkkel: nei
assert c.get("/do/play", headers={"X-Mesh-Key": "riktig"}).status_code == 200
def test_tom_providers_liste_er_fortsatt_apen(tmp_path): def test_tom_providers_liste_er_fortsatt_apen(tmp_path):

240
tests/test_mesh.py Normal file
View file

@ -0,0 +1,240 @@
"""Tester for mesh.py: soner, fordeling og statuspolling.
Dispatcheren får synthesize/play_local/send inn som funksjoner, så alt her
går uten nett. Den ekte HTTP-transporten (send_to_zone mot en levende
/api/play) prøves i test_routes.py, der announcer-appen finnes.
"""
from __future__ import annotations
import sys
import threading
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import mesh # noqa: E402
# --------------------------------------------------------------------------
# Soner fra config
# --------------------------------------------------------------------------
def test_zones_fra_config():
z = mesh.zones_from_config({"her": "local", "scene": "http://10.0.0.5:8090/", "res": "https://r.lan"})
assert z["her"].local and z["her"].url == ""
assert not z["scene"].local and z["scene"].url == "http://10.0.0.5:8090" # uten skråstrek
assert list(z) == ["her", "scene", "res"] # rekkefølgen bevares
@pytest.mark.parametrize("raw, feil", [
({"scene": "10.0.0.5:8090"}, "verken"),
({"scene": "ftp://x"}, "verken"),
({"": "local"}, "ugyldig sonenavn"),
({"har mellomrom": "local"}, "ugyldig sonenavn"),
({"x" * 33: "local"}, "ugyldig sonenavn"),
])
def test_ugyldig_zones_feiler_hoyt(raw, feil):
with pytest.raises(ValueError, match=feil):
mesh.zones_from_config(raw)
# --------------------------------------------------------------------------
# Dispatcher
# --------------------------------------------------------------------------
ZONES = mesh.zones_from_config({"her": "local", "a": "http://a", "b": "http://b"})
def make(tmp_path, *, send=None, synth=None, timeout=1.0, on_finished=None):
wav = tmp_path / "x.wav"
wav.write_bytes(b"RIFF")
played: list[tuple[str, Path]] = []
sent: list[str] = []
def play_local(d, path):
played.append((d.text, path))
return 42
def default_send(zone, path, d):
sent.append(zone.name)
return "queued", 7, ""
disp = mesh.Dispatcher(
ZONES,
synthesize=synth or (lambda *a: wav),
play_local=play_local,
send=send or default_send,
timeout=timeout,
on_finished=on_finished,
)
return disp, played, sent, wav
def test_resolve():
disp, *_ = make(Path("/tmp"))
assert disp.resolve(None) == ["her", "a", "b"]
assert disp.resolve([]) == ["her", "a", "b"]
assert disp.resolve(["b", "a", "b"]) == ["b", "a"] # dedupe, rekkefølge som bedt
assert disp.resolve("a") == ["a"]
with pytest.raises(ValueError, match="Ukjent sone: c"):
disp.resolve(["a", "c"])
with pytest.raises(ValueError, match="liste"):
disp.resolve({"a": 1})
def test_fordeling_til_alle(tmp_path):
ferdig = []
disp, played, sent, wav = make(tmp_path, on_finished=ferdig.append)
d = disp.submit("Hallo", "finn", 1.0, "none", ["her", "a", "b"], user="Kari")
assert d.pending
assert disp.run_once(timeout=1) is d
assert not d.pending
assert {n: z.status for n, z in d.zones.items()} == {"her": "queued", "a": "queued", "b": "queued"}
assert d.zones["her"].item_id == 42 and d.zones["a"].item_id == 7
assert played == [("Hallo", wav)]
assert sorted(sent) == ["a", "b"]
assert ferdig == [d]
assert d.accepted == ["her", "a", "b"]
assert d.summary() == "her: kø #42, a: kø #7, b: kø #7"
def test_bare_valgte_soner(tmp_path):
disp, played, sent, _ = make(tmp_path)
disp.submit("x", "finn", 1.0, "none", ["b"])
disp.run_once(timeout=1)
assert played == [] and sent == ["b"]
def test_en_sone_nede_stopper_ikke_de_andre(tmp_path):
def send(zone, path, d):
if zone.name == "b":
return "unreachable", None, "connection refused"
return "queued", 3, ""
disp, played, _, _ = make(tmp_path, send=send)
d = disp.submit("x", "finn", 1.0, "none", ["her", "a", "b"])
disp.run_once(timeout=1)
assert d.zones["a"].status == "queued"
assert d.zones["b"].status == "unreachable" and "refused" in d.zones["b"].error
assert d.zones["her"].status == "queued"
assert d.accepted == ["her", "a"]
assert "b: nede" in d.summary()
def test_send_som_kaster_blir_unreachable(tmp_path):
def send(zone, path, d):
raise OSError("kabelen er ute")
disp, *_ = make(tmp_path, send=send)
d = disp.submit("x", "finn", 1.0, "none", ["a"])
disp.run_once(timeout=1)
assert d.zones["a"].status == "unreachable"
assert "kabelen" in d.zones["a"].error
def test_treg_sone_gir_opp_etter_timeout(tmp_path):
def send(zone, path, d):
time.sleep(2)
return "queued", 1, ""
disp, *_ = make(tmp_path, send=send, timeout=0.1)
d = disp.submit("x", "finn", 1.0, "none", ["a"])
t0 = time.time()
disp.run_once(timeout=1)
assert time.time() - t0 < 1.8
assert d.zones["a"].status == "unreachable"
assert "i tide" in d.zones["a"].error
def test_syntesefeil_feiler_alle_soner(tmp_path):
def synth(*a):
raise RuntimeError("edge-tts nede")
ferdig = []
disp, played, sent, _ = make(tmp_path, synth=synth, on_finished=ferdig.append)
d = disp.submit("x", "finn", 1.0, "none", ["her", "a"])
disp.run_once(timeout=1)
assert d.error == "edge-tts nede"
assert all(z.status == "failed" for z in d.zones.values())
assert played == [] and sent == []
assert d.accepted == []
assert ferdig == [d] # planen må få vite det
def test_lokal_feil_rammer_bare_lokal(tmp_path):
disp, _, sent, _ = make(tmp_path)
def boom(d, path):
raise RuntimeError("ingen lyd")
disp.play_local = boom
d = disp.submit("x", "finn", 1.0, "none", ["her", "a"])
disp.run_once(timeout=1)
assert d.zones["her"].status == "failed" and "ingen lyd" in d.zones["her"].error
assert d.zones["a"].status == "queued"
def test_snapshot_nyeste_forst_og_uten_ip(tmp_path):
disp, *_ = make(tmp_path)
disp.submit("en", "finn", 1.0, "none", ["a"], sender="10.0.0.9")
disp.submit("to", "finn", 1.0, "none", ["a"], sender="10.0.0.9")
snap = disp.snapshot()
assert [d["text"] for d in snap] == ["to", "en"]
assert snap[0]["sender"] == ""
assert disp.snapshot(show_ip=True)[0]["sender"] == "10.0.0.9"
assert "zones" in snap[0] and snap[0]["zones"]["a"]["status"] == "sending"
def test_run_once_pa_tom_ko(tmp_path):
disp, *_ = make(tmp_path)
assert disp.run_once(timeout=0.05) is None
# --------------------------------------------------------------------------
# ZonePoller
# --------------------------------------------------------------------------
def test_poller_henter_eksterne_og_slar_opp_lokal_ferskt():
svar = {"a": {"reachable": True, "current": None, "queued": 2},
"b": {"reachable": False, "error": "timeout"}}
lokal = {"n": 0}
def local_status():
lokal["n"] += 1
return {"current": None, "queued": lokal["n"]}
p = mesh.ZonePoller(ZONES, fetch=lambda z: svar[z.name], interval=99, local_status=local_status)
before = p.snapshot()
assert before["a"]["reachable"] is False and "ennå" in before["a"]["error"]
assert before["her"]["reachable"] is True and before["her"]["queued"] == 1
p.poll_once()
after = p.snapshot()
assert after["a"] == {"reachable": True, "current": None, "queued": 2, "checked": after["a"]["checked"]}
assert after["b"]["reachable"] is False and after["b"]["error"] == "timeout"
assert after["her"]["queued"] == 2 # lokal slås opp på nytt hver gang
assert list(after) == ["her", "a", "b"]
def test_poller_taler_fetch_som_kaster():
def fetch(z):
raise RuntimeError("nei")
p = mesh.ZonePoller(ZONES, fetch=fetch, interval=99)
p.poll_once() # skal ikke kaste
assert p.snapshot()["a"]["reachable"] is False
# --------------------------------------------------------------------------
# Transport mot noe som ikke finnes
# --------------------------------------------------------------------------
def test_send_til_sone_som_ikke_finnes(tmp_path):
wav = tmp_path / "x.wav"; wav.write_bytes(b"RIFF")
d = mesh.Dispatch("abc", "x", "finn", 1.0, "none", {})
status, item_id, err = mesh.send_to_zone(mesh.Zone("z", "http://127.0.0.1:1"), wav, d,
secret="s", timeout=1)
assert status == "unreachable" and item_id is None and err
def test_status_fra_sone_som_ikke_finnes():
r = mesh.fetch_zone_status(mesh.Zone("z", "http://127.0.0.1:1"), secret="s", timeout=1)
assert r["reachable"] is False and r["error"]

View file

@ -343,3 +343,231 @@ def test_uskrivbar_datamappe_gir_klar_melding(tmp_path, monkeypatch):
announcer.check_writable() announcer.check_writable()
assert "PUID" in str(e.value) and "chown" in str(e.value) assert "PUID" in str(e.value) and "chown" in str(e.value)
locked.chmod(0o700) locked.chmod(0o700)
# --------------------------------------------------------------------------
# Mesh gjennom HTTP: sonens /api/play, sentralens fordeling, og ekte transport
# --------------------------------------------------------------------------
import mesh # noqa: E402
MESH_CFG = '[mesh]\nsecret = "mesh-hemmelighet-for-test"\n'
KEY = {"X-Mesh-Key": "mesh-hemmelighet-for-test"}
def play_body(tmp_path, meta: dict | None = None):
wav = tiny_wav(tmp_path / "inn.wav")
return {"wav": (wav.open("rb"), "inn.wav"),
"meta": json.dumps(meta or {"origin_id": "d1", "text": "Fra sentralen", "user": "Kari",
"subject": "jwt:7", "method": "jwt", "voice": "finn"})}
def test_play_krever_mesh_nokkel(c, tmp_path):
with_auth(tmp_path, MESH_CFG)
assert c.post("/api/play", data=play_body(tmp_path)).status_code == 401
r = c.post("/api/play", data=play_body(tmp_path), headers={"X-Mesh-Key": "feil"})
assert r.status_code == 401 and r.get_json()["error"] == "Feil mesh-nøkkel"
def test_play_uten_mesh_oppsett_er_stengt(c):
"""Ingen leverandør gir play, så 401 selv om endepunktet finnes."""
assert c.post("/api/play", data=play_body(_TMP)).status_code == 401
def test_mesh_nokkel_apner_ikke_sida_for_folk(c, tmp_path):
"""En sone med bare mesh-nøkkel skal fortsatt ha åpen side lokalt."""
with_auth(tmp_path, MESH_CFG)
d = c.get("/api/config").get_json()["auth"]
assert d["enabled"] is False and d["methods"] == []
assert c.post("/api/announce", json={"text": "lokalt"}).status_code == 200
def test_play_koer_wav_uten_syntese(c, tmp_path, monkeypatch):
with_auth(tmp_path, MESH_CFG)
def aldri(*a):
raise AssertionError("sonen skal ikke syntetisere")
monkeypatch.setattr(announcer, "synthesize", aldri)
r = c.post("/api/play", data=play_body(tmp_path), headers=KEY)
assert r.status_code == 200
item = announcer.player.history[0]
assert r.get_json() == {"id": item.id}
assert item.wav == announcer.CACHE_DIR / "mesh-d1.wav" and item.wav.exists()
assert (item.text, item.user, item.subject, item.method) == ("Fra sentralen", "Kari", "jwt:7", "jwt")
assert announcer.player.run_once(timeout=1).status == "done"
log = audit_lines(tmp_path)
assert log[0]["action"] == "play" and log[0]["user"] == "Kari" and log[0]["subject"] == "mesh"
@pytest.mark.parametrize("data, feil", [
({}, "Mangler wav"),
({"meta": "{ikke json"}, "Ugyldig meta"),
])
def test_play_avviser_ufullstendig(c, tmp_path, data, feil):
with_auth(tmp_path, MESH_CFG)
if "meta" in data:
data = {**data, "wav": (tiny_wav(tmp_path / "w.wav").open("rb"), "w.wav")}
r = c.post("/api/play", data=data, headers=KEY)
assert r.status_code == 400 and r.get_json()["error"] == feil
def test_play_avviser_fil_som_ikke_er_wav(c, tmp_path):
with_auth(tmp_path, MESH_CFG)
junk = tmp_path / "junk.wav"; junk.write_bytes(b"dette er ikke lyd")
r = c.post("/api/play", data={"wav": (junk.open("rb"), "junk.wav"), "meta": "{}"}, headers=KEY)
assert r.status_code == 400 and "WAV" in r.get_json()["error"]
assert not list(announcer.CACHE_DIR.glob("mesh-*.wav")), "søppel skal ikke bli liggende"
def test_zones_uten_mesh_oppsett_gir_400(c):
r = c.post("/api/announce", json={"text": "x", "zones": ["scene"]})
assert r.status_code == 400 and "Ingen soner" in r.get_json()["error"]
assert c.get("/api/config").get_json()["zones"] == []
@pytest.fixture
def sentral(app, tmp_path, monkeypatch):
"""Gjør test-appen til sentral for «her» + to soner, med stubbet transport."""
zones = mesh.zones_from_config({"her": "local", "a": "http://a", "b": "http://b"})
sent: list[tuple[str, str]] = []
def send(zone, path, d):
sent.append((zone.name, d.text))
return ("unreachable", None, "nede") if zone.name == "b" else ("queued", 5, "")
disp = mesh.Dispatcher(zones, synthesize=lambda *a: announcer.synthesize(*a),
play_local=announcer._play_local, send=send, timeout=1,
on_finished=announcer._dispatch_finished)
poller = mesh.ZonePoller(zones, fetch=lambda z: {"reachable": z.name == "a", "queued": 0, "current": None},
interval=99, local_status=lambda: {"queued": 0, "current": None})
monkeypatch.setattr(announcer, "DISPATCHER", disp)
monkeypatch.setattr(announcer, "POLLER", poller)
return disp, sent
def test_sentral_fordeler(c, sentral, tmp_path):
disp, sent = sentral
assert c.get("/api/config").get_json()["zones"] == ["her", "a", "b"]
r = c.post("/api/announce", json={"text": "Til alle", "chime": "none"})
assert r.status_code == 200
body = r.get_json()
assert body["dispatch"]["zones"]["a"]["status"] == "sending"
d = disp.run_once(timeout=1)
assert d.id == body["id"]
assert {n: z.status for n, z in d.zones.items()} == {"her": "queued", "a": "queued", "b": "unreachable"}
assert sorted(sent) == [("a", "Til alle"), ("b", "Til alle")]
# Lokalt: ferdig WAV i køen, spilles uten ny syntese
item = announcer.player.history[0]
assert item.wav is not None and item.text == "Til alle"
assert announcer.player.run_once(timeout=1).status == "done"
log = audit_lines(tmp_path)
assert [l["action"] for l in log] == ["announce", "dispatched", "played"]
assert log[0]["zones"] == ["her", "a", "b"]
assert log[1]["zones"] == {"her": "queued", "a": "queued", "b": "unreachable"}
def test_sentral_bare_til_valgte(c, sentral):
disp, sent = sentral
c.post("/api/announce", json={"text": "x", "zones": ["a"]})
disp.run_once(timeout=1)
assert sent == [("a", "x")]
assert announcer.player.history == [] # ikke lokalt
def test_sentral_ukjent_sone_gir_400_for_noe_sendes(c, sentral):
disp, sent = sentral
r = c.post("/api/announce", json={"text": "x", "zones": ["a", "finnes-ikke"]})
assert r.status_code == 400 and r.get_json()["error"] == "Ukjent sone: finnes-ikke"
assert disp.run_once(timeout=0.05) is None and sent == []
def test_sentral_status_har_soner_og_fordelinger(c, sentral):
disp, _ = sentral
announcer.POLLER.poll_once()
c.post("/api/announce", json={"text": "x"})
disp.run_once(timeout=1)
s = c.get("/api/status").get_json()
assert s["zones"]["her"]["reachable"] and s["zones"]["a"]["reachable"] and not s["zones"]["b"]["reachable"]
assert s["dispatches"][0]["text"] == "x"
assert s["dispatches"][0]["zones"]["b"]["status"] == "unreachable"
def test_planlagt_med_soner_fordeles(c, sentral, tmp_path):
disp, sent = sentral
r = c.post("/api/schedule", json={"text": "Planlagt", "at": time.time() + 600, "zones": ["a", "her"]})
sid = r.get_json()["id"]
assert r.get_json()["zones"] == ["a", "her"]
# overlever reload
assert sched.Schedule(announcer.schedule.path).get(sid).zones == ["a", "her"]
announcer.schedule.get(sid).at = time.time() - 10
announcer.scheduler_tick()
assert announcer.schedule.get(sid).status == "queued"
assert "fordeles til a, her" in announcer.schedule.get(sid).note
d = disp.run_once(timeout=1)
assert d.schedule_id == sid
assert sent == [("a", "Planlagt")]
assert announcer.schedule.get(sid).status == "sent"
assert announcer.schedule.get(sid).note == "a: kø #5, her: kø #1" # rekkefølgen som ble bedt om
def test_planlagt_der_alle_soner_er_nede_blir_failed(c, sentral):
disp, _ = sentral
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600, "zones": ["b"]}).get_json()["id"]
announcer.schedule.get(sid).at = time.time() - 10
announcer.scheduler_tick()
disp.run_once(timeout=1)
assert announcer.schedule.get(sid).status == "failed"
assert "b: nede" in announcer.schedule.get(sid).note
def test_planlagt_til_sone_som_er_fjernet_fra_config(c, sentral):
"""Planen ble lagt før sonen ble tatt ut av config."""
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}).get_json()["id"]
announcer.schedule.get(sid).zones = ["borte"]
announcer.schedule.get(sid).at = time.time() - 10
announcer.scheduler_tick()
assert announcer.schedule.get(sid).status == "failed"
assert "Ukjent sone" in announcer.schedule.get(sid).note
# --- ekte transport: send_to_zone mot en levende /api/play -------------------
@pytest.fixture
def live_sone(app, tmp_path):
"""Kjører test-appen som sone på en ekte port, så multipart-koden i
send_to_zone møter Flask sin parser og ikke bare seg selv."""
from werkzeug.serving import make_server
with_auth(tmp_path, MESH_CFG)
srv = make_server("127.0.0.1", 0, app, threaded=True)
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
yield mesh.Zone("live", f"http://127.0.0.1:{srv.server_port}")
srv.shutdown()
def test_ekte_runde_sentral_til_sone(live_sone, tmp_path):
wav = tiny_wav(tmp_path / "ut.wav")
d = mesh.Dispatch("rt1", "Over nettet", "finn", 1.0, "none", {}, user="Kari", subject="jwt:7", method="jwt")
status, item_id, err = mesh.send_to_zone(live_sone, wav, d, secret="mesh-hemmelighet-for-test", timeout=3)
assert (status, err) == ("queued", "")
item = announcer.player.history[0]
assert item.id == item_id
assert item.text == "Over nettet" and item.user == "Kari"
assert item.wav.read_bytes() == wav.read_bytes()
st = mesh.fetch_zone_status(live_sone, secret="mesh-hemmelighet-for-test", timeout=3)
assert st["reachable"] is True and st["queued"] == 1
def test_ekte_runde_med_feil_nokkel(live_sone, tmp_path):
wav = tiny_wav(tmp_path / "ut.wav")
d = mesh.Dispatch("rt2", "x", "finn", 1.0, "none", {})
status, item_id, err = mesh.send_to_zone(live_sone, wav, d, secret="feil", timeout=3)
assert status == "rejected" and item_id is None
assert err.startswith("401") and "mesh-nøkkel" in err
assert mesh.fetch_zone_status(live_sone, secret="feil", timeout=3)["reachable"] is False