From c4a15799dd39039dd1d59a7228decd2c27922f85 Mon Sep 17 00:00:00 2001 From: Kent Date: Fri, 9 Oct 2026 02:22:05 +0200 Subject: [PATCH] =?UTF-8?q?Mesh:=20=C3=A9n=20sentral=20fordeler=20til=20fl?= =?UTF-8?q?ere=20soner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 4 + AGENTS.md | 57 +++++- announcer.py | 168 +++++++++++++++- auth.py | 48 ++++- compose.test.yaml | 35 +++- compose.yaml | 1 + config.example.toml | 23 +++ config.py | 25 ++- docker/config.test.toml | 7 + docs/mesh.md | 119 ++++++++++++ index.html | 104 +++++++++- install.sh | 17 ++ mesh.py | 414 ++++++++++++++++++++++++++++++++++++++++ schedule.py | 5 +- tests/test_auth.py | 14 ++ tests/test_mesh.py | 240 +++++++++++++++++++++++ tests/test_routes.py | 228 ++++++++++++++++++++++ 17 files changed, 1486 insertions(+), 23 deletions(-) create mode 100644 docs/mesh.md create mode 100644 mesh.py create mode 100644 tests/test_mesh.py diff --git a/.env.example b/.env.example index 70b8647..c4ef6b4 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ ANNOUNCER_TITLE= # Full URL utenfra. Trengs bare for OIDC bak en omvendt proxy. ANNOUNCER_PUBLIC_URL= +# Mesh. Samme verdi på sentralen og alle soner. Tom = ingen mesh. +# Lag en: openssl rand -hex 24 +ANNOUNCER_MESH_SECRET= + # --- hemmeligheter ------------------------------------------------------- # Tom = av. Se config.example.toml og docs/crew-integration.md. ANNOUNCER_PIN= diff --git a/AGENTS.md b/AGENTS.md index df4e315..30065c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,10 +19,12 @@ Filer: | `auth.py` | `Principal`, leverandørene, OIDC-rutene | | `config.py` | `config.toml` + miljøvariabler, standardverdier | | `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 | | `config.example.toml` | kommentert mal; kopier til `config.toml` | | `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 | | `.env.example` | bruker-ID, portbinding og hemmeligheter | | `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 `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 `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 med flere workere: `Player` og planleggeren er singletons i prosessen, og 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 `[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din diff --git a/announcer.py b/announcer.py index 0fc2017..f7dde3a 100644 --- a/announcer.py +++ b/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 diff --git a/auth.py b/auth.py index 0aab945..b6953db 100644 --- a/auth.py +++ b/auth.py @@ -214,6 +214,29 @@ class PinProvider(Provider): 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): name = "jwt" @@ -372,7 +395,7 @@ class OidcProvider(Provider): 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] = [ 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: return next((p for p in self.providers if p.name == name), None) @@ -426,8 +452,11 @@ class Authenticator: veggen uten innlogging.""" if not self.enabled: # Tomt navn: da står køen og loggen som før, uten en «bruker» som - # i praksis bare betyr «hvem som helst». - return Principal("anonymous", "", ("anonymous",), "none", frozenset(ACTIONS)) + # i praksis bare betyr «hvem som helst». Og aldri «play»: den er + # 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",) return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles)) @@ -479,7 +508,7 @@ class Authenticator: def describe(self) -> dict[str, Any]: return { "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, } @@ -507,16 +536,21 @@ def require(action: str) -> Principal: hvem det er men rollen mangler handlingen. """ 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): return p 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.") def _login_hint() -> str: assert _auth is not None - names = {p.name for p in _auth.providers} + names = {p.name for p in _auth.human} if names == {"pin"}: return "Feil eller manglende PIN." if "oidc" in names: diff --git a/compose.test.yaml b/compose.test.yaml index 3ef2adf..7a6376d 100644 --- a/compose.test.yaml +++ b/compose.test.yaml @@ -2,8 +2,13 @@ # # 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: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 # 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_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker ANNOUNCER_PUBLIC_URL: http://localhost:8091 + ANNOUNCER_MESH_SECRET: mesh-test-nokkel-kun-for-docker XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000} TZ: ${TZ:-Europe/Oslo} volumes: - .:/app - ./data:/data - ${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: build: . diff --git a/compose.yaml b/compose.yaml index daa8d5e..b054205 100644 --- a/compose.yaml +++ b/compose.yaml @@ -31,6 +31,7 @@ services: ANNOUNCER_JWT_SECRET: ${ANNOUNCER_JWT_SECRET:-} ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-} ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-} + ANNOUNCER_MESH_SECRET: ${ANNOUNCER_MESH_SECRET:-} XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000} # 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 diff --git a/config.example.toml b/config.example.toml index c30de33..c556c37 100644 --- a/config.example.toml +++ b/config.example.toml @@ -25,6 +25,26 @@ quick_phrases = [ "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] host = "0.0.0.0" port = 8090 @@ -123,6 +143,7 @@ label = "Logg inn med crew-kontoen" # preview forhåndslytte (synteser lyd) # announce sende ut på anlegget # 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 # clear tømme køen # "*" betyr alt. @@ -132,3 +153,5 @@ label = "Logg inn med crew-kontoen" anonymous = [] crew = ["view", "preview", "announce", "schedule"] admin = ["*"] +# Sentralen, når den snakker med denne maskina som sone. Ikke endre. +mesh = ["play", "view"] diff --git a/config.py b/config.py index 8cb14ef..45f0c8e 100644 --- a/config.py +++ b/config.py @@ -26,7 +26,8 @@ from typing import Any, Callable HERE = Path(__file__).resolve().parent # Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse. -ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear") +# «play» er bare for mesh: en sentral som leverer en ferdig WAV til en sone. +ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear", "play") DEFAULTS: dict[str, Any] = { "app": { @@ -72,6 +73,15 @@ DEFAULTS: dict[str, Any] = { # Hvor ofte vi ser etter forfalte sendinger. "tick_seconds": 20, }, + # Mesh: én sentral som fordeler til flere soner. [zones] settes bare på + # sentralen (navn -> URL, eller "local" for eget anlegg); hemmeligheten + # settes på sentralen OG hver sone. Begge tomme = ingen mesh, alt som før. + "zones": {}, + "mesh": { + "secret": "", + "timeout_seconds": 5, + "poll_seconds": 2, + }, "auth": { # Tom liste = alt åpent. Se active_providers(): en leverandør er også # aktiv når den er ferdig konfigurert, så det holder å sette @@ -117,6 +127,8 @@ DEFAULTS: dict[str, Any] = { "anonymous": [], "crew": ["view", "preview", "announce", "schedule"], "admin": ["*"], + # Sentralen når den snakker med en sone. view er for statuspollingen. + "mesh": ["play", "view"], }, } @@ -150,6 +162,7 @@ ENV: dict[str, tuple[tuple[str, str], Callable[[str], Any]]] = { "ANNOUNCER_SECRET_KEY": (("server", "secret_key"), str), "ANNOUNCER_PUBLIC_URL": (("server", "public_url"), str), "ANNOUNCER_AUTH": (("auth", "providers"), _list), + "ANNOUNCER_MESH_SECRET": (("mesh", "secret"), str), "ANNOUNCER_PIN": (("auth.pin", "pin"), str), "ANNOUNCER_PIN_ROLE": (("auth.pin", "role"), str), "ANNOUNCER_JWT_ISSUER": (("auth.jwt", "issuer"), str), @@ -232,7 +245,7 @@ class Config: """ listed = [str(p).strip().lower() for p in self.get("auth", "providers", [])] out: list[str] = [] - for name in ("pin", "jwt", "oidc"): + for name in ("mesh", "pin", "jwt", "oidc"): if name in listed or self.configured(name): out.append(name) # Rekkefølgen i config vinner for de som er nevnt der. @@ -247,8 +260,16 @@ class Config: return bool(s.get("secret") or s.get("jwks_url")) if provider == "oidc": return bool(s.get("client_id") and (s.get("issuer") or s.get("authorization_endpoint"))) + if provider == "mesh": + return bool(str(self.get("mesh", "secret", "")).strip()) return False + @property + def zones(self) -> dict[str, str]: + """Sonene på en sentral, i konfigurert rekkefølge. Tom = ingen mesh.""" + raw = self.section("zones") + return {str(k): str(v).strip() for k, v in raw.items() if str(v).strip()} + def permissions(self, roles: tuple[str, ...]) -> frozenset[str]: """Rollenavn -> handlinger. "*" betyr alt.""" out: set[str] = set() diff --git a/docker/config.test.toml b/docker/config.test.toml index 054106c..34318af 100644 --- a/docker/config.test.toml +++ b/docker/config.test.toml @@ -10,6 +10,13 @@ subtitle = "Docker-oppsett – PIN, JWT og OIDC er alle slått på" accent = "#f5a623" 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] providers = ["oidc", "jwt", "pin"] diff --git a/docs/mesh.md b/docs/mesh.md new file mode 100644 index 0000000..4cb712c --- /dev/null +++ b/docs/mesh.md @@ -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= +``` + +`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= ./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/` 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: `, 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": }`. 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. diff --git a/index.html b/index.html index 28615b5..46e8fd4 100644 --- a/index.html +++ b/index.html @@ -77,6 +77,22 @@ ul.list li button.x:hover { color: var(--danger); } ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); } .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); } @@ -121,6 +137,8 @@ + +
@@ -148,6 +166,7 @@
+
    @@ -163,10 +182,37 @@ const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#c const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce'); 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 payload() { 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) { const h = json ? { 'Content-Type': 'application/json' } : {}; // 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); text.placeholder = a.message_placeholder || ''; $('#max').textContent = c.max_chars; text.maxLength = c.max_chars; + renderZones(c.zones || []); $('#chips').replaceChildren(...(a.quick_phrases || []).map(q => { const b = document.createElement('button'); @@ -360,9 +407,10 @@ $('#btnSchedule').addEventListener('click', async () => { const at = atEpoch(); if (!at) return say('Velg et tidspunkt først.', 'err'); if (at < serverNow()) return say('Tidspunktet er passert.', 'err'); + if (hasZones() && !selectedZones().length) return say('Velg minst én sone.', 'err'); $('#btnSchedule').disabled = true; 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', { weekday: 'short', hour: '2-digit', minute: '2-digit' })}.`, 'ok'); text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null; @@ -409,10 +457,12 @@ btnPreview.addEventListener('click', async () => { }); btnAnnounce.addEventListener('click', async () => { + if (hasZones() && !selectedZones().length) return say('Velg minst én sone.', 'err'); btnAnnounce.disabled = true; try { - const item = await (await api('/api/announce', payload())).json(); - say(`Lagt i kø som #${item.id}.`, 'ok'); + const item = await (await api('/api/announce', sendPayload())).json(); + 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; audio.pause(); audio.classList.remove('show'); poll(); @@ -462,10 +512,56 @@ function schedLi(it) { 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 = ''; + 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() { const now = $('#now'); try { 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; const sc = s.scheduled || { pending: [], 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) || ''; } $('#queue').replaceChildren(...s.queued.map(li)); - $('#history').replaceChildren(...s.history.map(li)); + if (!meshMode) $('#history').replaceChildren(...s.history.map(li)); } catch (e) { if (e.status === 401 || e.status === 403) { now.className = 'now idle'; diff --git a/install.sh b/install.sh index 2f7aed7..79c4cb3 100755 --- a/install.sh +++ b/install.sh @@ -12,6 +12,7 @@ # ANNOUNCER_PORT=8090 ./install.sh annen port # ANNOUNCER_TITLE="Utrop" ./install.sh annen tittel # 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 # som finnes blir ikke overskrevet. @@ -93,11 +94,22 @@ ANNOUNCER_PIN=${ANNOUNCER_PIN:-} ANNOUNCER_JWT_SECRET=${ANNOUNCER_JWT_SECRET:-} ANNOUNCER_OIDC_CLIENT_SECRET=${ANNOUNCER_OIDC_CLIENT_SECRET:-} ANNOUNCER_SECRET_KEY= +ANNOUNCER_MESH_SECRET=${ANNOUNCER_MESH_SECRET:-} ENV chmod 600 .env # PIN og nøkler står i klartekst echo "skrev .env (PUID=$(id -u), BIND_IP=$BIND_IP)" fi +step "Lyd uten innlogging" +# Lydsocketen ligger i /run/user/, 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" mkdir -p data/{voices,cache,chimes,logs,instance} [[ -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 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: docker compose logs -f følg loggen docker compose restart etter endringer i data/config.toml diff --git a/mesh.py b/mesh.py new file mode 100644 index 0000000..f4612f8 --- /dev/null +++ b/mesh.py @@ -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) diff --git a/schedule.py b/schedule.py index 97ab15b..d081b9a 100644 --- a/schedule.py +++ b/schedule.py @@ -56,6 +56,7 @@ class Scheduled: created: float = field(default_factory=time.time) status: str = "pending" # se STATUSES note: str = "" + zones: list[str] = field(default_factory=list) # tom = lokalt / alle def public(self) -> dict: d = asdict(self) @@ -118,7 +119,8 @@ class Schedule: # -- endringer --------------------------------------------------------- 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.""" at = float(at) now = time.time() @@ -129,6 +131,7 @@ class Schedule: item = Scheduled( id=secrets.token_hex(4), at=at, text=text, voice=voice, speed=speed, chime=chime, user=user, subject=subject, method=method, + zones=[str(z) for z in (zones or [])], ) with self._lock: self.items.append(item) diff --git a/tests/test_auth.py b/tests/test_auth.py index 6ffef76..b3062f1 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -72,7 +72,21 @@ def test_uten_konfig_er_alt_apent(tmp_path): c, a = build(tmp_path) assert a.enabled is False for action in config.ACTIONS: + if action == "play": + continue 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): diff --git a/tests/test_mesh.py b/tests/test_mesh.py new file mode 100644 index 0000000..c8278dc --- /dev/null +++ b/tests/test_mesh.py @@ -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"] diff --git a/tests/test_routes.py b/tests/test_routes.py index f9074e1..00d3387 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -343,3 +343,231 @@ def test_uskrivbar_datamappe_gir_klar_melding(tmp_path, monkeypatch): announcer.check_writable() assert "PUID" in str(e.value) and "chown" in str(e.value) 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