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.
|
||||
ANNOUNCER_PUBLIC_URL=
|
||||
|
||||
# Mesh. Samme verdi på sentralen og alle soner. Tom = ingen mesh.
|
||||
# Lag en: openssl rand -hex 24
|
||||
ANNOUNCER_MESH_SECRET=
|
||||
|
||||
# --- hemmeligheter -------------------------------------------------------
|
||||
# Tom = av. Se config.example.toml og docs/crew-integration.md.
|
||||
ANNOUNCER_PIN=
|
||||
|
|
|
|||
57
AGENTS.md
57
AGENTS.md
|
|
@ -19,10 +19,12 @@ Filer:
|
|||
| `auth.py` | `Principal`, leverandørene, OIDC-rutene |
|
||||
| `config.py` | `config.toml` + miljøvariabler, standardverdier |
|
||||
| `schedule.py` | planlagte sendinger, lagret i `data/scheduled.json` |
|
||||
| `mesh.py` | sentral + soner: fordeling, transport, statuspolling |
|
||||
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
|
||||
| `config.example.toml` | kommentert mal; kopier til `config.toml` |
|
||||
| `docs/crew-integration.md` | kontrakten mot crew-panelet |
|
||||
| `tests/` | `uv run pytest` – 110 tester: auth, planlegging og HTTP-laget |
|
||||
| `docs/mesh.md` | flere anlegg: oppsett, bruk, feiltilfeller, protokoll |
|
||||
| `tests/` | `uv run pytest` – 148 tester: auth, planlegging, mesh og HTTP-laget |
|
||||
| `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker |
|
||||
| `.env.example` | bruker-ID, portbinding og hemmeligheter |
|
||||
| `compose.test.yaml` | testoppsett med dummy-IdP, se under |
|
||||
|
|
@ -203,6 +205,51 @@ veien for å prøve en endring kjapt; `uv run pytest` for testene. Men
|
|||
det er ryddet bort. Skal noe prøves med innlogging, bruk
|
||||
`compose.test.yaml`.
|
||||
|
||||
## Mesh: én sentral, flere soner
|
||||
|
||||
Brukerdokumentasjonen står i `docs/mesh.md`. Her er det som gjelder koden.
|
||||
|
||||
`mesh.py` kjenner verken Flask eller `Player`. `Dispatcher` får
|
||||
`synthesize`, `play_local` og `send` inn som funksjoner, og det er det som
|
||||
gjør at hele fordelingen testes uten nett i `tests/test_mesh.py`. Den ekte
|
||||
transporten (`send_to_zone` sin håndrullede multipart mot Flask sin parser)
|
||||
prøves i `test_routes.py` mot en levende werkzeug-server – det er den
|
||||
testen som fanger en feil i selve HTTP-en.
|
||||
|
||||
- **Sentral eller sone avgjøres av config, ikke kode.** `[zones]` satt →
|
||||
`DISPATCHER` og `POLLER` finnes, `/api/announce` tar `zones`. Ellers er
|
||||
begge `None` og alt går i lokal kø nøyaktig som før. `ANNOUNCER_MESH_SECRET`
|
||||
satt → `MeshProvider` er aktiv og `/api/play` tar imot. Begge kan være
|
||||
satt på samme maskin (sentral med eget anlegg).
|
||||
- **Egen tråd og egen kø for fordeling**, atskilt fra `Player`. Hadde
|
||||
fordelingen gått gjennom sentralens avspillingskø, ville en melding til
|
||||
resepsjonen ventet på at hovedscenen ble ferdig. Dispatcheren
|
||||
syntetiserer, leverer til hver sone i hver sin tråd (timeout per sone), og
|
||||
er ferdig. Hver sone køer selv.
|
||||
- **`Item.wav`** er hele endringen i avspillingen: `run_once()` bruker
|
||||
`item.wav or synthesize(...)`. En sone syntetiserer aldri det den får fra
|
||||
sentralen.
|
||||
- **`play` følger aldri med «alt åpent».** `Authenticator.anonymous()`
|
||||
trekker den fra. Uten det kunne hvem som helst POSTe en WAV til en åpen
|
||||
sone og få den spilt. Og en legitimasjon som vises fram og er feil (PIN,
|
||||
token, mesh-nøkkel) gir nå 401 også der gjester ellers har lov –
|
||||
`require()` sjekker `error()` først. Det var ikke slik før; en feil PIN
|
||||
i åpen modus slapp stille gjennom som gjest.
|
||||
- **`MeshProvider` teller ikke som innlogging for folk.** `Authenticator`
|
||||
skiller `providers` fra `human`; `enabled` og `/api/config`-blokka ser
|
||||
bare på de menneskelige. Ellers ville en sone med bare mesh-nøkkel krevd
|
||||
innlogging lokalt uten noen måte å logge inn på.
|
||||
- **Planlagt sending i mesh**: `Scheduled.zones` lagres med planen.
|
||||
`scheduler_tick()` fordeler via `DISPATCHER` når den finnes, og planen
|
||||
ender som `sent` hvis minst én sone tok imot (`_dispatch_finished`), med
|
||||
`summary()` som note – `hovedscene: kø #3, crew: nede`. Det lokale
|
||||
køelementet bærer *ikke* `schedule_id` i mesh-modus; fordelingen eier
|
||||
planen. Nevnes fordi det er motsatt av lokal modus.
|
||||
- **Statuspolling er én tråd** (`ZonePoller`), cachet, så N nettlesere ikke
|
||||
gir N × soner kall. Lokal sone slås opp ferskt per snapshot.
|
||||
- **Rekkefølgen i `zones`** i en forespørsel bevares i `Dispatch.zones`,
|
||||
og dermed i `summary()` og i UI-et. Testene antar det.
|
||||
|
||||
## Planlagte sendinger
|
||||
|
||||
`schedule.py` er den første delen av appen som **må** ligge på disk. Kø og
|
||||
|
|
@ -315,6 +362,14 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI
|
|||
- **Én prosess.** `main()` kjører waitress med mange tråder, ikke gunicorn
|
||||
med flere workere: `Player` og planleggeren er singletons i prosessen, og
|
||||
flere workere ville gitt flere køer som snakker i munnen på hverandre.
|
||||
- **Mesh-nøkkelen må være lik** på sentral og sone, ellers `✗ avvist` med
|
||||
`401 Feil mesh-nøkkel` på fordelingen. Og sonens `BIND_IP` må gjøre den
|
||||
nåbar fra sentralen – med NetBird er det NetBird-adressen som skal stå i
|
||||
`[zones]`.
|
||||
- **En sone uten lydsocket** (hodeløs Pi uten linger, eller feil
|
||||
`PUID`) tar imot og køer, men hvert utrop ender `failed`. Det ser riktig
|
||||
ut fra sentralen (`✓` = tatt imot) helt til man ser på sonens egen kø.
|
||||
`install.sh` slår på `loginctl enable-linger` nettopp derfor.
|
||||
- **Oppgradering:** `schedule` er en ny handling. Har et oppsett et eksplisitt
|
||||
`[roles]` i `config.toml`, får `crew` den *ikke* automatisk – eksplisitt
|
||||
konfigurasjon overstyrer standardverdiene, og da blir svaret 403 «Rollen din
|
||||
|
|
|
|||
168
announcer.py
168
announcer.py
|
|
@ -18,6 +18,8 @@ import logging
|
|||
import math
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
|
|
@ -31,6 +33,7 @@ from flask import Flask, Response, abort, jsonify, request, send_file
|
|||
|
||||
import auth
|
||||
import config
|
||||
import mesh
|
||||
import schedule as sched
|
||||
from auth import require
|
||||
|
||||
|
|
@ -84,6 +87,9 @@ DEFAULT_CHIME = CFG.app["default_chime"]
|
|||
|
||||
log = logging.getLogger("announcer")
|
||||
app = Flask(__name__, static_folder=None)
|
||||
# En WAV fra sentralen: et par MB for en normal melding. Grensen er romslig
|
||||
# bare for å stoppe det som åpenbart ikke er lyd.
|
||||
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024
|
||||
AUTH = auth.install(app, CFG)
|
||||
|
||||
|
||||
|
|
@ -335,6 +341,7 @@ class Item:
|
|||
status: str = "queued"
|
||||
error: str = ""
|
||||
schedule_id: str = "" # satt når meldingen kom fra planleggeren
|
||||
wav: Path | None = None # ferdig lyd fra sentralen (mesh); ingen syntese
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
|
|
@ -397,7 +404,8 @@ class Player:
|
|||
self._thread.start()
|
||||
|
||||
def enqueue(self, text: str, voice: str, speed: float, chime: str, sender: str,
|
||||
who: auth.Principal | None = None, schedule_id: str = "") -> Item:
|
||||
who: auth.Principal | None = None, schedule_id: str = "",
|
||||
wav: Path | None = None) -> Item:
|
||||
with self.lock:
|
||||
self.seq += 1
|
||||
item = Item(
|
||||
|
|
@ -406,6 +414,7 @@ class Player:
|
|||
subject=who.subject if who else "",
|
||||
method=who.method if who else "none",
|
||||
schedule_id=schedule_id,
|
||||
wav=wav,
|
||||
)
|
||||
self.history.append(item)
|
||||
del self.history[:-50]
|
||||
|
|
@ -460,7 +469,8 @@ class Player:
|
|||
with self.lock:
|
||||
self.current = item
|
||||
try:
|
||||
path = synthesize(item.text, item.voice, item.speed, item.chime)
|
||||
# Fra sentralen kommer lyden ferdig; da er det ingenting å lage.
|
||||
path = item.wav or synthesize(item.text, item.voice, item.speed, item.chime)
|
||||
with self.lock:
|
||||
if item.status != "preparing":
|
||||
return item # stoppet mens lyden ble laget
|
||||
|
|
@ -523,6 +533,63 @@ def _scheduled_finished(item: Item) -> None:
|
|||
player = Player(on_finished=_scheduled_finished)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mesh: sentral + soner. Finnes bare når [zones] er satt i config.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
ZONES = mesh.zones_from_config(CFG.zones)
|
||||
except ValueError as e:
|
||||
raise SystemExit(f"config: {e}") from e
|
||||
MESH_SECRET = str(CFG.get("mesh", "secret", "")).strip()
|
||||
if ZONES and not MESH_SECRET and any(not z.local for z in ZONES.values()):
|
||||
raise SystemExit("config: [zones] har eksterne soner, men ANNOUNCER_MESH_SECRET er ikke satt")
|
||||
|
||||
|
||||
def _play_local(d: mesh.Dispatch, path: Path) -> int:
|
||||
"""Sentralens eget anlegg er en sone som alle andre, bare uten HTTP."""
|
||||
item = player.enqueue(
|
||||
d.text, d.voice, d.speed, d.chime, d.sender,
|
||||
who=auth.Principal(d.subject, d.user, (), d.method, frozenset()),
|
||||
wav=path,
|
||||
)
|
||||
return item.id
|
||||
|
||||
|
||||
def _dispatch_finished(d: mesh.Dispatch) -> None:
|
||||
audit("dispatched", id=d.id, subject=d.subject, name=d.user, method=d.method,
|
||||
ip=d.sender, text=d.text, zones={n: z.status for n, z in d.zones.items()},
|
||||
error=d.error)
|
||||
if d.schedule_id:
|
||||
if d.accepted:
|
||||
schedule.mark(d.schedule_id, "sent", d.summary())
|
||||
else:
|
||||
schedule.mark(d.schedule_id, "failed", d.error or d.summary())
|
||||
|
||||
|
||||
DISPATCHER: mesh.Dispatcher | None = None
|
||||
POLLER: mesh.ZonePoller | None = None
|
||||
if ZONES:
|
||||
_timeout = float(CFG.get("mesh", "timeout_seconds", 5))
|
||||
DISPATCHER = mesh.Dispatcher(
|
||||
ZONES,
|
||||
synthesize=lambda *a: synthesize(*a),
|
||||
play_local=_play_local,
|
||||
send=lambda zone, path, d: mesh.send_to_zone(zone, path, d, secret=MESH_SECRET, timeout=_timeout),
|
||||
timeout=_timeout,
|
||||
on_finished=_dispatch_finished,
|
||||
)
|
||||
POLLER = mesh.ZonePoller(
|
||||
ZONES,
|
||||
fetch=lambda zone: mesh.fetch_zone_status(zone, secret=MESH_SECRET),
|
||||
interval=float(CFG.get("mesh", "poll_seconds", 2)),
|
||||
local_status=lambda: {
|
||||
"current": player.snapshot()["current"],
|
||||
"queued": len(player.snapshot()["queued"]),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def scheduler_tick() -> None:
|
||||
"""Ett steg: flytter forfalte meldinger inn i den vanlige køen.
|
||||
|
||||
|
|
@ -538,6 +605,25 @@ def scheduler_tick() -> None:
|
|||
audit("missed", id=item.id, subject=item.subject, name=item.user,
|
||||
method=item.method, text=item.text, at=item.at)
|
||||
for item in send:
|
||||
if DISPATCHER is not None:
|
||||
try:
|
||||
names = DISPATCHER.resolve(item.zones)
|
||||
except ValueError as e:
|
||||
schedule.mark(item.id, "failed", str(e))
|
||||
audit("scheduled_fire", id=item.id, error=str(e), subject=item.subject,
|
||||
name=item.user, method=item.method, text=item.text, at=item.at)
|
||||
continue
|
||||
d = DISPATCHER.submit(
|
||||
item.text, item.voice, item.speed, item.chime, names,
|
||||
user=item.user, subject=item.subject, method=item.method,
|
||||
sender="planlagt", schedule_id=item.id,
|
||||
)
|
||||
schedule.mark(item.id, "queued", "fordeles til " + ", ".join(names))
|
||||
log.info("planlagt #%s fordeles som #%s til %s", item.id, d.id, ", ".join(names))
|
||||
audit("scheduled_fire", id=item.id, dispatch_id=d.id, zones=names,
|
||||
subject=item.subject, name=item.user, method=item.method,
|
||||
text=item.text, voice=item.voice, at=item.at)
|
||||
continue
|
||||
queued = player.enqueue(
|
||||
item.text, item.voice, item.speed, item.chime,
|
||||
sender="planlagt",
|
||||
|
|
@ -601,6 +687,18 @@ def who(p: auth.Principal) -> dict:
|
|||
"ip": request.remote_addr or ""}
|
||||
|
||||
|
||||
def requested_zones(data: dict) -> list[str]:
|
||||
"""Sonene en forespørsel ber om. Tom liste = ikke mesh, gå i lokal kø."""
|
||||
if DISPATCHER is None:
|
||||
if data.get("zones"):
|
||||
abort(400, "Ingen soner er satt opp på denne maskinen")
|
||||
return []
|
||||
try:
|
||||
return DISPATCHER.resolve(data.get("zones"))
|
||||
except ValueError as e:
|
||||
abort(400, str(e))
|
||||
|
||||
|
||||
def parse_request() -> tuple[str, str, float, str]:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = " ".join(str(data.get("text", "")).split())
|
||||
|
|
@ -659,6 +757,8 @@ def client_config() -> Response:
|
|||
"max_days_ahead": int(CFG.get("schedule", "max_days_ahead", 30)),
|
||||
"grace_seconds": int(CFG.get("schedule", "grace_seconds", 120)),
|
||||
},
|
||||
# Tom liste = ingen mesh; da viser ikke sida noen avkryssing.
|
||||
"zones": DISPATCHER.names() if DISPATCHER else [],
|
||||
"auth": {
|
||||
**AUTH.describe(),
|
||||
"user": None if me.anonymous else me.public(),
|
||||
|
|
@ -687,7 +787,15 @@ def preview() -> Response:
|
|||
def announce() -> Response:
|
||||
me = require("announce")
|
||||
text, voice, speed, chime = parse_request()
|
||||
zones = requested_zones(request.get_json(silent=True) or {})
|
||||
sender = request.remote_addr or "?"
|
||||
if zones:
|
||||
d = DISPATCHER.submit(text, voice, speed, chime, zones, user=me.name,
|
||||
subject=me.subject, method=me.method, sender=sender)
|
||||
log.info("fordeling #%s fra %s til %s: %s", d.id, me.name or sender, ", ".join(zones), text)
|
||||
audit("announce", **who(me), dispatch_id=d.id, zones=zones, text=text,
|
||||
voice=voice, speed=speed, chime=chime)
|
||||
return jsonify({"dispatch": d.public(show_ip=me.can("clear")), "id": d.id})
|
||||
item = player.enqueue(text, voice, speed, chime, sender, me)
|
||||
log.info("kø #%d fra %s (%s): %s", item.id, item.user or sender, sender, text)
|
||||
audit("announce", **who(me), id=item.id, text=text, voice=voice,
|
||||
|
|
@ -695,6 +803,43 @@ def announce() -> Response:
|
|||
return jsonify(item.public(show_ip=me.can("clear")))
|
||||
|
||||
|
||||
@app.post("/api/play")
|
||||
def play() -> Response:
|
||||
"""Sonens side av mesh: ta imot en ferdig WAV fra sentralen og køe den."""
|
||||
me = require("play")
|
||||
f = request.files.get("wav")
|
||||
if f is None:
|
||||
abort(400, "Mangler wav")
|
||||
try:
|
||||
meta = json.loads(request.form.get("meta") or "{}")
|
||||
except ValueError:
|
||||
abort(400, "Ugyldig meta")
|
||||
if not isinstance(meta, dict):
|
||||
abort(400, "Ugyldig meta")
|
||||
origin = re.sub(r"[^A-Za-z0-9_-]", "", str(meta.get("origin_id", "")))[:32] or secrets.token_hex(4)
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = CACHE_DIR / f"mesh-{origin}.wav"
|
||||
f.save(path)
|
||||
try:
|
||||
with wave.open(str(path)) as w:
|
||||
if w.getnframes() == 0:
|
||||
raise ValueError("tom fil")
|
||||
except (wave.Error, EOFError, ValueError) as e:
|
||||
path.unlink(missing_ok=True)
|
||||
abort(400, f"Ikke en gyldig WAV ({e})")
|
||||
text = " ".join(str(meta.get("text", "")).split())[:MAX_CHARS] or "(uten tekst)"
|
||||
origin_who = auth.Principal(
|
||||
str(meta.get("subject") or "mesh"), str(meta.get("user") or "sentral"),
|
||||
(), str(meta.get("method") or "mesh"), frozenset(),
|
||||
)
|
||||
item = player.enqueue(text, str(meta.get("voice") or "?"), 1.0, "none",
|
||||
request.remote_addr or "?", who=origin_who, wav=path)
|
||||
log.info("mesh: kø #%d fra sentralen (%s): %s", item.id, origin_who.name, text)
|
||||
audit("play", **who(me), id=item.id, origin_id=origin, text=text,
|
||||
user=origin_who.name, user_subject=origin_who.subject)
|
||||
return jsonify({"id": item.id})
|
||||
|
||||
|
||||
@app.post("/api/schedule")
|
||||
def schedule_add() -> Response:
|
||||
me = require("schedule")
|
||||
|
|
@ -704,15 +849,16 @@ def schedule_add() -> Response:
|
|||
at = float(data["at"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
abort(400, "Mangler eller ugyldig tidspunkt")
|
||||
zones = requested_zones(data)
|
||||
try:
|
||||
item = schedule.add(at=at, text=text, voice=voice, speed=speed, chime=chime,
|
||||
user=me.name, subject=me.subject, method=me.method)
|
||||
user=me.name, subject=me.subject, method=me.method, zones=zones)
|
||||
except ValueError as e:
|
||||
abort(400, str(e))
|
||||
log.info("planlagt #%s kl. %s av %s: %s", item.id,
|
||||
time.strftime("%Y-%m-%d %H:%M", time.localtime(at)), me.name or "anonym", text)
|
||||
audit("schedule", **who(me), id=item.id, at=at, text=text, voice=voice,
|
||||
speed=speed, chime=chime)
|
||||
speed=speed, chime=chime, zones=zones)
|
||||
return jsonify(item.public())
|
||||
|
||||
|
||||
|
|
@ -754,11 +900,15 @@ def status() -> Response:
|
|||
# Lukket bak «view»: køen inneholder meldingstekstene og IP-ene til dem
|
||||
# som har sendt noe, og det er ikke noe alle utenfra skal få se.
|
||||
me = require("view")
|
||||
return jsonify({
|
||||
out = {
|
||||
**player.snapshot(show_ip=me.can("clear")),
|
||||
"scheduled": schedule.snapshot(),
|
||||
"now": time.time(), # klienten regner «om 20 min» mot serverens klokke
|
||||
})
|
||||
}
|
||||
if DISPATCHER is not None:
|
||||
out["zones"] = POLLER.snapshot()
|
||||
out["dispatches"] = DISPATCHER.snapshot(show_ip=me.can("clear"))
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@app.errorhandler(400)
|
||||
|
|
@ -832,6 +982,12 @@ def main() -> None:
|
|||
venter = len(schedule.snapshot()["pending"])
|
||||
player.start()
|
||||
start_scheduler()
|
||||
if DISPATCHER is not None:
|
||||
DISPATCHER.start()
|
||||
POLLER.start()
|
||||
log.info("mesh: sentral for %s", ", ".join(ZONES))
|
||||
elif MESH_SECRET:
|
||||
log.info("mesh: sone, tar imot fra sentralen")
|
||||
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
|
||||
args.host, args.port, methods, SINK or "standard", venter)
|
||||
# waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere
|
||||
|
|
|
|||
48
auth.py
48
auth.py
|
|
@ -214,6 +214,29 @@ class PinProvider(Provider):
|
|||
return {"method": "pin", "label": self.label, "hint": self.hint}
|
||||
|
||||
|
||||
class MeshProvider(Provider):
|
||||
"""Sentralen som leverer en WAV til denne sonen.
|
||||
|
||||
Egen header, ikke Authorization: Bearer – JwtProvider ville avvist en
|
||||
nøkkel som ikke er et JWT før neste leverandør fikk prøve.
|
||||
"""
|
||||
|
||||
name = "mesh"
|
||||
|
||||
def __init__(self, cfg: Config) -> None:
|
||||
self.secret = str(cfg.get("mesh", "secret", "")).strip()
|
||||
self.cfg = cfg
|
||||
|
||||
def authenticate(self, req) -> Principal | None: # noqa: ANN001
|
||||
given = req.headers.get("X-Mesh-Key", "").strip()
|
||||
if not given:
|
||||
return None
|
||||
if not hmac.compare_digest(given, self.secret):
|
||||
raise AuthError("Feil mesh-nøkkel")
|
||||
roles = ("mesh",)
|
||||
return Principal("mesh", "sentral", roles, "mesh", self.cfg.permissions(roles))
|
||||
|
||||
|
||||
class JwtProvider(Provider):
|
||||
name = "jwt"
|
||||
|
||||
|
|
@ -372,7 +395,7 @@ class OidcProvider(Provider):
|
|||
return {"method": "oidc", "label": self.label, "login_url": "/auth/login"}
|
||||
|
||||
|
||||
PROVIDERS = {"pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider}
|
||||
PROVIDERS = {"mesh": MeshProvider, "pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -412,7 +435,10 @@ class Authenticator:
|
|||
self.providers: list[Provider] = [
|
||||
PROVIDERS[name](cfg) for name in cfg.active_providers()
|
||||
]
|
||||
self.enabled = bool(self.providers)
|
||||
# mesh er maskin-til-maskin og teller ikke som innlogging for folk:
|
||||
# en sone som bare har mesh-nøkkel skal fortsatt ha åpen side lokalt.
|
||||
self.human: list[Provider] = [p for p in self.providers if p.name != "mesh"]
|
||||
self.enabled = bool(self.human)
|
||||
|
||||
def provider(self, name: str) -> Provider | None:
|
||||
return next((p for p in self.providers if p.name == name), None)
|
||||
|
|
@ -426,8 +452,11 @@ class Authenticator:
|
|||
veggen uten innlogging."""
|
||||
if not self.enabled:
|
||||
# Tomt navn: da står køen og loggen som før, uten en «bruker» som
|
||||
# i praksis bare betyr «hvem som helst».
|
||||
return Principal("anonymous", "", ("anonymous",), "none", frozenset(ACTIONS))
|
||||
# i praksis bare betyr «hvem som helst». Og aldri «play»: den er
|
||||
# for en sentral med mesh-nøkkel, ikke for hvem som helst som
|
||||
# finner en åpen sone og vil spille lyd på den.
|
||||
return Principal("anonymous", "", ("anonymous",), "none",
|
||||
frozenset(ACTIONS) - {"play"})
|
||||
roles = ("anonymous",)
|
||||
return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles))
|
||||
|
||||
|
|
@ -479,7 +508,7 @@ class Authenticator:
|
|||
def describe(self) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"methods": [p.describe() for p in self.providers],
|
||||
"methods": [p.describe() for p in self.human],
|
||||
"logout_url": "/auth/logout" if self.enabled else None,
|
||||
}
|
||||
|
||||
|
|
@ -507,16 +536,21 @@ def require(action: str) -> Principal:
|
|||
hvem det er men rollen mangler handlingen.
|
||||
"""
|
||||
p = current()
|
||||
if error():
|
||||
# Noen viste fram en PIN, et token eller en mesh-nøkkel som ikke
|
||||
# holdt. Det er en feil, ikke en anonym bruker – også på en åpen
|
||||
# sone, der anonym ellers hadde fått lov.
|
||||
abort(401, error())
|
||||
if p.can(action):
|
||||
return p
|
||||
if p.anonymous:
|
||||
abort(401, error() or _login_hint())
|
||||
abort(401, _login_hint())
|
||||
abort(403, f"Rollen din ({', '.join(p.roles) or 'ingen'}) har ikke lov til dette.")
|
||||
|
||||
|
||||
def _login_hint() -> str:
|
||||
assert _auth is not None
|
||||
names = {p.name for p in _auth.providers}
|
||||
names = {p.name for p in _auth.human}
|
||||
if names == {"pin"}:
|
||||
return "Feil eller manglende PIN."
|
||||
if "oidc" in names:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,13 @@
|
|||
#
|
||||
# docker compose -f compose.test.yaml up --build
|
||||
#
|
||||
# http://localhost:8091 announcer med PIN, JWT og OIDC slått på
|
||||
# http://localhost:8091 sentralen: PIN, JWT og OIDC slått på, soner «her», «a», «b»
|
||||
# http://localhost:9000 dummy-IdP som står for crew-panelet
|
||||
# http://localhost:8093 sone-a (uten lyd – bare kø, så fordeling kan ses)
|
||||
# http://localhost:8094 sone-b (samme)
|
||||
#
|
||||
# Stopp sone-b og send til alle, så ser du «nede» på den og «kø» på resten:
|
||||
# docker compose -f compose.test.yaml stop sone-b
|
||||
#
|
||||
# Egen fil og eget prosjektnavn, så den ikke kan forveksles med eller
|
||||
# forstyrre den som kjører i drift. Porten er 8091 av samme grunn.
|
||||
|
|
@ -27,13 +32,39 @@ services:
|
|||
ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte
|
||||
ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker
|
||||
ANNOUNCER_PUBLIC_URL: http://localhost:8091
|
||||
ANNOUNCER_MESH_SECRET: mesh-test-nokkel-kun-for-docker
|
||||
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
|
||||
TZ: ${TZ:-Europe/Oslo}
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./data:/data
|
||||
- ${XDG_RUNTIME_DIR:-/run/user/1000}:${XDG_RUNTIME_DIR:-/run/user/1000}
|
||||
depends_on: [idp]
|
||||
depends_on: [idp, sone-a, sone-b]
|
||||
|
||||
# Soner: samme image, bare mesh-nøkkelen satt. Ingen lyd montert inn, så
|
||||
# avspilling feiler der – med vilje, det er fordelingen som skal ses.
|
||||
# tmpfs på /data, så ingen rot-eide mapper dukker opp på verten.
|
||||
sone-a: &sone
|
||||
build: .
|
||||
ports: ["127.0.0.1:8093:8090"]
|
||||
user: "${PUID:-1000}:${PGID:-1000}"
|
||||
environment: &sone-env
|
||||
ANNOUNCER_CONFIG: /finnes-ikke.toml
|
||||
ANNOUNCER_MESH_SECRET: mesh-test-nokkel-kun-for-docker
|
||||
ANNOUNCER_VOICES: /app/voices
|
||||
ANNOUNCER_TITLE: "Sone A"
|
||||
TZ: ${TZ:-Europe/Oslo}
|
||||
volumes:
|
||||
- .:/app
|
||||
tmpfs:
|
||||
- /data:mode=1777
|
||||
|
||||
sone-b:
|
||||
<<: *sone
|
||||
ports: ["127.0.0.1:8094:8090"]
|
||||
environment:
|
||||
<<: *sone-env
|
||||
ANNOUNCER_TITLE: "Sone B"
|
||||
|
||||
idp:
|
||||
build: .
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ services:
|
|||
ANNOUNCER_JWT_SECRET: ${ANNOUNCER_JWT_SECRET:-}
|
||||
ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-}
|
||||
ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-}
|
||||
ANNOUNCER_MESH_SECRET: ${ANNOUNCER_MESH_SECRET:-}
|
||||
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
|
||||
# Planlagte sendinger lagres som absolutt tid, så TZ påvirker dem ikke.
|
||||
# Men uten den står loggen i UTC mens du står i CEST, og da blir det
|
||||
|
|
|
|||
|
|
@ -25,6 +25,26 @@ quick_phrases = [
|
|||
"Husk å rydde rundt plassen din. Takk!",
|
||||
]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mesh: én sentral som fordeler til flere soner
|
||||
# --------------------------------------------------------------------------
|
||||
# Settes BARE på sentralen. Navn -> "local" (eget anlegg) eller URL til en
|
||||
# sone. Sonene er vanlige announcere med ANNOUNCER_MESH_SECRET satt i .env –
|
||||
# samme hemmelighet som sentralen. En sone kan brukes alene også.
|
||||
# Sentralen syntetiserer én gang og sender ferdig WAV; sonen trenger
|
||||
# verken nett eller stemmer for det den får derfra.
|
||||
#
|
||||
# [zones]
|
||||
# her = "local"
|
||||
# hovedscene = "http://10.20.70.11:8090"
|
||||
# resepsjon = "http://10.20.70.12:8090"
|
||||
# crew = "http://10.20.70.13:8090"
|
||||
|
||||
[mesh]
|
||||
# secret settes som ANNOUNCER_MESH_SECRET i .env, ikke her
|
||||
timeout_seconds = 5
|
||||
poll_seconds = 2
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8090
|
||||
|
|
@ -123,6 +143,7 @@ label = "Logg inn med crew-kontoen"
|
|||
# preview forhåndslytte (synteser lyd)
|
||||
# announce sende ut på anlegget
|
||||
# schedule planlegge en sending fram i tid, og avbryte sine egne
|
||||
# play ta imot ferdig lyd fra sentralen (mesh). Bare rollen «mesh».
|
||||
# stop stoppe det som spilles
|
||||
# clear tømme køen
|
||||
# "*" betyr alt.
|
||||
|
|
@ -132,3 +153,5 @@ label = "Logg inn med crew-kontoen"
|
|||
anonymous = []
|
||||
crew = ["view", "preview", "announce", "schedule"]
|
||||
admin = ["*"]
|
||||
# Sentralen, når den snakker med denne maskina som sone. Ikke endre.
|
||||
mesh = ["play", "view"]
|
||||
|
|
|
|||
25
config.py
25
config.py
|
|
@ -26,7 +26,8 @@ from typing import Any, Callable
|
|||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
# Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse.
|
||||
ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear")
|
||||
# «play» er bare for mesh: en sentral som leverer en ferdig WAV til en sone.
|
||||
ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear", "play")
|
||||
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"app": {
|
||||
|
|
@ -72,6 +73,15 @@ DEFAULTS: dict[str, Any] = {
|
|||
# Hvor ofte vi ser etter forfalte sendinger.
|
||||
"tick_seconds": 20,
|
||||
},
|
||||
# Mesh: én sentral som fordeler til flere soner. [zones] settes bare på
|
||||
# sentralen (navn -> URL, eller "local" for eget anlegg); hemmeligheten
|
||||
# settes på sentralen OG hver sone. Begge tomme = ingen mesh, alt som før.
|
||||
"zones": {},
|
||||
"mesh": {
|
||||
"secret": "",
|
||||
"timeout_seconds": 5,
|
||||
"poll_seconds": 2,
|
||||
},
|
||||
"auth": {
|
||||
# Tom liste = alt åpent. Se active_providers(): en leverandør er også
|
||||
# aktiv når den er ferdig konfigurert, så det holder å sette
|
||||
|
|
@ -117,6 +127,8 @@ DEFAULTS: dict[str, Any] = {
|
|||
"anonymous": [],
|
||||
"crew": ["view", "preview", "announce", "schedule"],
|
||||
"admin": ["*"],
|
||||
# Sentralen når den snakker med en sone. view er for statuspollingen.
|
||||
"mesh": ["play", "view"],
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +162,7 @@ ENV: dict[str, tuple[tuple[str, str], Callable[[str], Any]]] = {
|
|||
"ANNOUNCER_SECRET_KEY": (("server", "secret_key"), str),
|
||||
"ANNOUNCER_PUBLIC_URL": (("server", "public_url"), str),
|
||||
"ANNOUNCER_AUTH": (("auth", "providers"), _list),
|
||||
"ANNOUNCER_MESH_SECRET": (("mesh", "secret"), str),
|
||||
"ANNOUNCER_PIN": (("auth.pin", "pin"), str),
|
||||
"ANNOUNCER_PIN_ROLE": (("auth.pin", "role"), str),
|
||||
"ANNOUNCER_JWT_ISSUER": (("auth.jwt", "issuer"), str),
|
||||
|
|
@ -232,7 +245,7 @@ class Config:
|
|||
"""
|
||||
listed = [str(p).strip().lower() for p in self.get("auth", "providers", [])]
|
||||
out: list[str] = []
|
||||
for name in ("pin", "jwt", "oidc"):
|
||||
for name in ("mesh", "pin", "jwt", "oidc"):
|
||||
if name in listed or self.configured(name):
|
||||
out.append(name)
|
||||
# Rekkefølgen i config vinner for de som er nevnt der.
|
||||
|
|
@ -247,8 +260,16 @@ class Config:
|
|||
return bool(s.get("secret") or s.get("jwks_url"))
|
||||
if provider == "oidc":
|
||||
return bool(s.get("client_id") and (s.get("issuer") or s.get("authorization_endpoint")))
|
||||
if provider == "mesh":
|
||||
return bool(str(self.get("mesh", "secret", "")).strip())
|
||||
return False
|
||||
|
||||
@property
|
||||
def zones(self) -> dict[str, str]:
|
||||
"""Sonene på en sentral, i konfigurert rekkefølge. Tom = ingen mesh."""
|
||||
raw = self.section("zones")
|
||||
return {str(k): str(v).strip() for k, v in raw.items() if str(v).strip()}
|
||||
|
||||
def permissions(self, roles: tuple[str, ...]) -> frozenset[str]:
|
||||
"""Rollenavn -> handlinger. "*" betyr alt."""
|
||||
out: set[str] = set()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ subtitle = "Docker-oppsett – PIN, JWT og OIDC er alle slått på"
|
|||
accent = "#f5a623"
|
||||
quick_phrases = ["Dette er en test", "Pizzaen er kommet. Hent i kantina!"]
|
||||
|
||||
# Mesh: denne er sentral for sitt eget anlegg og to soner i compose-nettet.
|
||||
# Nøkkelen settes som ANNOUNCER_MESH_SECRET i compose.test.yaml.
|
||||
[zones]
|
||||
her = "local"
|
||||
a = "http://sone-a:8090"
|
||||
b = "http://sone-b:8090"
|
||||
|
||||
[auth]
|
||||
providers = ["oidc", "jwt", "pin"]
|
||||
|
||||
|
|
|
|||
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.cancelled .s, ul.list li.missed .s { color: var(--warn); }
|
||||
.now .t { color: var(--muted); font-size: 13px; }
|
||||
.zones { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; align-items: center; }
|
||||
.zones .zl { color: var(--muted); font-size: 13px; text-transform: uppercase; letter-spacing: .06em; margin-right: 4px; }
|
||||
.zones label { display: inline-flex; align-items: center; gap: 6px; margin: 0; text-transform: none; letter-spacing: 0;
|
||||
font-size: 14px; color: var(--fg); background: var(--panel2); border: 1px solid var(--line); border-radius: 999px;
|
||||
padding: 6px 12px; cursor: pointer; }
|
||||
.zones label:has(input:checked) { border-color: var(--accent); }
|
||||
.zones input { accent-color: var(--accent); }
|
||||
ul.list li.zone { align-items: center; }
|
||||
ul.list li.zone .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); flex: 0 0 8px; }
|
||||
ul.list li.zone.down .dot { background: var(--danger); }
|
||||
ul.list li.zone.busy .dot { animation: pulse 1.2s infinite; }
|
||||
ul.list li.zone .zn { font-weight: 600; min-width: 90px; }
|
||||
.badge { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line);
|
||||
color: var(--muted); margin-left: 4px; white-space: nowrap; }
|
||||
.badge.queued { border-color: var(--ok); color: var(--ok); }
|
||||
.badge.unreachable, .badge.rejected, .badge.failed { border-color: var(--danger); color: var(--danger); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -121,6 +137,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zones hidden" id="zones"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" id="btnPreview">▶ Forhåndslytt</button>
|
||||
<button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button>
|
||||
|
|
@ -148,6 +166,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="now idle" id="now"></div>
|
||||
<ul class="list hidden" id="zonelist"></ul>
|
||||
<ul class="list" id="queue"></ul>
|
||||
</div>
|
||||
|
||||
|
|
@ -163,10 +182,37 @@ const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#c
|
|||
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
|
||||
let cfg = {}, can = [], previewedKey = null, pollTimer = null, me = null, skew = 0;
|
||||
|
||||
// Nøkkelen for «har du hørt denne?» er lyden – tekst, stemme, hastighet,
|
||||
// jingle. Sonene er ikke med: å bytte sone endrer ikke det du hørte.
|
||||
function key() { return JSON.stringify(payload()); }
|
||||
function payload() {
|
||||
return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value };
|
||||
}
|
||||
function hasZones() { return !!(cfg.zones && cfg.zones.length); }
|
||||
function selectedZones() {
|
||||
return [...document.querySelectorAll('#zones input:checked')].map(i => i.value);
|
||||
}
|
||||
function sendPayload() {
|
||||
const p = payload();
|
||||
if (hasZones()) p.zones = selectedZones();
|
||||
return p;
|
||||
}
|
||||
function renderZones(names) {
|
||||
const box = $('#zones');
|
||||
box.classList.toggle('hidden', !names.length);
|
||||
if (!names.length) return;
|
||||
let saved = null;
|
||||
try { saved = JSON.parse(localStorage.getItem('zones') || 'null'); } catch {}
|
||||
const head = document.createElement('span'); head.className = 'zl'; head.textContent = 'Til';
|
||||
box.replaceChildren(head, ...names.map(name => {
|
||||
const l = document.createElement('label');
|
||||
const i = document.createElement('input'); i.type = 'checkbox'; i.value = name;
|
||||
i.checked = !Array.isArray(saved) || saved.includes(name); // alle valgt som standard
|
||||
i.onchange = () => localStorage.setItem('zones', JSON.stringify(selectedZones()));
|
||||
l.append(i, document.createTextNode(name));
|
||||
return l;
|
||||
}));
|
||||
}
|
||||
function headers(json) {
|
||||
const h = json ? { 'Content-Type': 'application/json' } : {};
|
||||
// PIN-en er den eneste legitimasjonen som ligger i nettleseren; OIDC og
|
||||
|
|
@ -262,6 +308,7 @@ function applyConfig(c) {
|
|||
if (a.accent) document.documentElement.style.setProperty('--accent', a.accent);
|
||||
text.placeholder = a.message_placeholder || '';
|
||||
$('#max').textContent = c.max_chars; text.maxLength = c.max_chars;
|
||||
renderZones(c.zones || []);
|
||||
|
||||
$('#chips').replaceChildren(...(a.quick_phrases || []).map(q => {
|
||||
const b = document.createElement('button');
|
||||
|
|
@ -360,9 +407,10 @@ $('#btnSchedule').addEventListener('click', async () => {
|
|||
const at = atEpoch();
|
||||
if (!at) return say('Velg et tidspunkt først.', 'err');
|
||||
if (at < serverNow()) return say('Tidspunktet er passert.', 'err');
|
||||
if (hasZones() && !selectedZones().length) return say('Velg minst én sone.', 'err');
|
||||
$('#btnSchedule').disabled = true;
|
||||
try {
|
||||
const item = await (await api('/api/schedule', { ...payload(), at })).json();
|
||||
const item = await (await api('/api/schedule', { ...sendPayload(), at })).json();
|
||||
say(`Planlagt ${new Date(item.at * 1000).toLocaleString('no', {
|
||||
weekday: 'short', hour: '2-digit', minute: '2-digit' })}.`, 'ok');
|
||||
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
|
||||
|
|
@ -409,10 +457,12 @@ btnPreview.addEventListener('click', async () => {
|
|||
});
|
||||
|
||||
btnAnnounce.addEventListener('click', async () => {
|
||||
if (hasZones() && !selectedZones().length) return say('Velg minst én sone.', 'err');
|
||||
btnAnnounce.disabled = true;
|
||||
try {
|
||||
const item = await (await api('/api/announce', payload())).json();
|
||||
say(`Lagt i kø som #${item.id}.`, 'ok');
|
||||
const item = await (await api('/api/announce', sendPayload())).json();
|
||||
if (item.dispatch) say(`Sendes til ${Object.keys(item.dispatch.zones).join(', ')}.`, 'ok');
|
||||
else say(`Lagt i kø som #${item.id}.`, 'ok');
|
||||
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
|
||||
audio.pause(); audio.classList.remove('show');
|
||||
poll();
|
||||
|
|
@ -462,10 +512,56 @@ function schedLi(it) {
|
|||
return l;
|
||||
}
|
||||
|
||||
const ZONE_BADGE = { queued: '✓', sending: '…', unreachable: '✗ nede', rejected: '✗ avvist', failed: '✗ feilet' };
|
||||
function zoneLi(name, z) {
|
||||
const l = document.createElement('li');
|
||||
const busy = z.reachable && z.current;
|
||||
l.className = 'zone' + (z.reachable ? (busy ? ' busy' : '') : ' down');
|
||||
const dot = document.createElement('span'); dot.className = 'dot';
|
||||
const zn = document.createElement('span'); zn.className = 'zn'; zn.textContent = name;
|
||||
const x = document.createElement('span'); x.className = 'x';
|
||||
const st = document.createElement('span'); st.className = 's';
|
||||
if (!z.reachable) { x.textContent = ''; st.textContent = 'nede' + (z.error ? ' · ' + z.error : ''); }
|
||||
else if (z.current) {
|
||||
x.textContent = z.current.text;
|
||||
st.textContent = (z.current.status === 'preparing' ? 'lager lyd' : 'spiller') + (z.queued ? ` · ${z.queued} i kø` : '');
|
||||
} else { x.textContent = (cfg.app && cfg.app.idle_text) || ''; x.style.color = 'var(--muted)';
|
||||
st.textContent = z.queued ? `${z.queued} i kø` : ''; }
|
||||
l.append(dot, zn, x, st);
|
||||
return l;
|
||||
}
|
||||
function dispLi(d) {
|
||||
const l = document.createElement('li');
|
||||
const zs = Object.values(d.zones).map(z => z.status);
|
||||
l.className = zs.every(s => s === 'queued') ? 'done' : (zs.some(s => s === 'queued') ? '' : 'failed');
|
||||
l.innerHTML = '<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() {
|
||||
const now = $('#now');
|
||||
try {
|
||||
const s = await (await req('/api/status', { headers: headers(false) })).json();
|
||||
// Mesh: én linje per sone i stedet for «nå spiller»-boksen, og
|
||||
// fordelingene med sonemerker i stedet for den lokale historikken.
|
||||
const meshMode = !!s.zones;
|
||||
$('#zonelist').classList.toggle('hidden', !meshMode);
|
||||
now.classList.toggle('hidden', meshMode);
|
||||
if (meshMode) {
|
||||
$('#zonelist').replaceChildren(...Object.entries(s.zones).map(([n, z]) => zoneLi(n, z)));
|
||||
$('#history').replaceChildren(...(s.dispatches || []).map(dispLi));
|
||||
}
|
||||
if (s.now) skew = s.now - Date.now() / 1000;
|
||||
const sc = s.scheduled || { pending: [], done: [] };
|
||||
const rows = [...sc.pending, ...sc.done];
|
||||
|
|
@ -481,7 +577,7 @@ async function poll() {
|
|||
}
|
||||
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
|
||||
$('#queue').replaceChildren(...s.queued.map(li));
|
||||
$('#history').replaceChildren(...s.history.map(li));
|
||||
if (!meshMode) $('#history').replaceChildren(...s.history.map(li));
|
||||
} catch (e) {
|
||||
if (e.status === 401 || e.status === 403) {
|
||||
now.className = 'now idle';
|
||||
|
|
|
|||
17
install.sh
17
install.sh
|
|
@ -12,6 +12,7 @@
|
|||
# ANNOUNCER_PORT=8090 ./install.sh annen port
|
||||
# ANNOUNCER_TITLE="Utrop" ./install.sh annen tittel
|
||||
# BIND_IP=127.0.0.1 ./install.sh bare maskina selv
|
||||
# ANNOUNCER_MESH_SECRET=… ./install.sh sone i et mesh (samme nøkkel som sentralen)
|
||||
#
|
||||
# Kan kjøres på nytt; da bygges bildet om og containeren startes om. En .env
|
||||
# som finnes blir ikke overskrevet.
|
||||
|
|
@ -93,11 +94,22 @@ ANNOUNCER_PIN=${ANNOUNCER_PIN:-}
|
|||
ANNOUNCER_JWT_SECRET=${ANNOUNCER_JWT_SECRET:-}
|
||||
ANNOUNCER_OIDC_CLIENT_SECRET=${ANNOUNCER_OIDC_CLIENT_SECRET:-}
|
||||
ANNOUNCER_SECRET_KEY=
|
||||
ANNOUNCER_MESH_SECRET=${ANNOUNCER_MESH_SECRET:-}
|
||||
ENV
|
||||
chmod 600 .env # PIN og nøkler står i klartekst
|
||||
echo "skrev .env (PUID=$(id -u), BIND_IP=$BIND_IP)"
|
||||
fi
|
||||
|
||||
step "Lyd uten innlogging"
|
||||
# Lydsocketen ligger i /run/user/<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"
|
||||
mkdir -p data/{voices,cache,chimes,logs,instance}
|
||||
[[ -f data/chimes/README.txt ]] || cp -n chimes/README.txt data/chimes/ 2>/dev/null || true
|
||||
|
|
@ -164,6 +176,11 @@ Tips for en laptop på arrangementet:
|
|||
sudo sed -i 's/^#\?HandleLidSwitch=.*/HandleLidSwitch=ignore/' /etc/systemd/logind.conf
|
||||
sudo systemctl restart systemd-logind
|
||||
|
||||
Mesh (flere anlegg): sett [zones] i data/config.toml på sentralen og samme
|
||||
ANNOUNCER_MESH_SECRET i .env på sentralen og hver sone. Se docs/mesh.md.
|
||||
På en Pi med både HDMI og jack: velg lydutgang med sudo raspi-config
|
||||
(System Options -> Audio), ellers går lyden til skjermen som ikke er der.
|
||||
|
||||
Drift:
|
||||
docker compose logs -f følg loggen
|
||||
docker compose restart etter endringer i data/config.toml
|
||||
|
|
|
|||
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)
|
||||
status: str = "pending" # se STATUSES
|
||||
note: str = ""
|
||||
zones: list[str] = field(default_factory=list) # tom = lokalt / alle
|
||||
|
||||
def public(self) -> dict:
|
||||
d = asdict(self)
|
||||
|
|
@ -118,7 +119,8 @@ class Schedule:
|
|||
|
||||
# -- endringer ---------------------------------------------------------
|
||||
def add(self, *, at: float, text: str, voice: str, speed: float, chime: str,
|
||||
user: str = "", subject: str = "", method: str = "none") -> Scheduled:
|
||||
user: str = "", subject: str = "", method: str = "none",
|
||||
zones: list[str] | None = None) -> Scheduled:
|
||||
"""Legger til en melding. Kaster ValueError på et ubrukelig tidspunkt."""
|
||||
at = float(at)
|
||||
now = time.time()
|
||||
|
|
@ -129,6 +131,7 @@ class Schedule:
|
|||
item = Scheduled(
|
||||
id=secrets.token_hex(4), at=at, text=text, voice=voice, speed=speed,
|
||||
chime=chime, user=user, subject=subject, method=method,
|
||||
zones=[str(z) for z in (zones or [])],
|
||||
)
|
||||
with self._lock:
|
||||
self.items.append(item)
|
||||
|
|
|
|||
|
|
@ -72,7 +72,21 @@ def test_uten_konfig_er_alt_apent(tmp_path):
|
|||
c, a = build(tmp_path)
|
||||
assert a.enabled is False
|
||||
for action in config.ACTIONS:
|
||||
if action == "play":
|
||||
continue
|
||||
assert c.get(f"/do/{action}").status_code == 200, action
|
||||
# Det ene unntaket: «play» er for en sentral med mesh-nøkkel. En åpen
|
||||
# sone skal ikke spille lyd for hvem som helst som finner den.
|
||||
assert c.get("/do/play").status_code == 401
|
||||
|
||||
|
||||
def test_feil_legitimasjon_er_feil_ogsa_pa_apen_sone(tmp_path):
|
||||
"""En PIN som vises fram og er feil, skal avvises – ikke behandles som
|
||||
gjest fordi gjester tilfeldigvis har lov."""
|
||||
c, _ = build(tmp_path, '[mesh]\nsecret = "riktig"\n')
|
||||
assert c.get("/do/view").status_code == 200 # gjest: lov
|
||||
assert c.get("/do/view", headers={"X-Mesh-Key": "feil"}).status_code == 401 # feil nøkkel: nei
|
||||
assert c.get("/do/play", headers={"X-Mesh-Key": "riktig"}).status_code == 200
|
||||
|
||||
|
||||
def test_tom_providers_liste_er_fortsatt_apen(tmp_path):
|
||||
|
|
|
|||
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()
|
||||
assert "PUID" in str(e.value) and "chown" in str(e.value)
|
||||
locked.chmod(0o700)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mesh gjennom HTTP: sonens /api/play, sentralens fordeling, og ekte transport
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
import mesh # noqa: E402
|
||||
|
||||
MESH_CFG = '[mesh]\nsecret = "mesh-hemmelighet-for-test"\n'
|
||||
KEY = {"X-Mesh-Key": "mesh-hemmelighet-for-test"}
|
||||
|
||||
|
||||
def play_body(tmp_path, meta: dict | None = None):
|
||||
wav = tiny_wav(tmp_path / "inn.wav")
|
||||
return {"wav": (wav.open("rb"), "inn.wav"),
|
||||
"meta": json.dumps(meta or {"origin_id": "d1", "text": "Fra sentralen", "user": "Kari",
|
||||
"subject": "jwt:7", "method": "jwt", "voice": "finn"})}
|
||||
|
||||
|
||||
def test_play_krever_mesh_nokkel(c, tmp_path):
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
assert c.post("/api/play", data=play_body(tmp_path)).status_code == 401
|
||||
r = c.post("/api/play", data=play_body(tmp_path), headers={"X-Mesh-Key": "feil"})
|
||||
assert r.status_code == 401 and r.get_json()["error"] == "Feil mesh-nøkkel"
|
||||
|
||||
|
||||
def test_play_uten_mesh_oppsett_er_stengt(c):
|
||||
"""Ingen leverandør gir play, så 401 selv om endepunktet finnes."""
|
||||
assert c.post("/api/play", data=play_body(_TMP)).status_code == 401
|
||||
|
||||
|
||||
def test_mesh_nokkel_apner_ikke_sida_for_folk(c, tmp_path):
|
||||
"""En sone med bare mesh-nøkkel skal fortsatt ha åpen side lokalt."""
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
d = c.get("/api/config").get_json()["auth"]
|
||||
assert d["enabled"] is False and d["methods"] == []
|
||||
assert c.post("/api/announce", json={"text": "lokalt"}).status_code == 200
|
||||
|
||||
|
||||
def test_play_koer_wav_uten_syntese(c, tmp_path, monkeypatch):
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
def aldri(*a):
|
||||
raise AssertionError("sonen skal ikke syntetisere")
|
||||
monkeypatch.setattr(announcer, "synthesize", aldri)
|
||||
|
||||
r = c.post("/api/play", data=play_body(tmp_path), headers=KEY)
|
||||
assert r.status_code == 200
|
||||
item = announcer.player.history[0]
|
||||
assert r.get_json() == {"id": item.id}
|
||||
assert item.wav == announcer.CACHE_DIR / "mesh-d1.wav" and item.wav.exists()
|
||||
assert (item.text, item.user, item.subject, item.method) == ("Fra sentralen", "Kari", "jwt:7", "jwt")
|
||||
assert announcer.player.run_once(timeout=1).status == "done"
|
||||
log = audit_lines(tmp_path)
|
||||
assert log[0]["action"] == "play" and log[0]["user"] == "Kari" and log[0]["subject"] == "mesh"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data, feil", [
|
||||
({}, "Mangler wav"),
|
||||
({"meta": "{ikke json"}, "Ugyldig meta"),
|
||||
])
|
||||
def test_play_avviser_ufullstendig(c, tmp_path, data, feil):
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
if "meta" in data:
|
||||
data = {**data, "wav": (tiny_wav(tmp_path / "w.wav").open("rb"), "w.wav")}
|
||||
r = c.post("/api/play", data=data, headers=KEY)
|
||||
assert r.status_code == 400 and r.get_json()["error"] == feil
|
||||
|
||||
|
||||
def test_play_avviser_fil_som_ikke_er_wav(c, tmp_path):
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
junk = tmp_path / "junk.wav"; junk.write_bytes(b"dette er ikke lyd")
|
||||
r = c.post("/api/play", data={"wav": (junk.open("rb"), "junk.wav"), "meta": "{}"}, headers=KEY)
|
||||
assert r.status_code == 400 and "WAV" in r.get_json()["error"]
|
||||
assert not list(announcer.CACHE_DIR.glob("mesh-*.wav")), "søppel skal ikke bli liggende"
|
||||
|
||||
|
||||
def test_zones_uten_mesh_oppsett_gir_400(c):
|
||||
r = c.post("/api/announce", json={"text": "x", "zones": ["scene"]})
|
||||
assert r.status_code == 400 and "Ingen soner" in r.get_json()["error"]
|
||||
assert c.get("/api/config").get_json()["zones"] == []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sentral(app, tmp_path, monkeypatch):
|
||||
"""Gjør test-appen til sentral for «her» + to soner, med stubbet transport."""
|
||||
zones = mesh.zones_from_config({"her": "local", "a": "http://a", "b": "http://b"})
|
||||
sent: list[tuple[str, str]] = []
|
||||
def send(zone, path, d):
|
||||
sent.append((zone.name, d.text))
|
||||
return ("unreachable", None, "nede") if zone.name == "b" else ("queued", 5, "")
|
||||
disp = mesh.Dispatcher(zones, synthesize=lambda *a: announcer.synthesize(*a),
|
||||
play_local=announcer._play_local, send=send, timeout=1,
|
||||
on_finished=announcer._dispatch_finished)
|
||||
poller = mesh.ZonePoller(zones, fetch=lambda z: {"reachable": z.name == "a", "queued": 0, "current": None},
|
||||
interval=99, local_status=lambda: {"queued": 0, "current": None})
|
||||
monkeypatch.setattr(announcer, "DISPATCHER", disp)
|
||||
monkeypatch.setattr(announcer, "POLLER", poller)
|
||||
return disp, sent
|
||||
|
||||
|
||||
def test_sentral_fordeler(c, sentral, tmp_path):
|
||||
disp, sent = sentral
|
||||
assert c.get("/api/config").get_json()["zones"] == ["her", "a", "b"]
|
||||
|
||||
r = c.post("/api/announce", json={"text": "Til alle", "chime": "none"})
|
||||
assert r.status_code == 200
|
||||
body = r.get_json()
|
||||
assert body["dispatch"]["zones"]["a"]["status"] == "sending"
|
||||
|
||||
d = disp.run_once(timeout=1)
|
||||
assert d.id == body["id"]
|
||||
assert {n: z.status for n, z in d.zones.items()} == {"her": "queued", "a": "queued", "b": "unreachable"}
|
||||
assert sorted(sent) == [("a", "Til alle"), ("b", "Til alle")]
|
||||
|
||||
# Lokalt: ferdig WAV i køen, spilles uten ny syntese
|
||||
item = announcer.player.history[0]
|
||||
assert item.wav is not None and item.text == "Til alle"
|
||||
assert announcer.player.run_once(timeout=1).status == "done"
|
||||
|
||||
log = audit_lines(tmp_path)
|
||||
assert [l["action"] for l in log] == ["announce", "dispatched", "played"]
|
||||
assert log[0]["zones"] == ["her", "a", "b"]
|
||||
assert log[1]["zones"] == {"her": "queued", "a": "queued", "b": "unreachable"}
|
||||
|
||||
|
||||
def test_sentral_bare_til_valgte(c, sentral):
|
||||
disp, sent = sentral
|
||||
c.post("/api/announce", json={"text": "x", "zones": ["a"]})
|
||||
disp.run_once(timeout=1)
|
||||
assert sent == [("a", "x")]
|
||||
assert announcer.player.history == [] # ikke lokalt
|
||||
|
||||
|
||||
def test_sentral_ukjent_sone_gir_400_for_noe_sendes(c, sentral):
|
||||
disp, sent = sentral
|
||||
r = c.post("/api/announce", json={"text": "x", "zones": ["a", "finnes-ikke"]})
|
||||
assert r.status_code == 400 and r.get_json()["error"] == "Ukjent sone: finnes-ikke"
|
||||
assert disp.run_once(timeout=0.05) is None and sent == []
|
||||
|
||||
|
||||
def test_sentral_status_har_soner_og_fordelinger(c, sentral):
|
||||
disp, _ = sentral
|
||||
announcer.POLLER.poll_once()
|
||||
c.post("/api/announce", json={"text": "x"})
|
||||
disp.run_once(timeout=1)
|
||||
s = c.get("/api/status").get_json()
|
||||
assert s["zones"]["her"]["reachable"] and s["zones"]["a"]["reachable"] and not s["zones"]["b"]["reachable"]
|
||||
assert s["dispatches"][0]["text"] == "x"
|
||||
assert s["dispatches"][0]["zones"]["b"]["status"] == "unreachable"
|
||||
|
||||
|
||||
def test_planlagt_med_soner_fordeles(c, sentral, tmp_path):
|
||||
disp, sent = sentral
|
||||
r = c.post("/api/schedule", json={"text": "Planlagt", "at": time.time() + 600, "zones": ["a", "her"]})
|
||||
sid = r.get_json()["id"]
|
||||
assert r.get_json()["zones"] == ["a", "her"]
|
||||
# overlever reload
|
||||
assert sched.Schedule(announcer.schedule.path).get(sid).zones == ["a", "her"]
|
||||
|
||||
announcer.schedule.get(sid).at = time.time() - 10
|
||||
announcer.scheduler_tick()
|
||||
assert announcer.schedule.get(sid).status == "queued"
|
||||
assert "fordeles til a, her" in announcer.schedule.get(sid).note
|
||||
d = disp.run_once(timeout=1)
|
||||
assert d.schedule_id == sid
|
||||
assert sent == [("a", "Planlagt")]
|
||||
assert announcer.schedule.get(sid).status == "sent"
|
||||
assert announcer.schedule.get(sid).note == "a: kø #5, her: kø #1" # rekkefølgen som ble bedt om
|
||||
|
||||
|
||||
def test_planlagt_der_alle_soner_er_nede_blir_failed(c, sentral):
|
||||
disp, _ = sentral
|
||||
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600, "zones": ["b"]}).get_json()["id"]
|
||||
announcer.schedule.get(sid).at = time.time() - 10
|
||||
announcer.scheduler_tick()
|
||||
disp.run_once(timeout=1)
|
||||
assert announcer.schedule.get(sid).status == "failed"
|
||||
assert "b: nede" in announcer.schedule.get(sid).note
|
||||
|
||||
|
||||
def test_planlagt_til_sone_som_er_fjernet_fra_config(c, sentral):
|
||||
"""Planen ble lagt før sonen ble tatt ut av config."""
|
||||
sid = c.post("/api/schedule", json={"text": "x", "at": time.time() + 600}).get_json()["id"]
|
||||
announcer.schedule.get(sid).zones = ["borte"]
|
||||
announcer.schedule.get(sid).at = time.time() - 10
|
||||
announcer.scheduler_tick()
|
||||
assert announcer.schedule.get(sid).status == "failed"
|
||||
assert "Ukjent sone" in announcer.schedule.get(sid).note
|
||||
|
||||
|
||||
# --- ekte transport: send_to_zone mot en levende /api/play -------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_sone(app, tmp_path):
|
||||
"""Kjører test-appen som sone på en ekte port, så multipart-koden i
|
||||
send_to_zone møter Flask sin parser og ikke bare seg selv."""
|
||||
from werkzeug.serving import make_server
|
||||
with_auth(tmp_path, MESH_CFG)
|
||||
srv = make_server("127.0.0.1", 0, app, threaded=True)
|
||||
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
t.start()
|
||||
yield mesh.Zone("live", f"http://127.0.0.1:{srv.server_port}")
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
def test_ekte_runde_sentral_til_sone(live_sone, tmp_path):
|
||||
wav = tiny_wav(tmp_path / "ut.wav")
|
||||
d = mesh.Dispatch("rt1", "Over nettet", "finn", 1.0, "none", {}, user="Kari", subject="jwt:7", method="jwt")
|
||||
|
||||
status, item_id, err = mesh.send_to_zone(live_sone, wav, d, secret="mesh-hemmelighet-for-test", timeout=3)
|
||||
assert (status, err) == ("queued", "")
|
||||
item = announcer.player.history[0]
|
||||
assert item.id == item_id
|
||||
assert item.text == "Over nettet" and item.user == "Kari"
|
||||
assert item.wav.read_bytes() == wav.read_bytes()
|
||||
|
||||
st = mesh.fetch_zone_status(live_sone, secret="mesh-hemmelighet-for-test", timeout=3)
|
||||
assert st["reachable"] is True and st["queued"] == 1
|
||||
|
||||
|
||||
def test_ekte_runde_med_feil_nokkel(live_sone, tmp_path):
|
||||
wav = tiny_wav(tmp_path / "ut.wav")
|
||||
d = mesh.Dispatch("rt2", "x", "finn", 1.0, "none", {})
|
||||
status, item_id, err = mesh.send_to_zone(live_sone, wav, d, secret="feil", timeout=3)
|
||||
assert status == "rejected" and item_id is None
|
||||
assert err.startswith("401") and "mesh-nøkkel" in err
|
||||
assert mesh.fetch_zone_status(live_sone, secret="feil", timeout=3)["reachable"] is False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue