Mesh: én sentral fordeler til flere soner
Flere anlegg – hovedscene, resepsjon, crew – og ett sted å sende fra, med avkryssing for hvilke soner meldingen skal til. Sentralen er en vanlig announcer med [zones] i config. En sone er en vanlig announcer med ANNOUNCER_MESH_SECRET. Samme image, ingen mode, og en sone kan brukes alene hvis sentralen faller ut. Hver avhengighet med C-kode har aarch64-wheel i uv.lock, så det samme bildet går på en Raspberry Pi uten å kompilere noe. Sentralen syntetiserer én gang og sender ferdig WAV (POST /api/play, multipart). Sonen trenger da verken nett, edge-tts eller Pipers 670 MB for det den får – Item.wav er hele endringen i avspillingen. Køene forblir per sone: dispatcheren har egen tråd og egen kø, leverer til hver sone i hver sin tråd med timeout, og går videre. En opptatt hovedscene holder ikke igjen resepsjonen, og en sone som er nede feiler alene – synlig per sone i UI og revisjonslogg. Auth gjenbrukes: ny leverandør mesh på header X-Mesh-Key (ikke Bearer – den leses av JWT-leverandøren, som avviser alt som ikke er et JWT før neste får prøve), med én handling, play. To regler som fulgte med: - play følger aldri med «alt åpent». Uten det kunne hvem som helst POSTe en WAV til en åpen sone og få den spilt. Testen for åpen modus fanget det. - En legitimasjon som vises fram og er feil gir 401 også der gjester ellers har lov. Før slapp en feil PIN i åpen modus stille gjennom som gjest. mesh teller ikke som innlogging for folk: en sone med bare nøkkelen har fortsatt åpen side lokalt. Planlagte sendinger husker sonene sine og fordeles gjennom dispatcheren; planen ender som sent hvis minst én sone tok imot, med «hovedscene: kø #3, crew: nede» som note. Hodeløs Pi: install.sh kjører loginctl enable-linger, ellers finnes ikke lydsocketen når ingen logger inn. docs/mesh.md tar også raspi-config for jack framfor HDMI. 38 nye tester, 148 totalt. Fordelingen testes uten nett med stubbet transport; selve HTTP-en testes mot en levende werkzeug-server, så den håndrullede multiparten møter Flask sin parser. Verifisert i compose med sentral og to soner: til alle, bare til én, og én sone stoppet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
eb27a162d5
commit
c4a15799dd
17 changed files with 1486 additions and 23 deletions
|
|
@ -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=
|
||||||
|
|
|
||||||
57
AGENTS.md
57
AGENTS.md
|
|
@ -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
|
||||||
|
|
|
||||||
168
announcer.py
168
announcer.py
|
|
@ -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
48
auth.py
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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: .
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
25
config.py
25
config.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
119
docs/mesh.md
Normal 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.
|
||||||
104
index.html
104
index.html
|
|
@ -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';
|
||||||
|
|
|
||||||
17
install.sh
17
install.sh
|
|
@ -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
414
mesh.py
Normal 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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
240
tests/test_mesh.py
Normal 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"]
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue