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

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