Mesh: én sentral fordeler til flere soner

Flere anlegg – hovedscene, resepsjon, crew – og ett sted å sende fra, med
avkryssing for hvilke soner meldingen skal til.

Sentralen er en vanlig announcer med [zones] i config. En sone er en vanlig
announcer med ANNOUNCER_MESH_SECRET. Samme image, ingen mode, og en sone kan
brukes alene hvis sentralen faller ut. Hver avhengighet med C-kode har
aarch64-wheel i uv.lock, så det samme bildet går på en Raspberry Pi uten å
kompilere noe.

Sentralen syntetiserer én gang og sender ferdig WAV (POST /api/play,
multipart). Sonen trenger da verken nett, edge-tts eller Pipers 670 MB for
det den får – Item.wav er hele endringen i avspillingen. Køene forblir per
sone: dispatcheren har egen tråd og egen kø, leverer til hver sone i hver sin
tråd med timeout, og går videre. En opptatt hovedscene holder ikke igjen
resepsjonen, og en sone som er nede feiler alene – synlig per sone i UI og
revisjonslogg.

Auth gjenbrukes: ny leverandør mesh på header X-Mesh-Key (ikke Bearer – den
leses av JWT-leverandøren, som avviser alt som ikke er et JWT før neste får
prøve), med én handling, play. To regler som fulgte med:

- play følger aldri med «alt åpent». Uten det kunne hvem som helst POSTe en
  WAV til en åpen sone og få den spilt. Testen for åpen modus fanget det.
- En legitimasjon som vises fram og er feil gir 401 også der gjester ellers
  har lov. Før slapp en feil PIN i åpen modus stille gjennom som gjest.

mesh teller ikke som innlogging for folk: en sone med bare nøkkelen har
fortsatt åpen side lokalt.

Planlagte sendinger husker sonene sine og fordeles gjennom dispatcheren;
planen ender som sent hvis minst én sone tok imot, med «hovedscene: kø #3,
crew: nede» som note.

Hodeløs Pi: install.sh kjører loginctl enable-linger, ellers finnes ikke
lydsocketen når ingen logger inn. docs/mesh.md tar også raspi-config for
jack framfor HDMI.

38 nye tester, 148 totalt. Fordelingen testes uten nett med stubbet
transport; selve HTTP-en testes mot en levende werkzeug-server, så den
håndrullede multiparten møter Flask sin parser. Verifisert i compose med
sentral og to soner: til alle, bare til én, og én sone stoppet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-09 02:22:05 +02:00
parent eb27a162d5
commit c4a15799dd
17 changed files with 1486 additions and 23 deletions

View file

@ -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