announcer/tests/test_mesh.py
Kent c4a15799dd 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>
2026-10-09 02:22:05 +02:00

240 lines
8.5 KiB
Python

"""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"]