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

119
docs/mesh.md Normal file
View 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.