Jedes System kann eine Quelle sein.
Dein System muss dafür nichts Neues lernen. Kann es HTTPS und AES-GCM, sendet es selbst. Sonst holt ein Baustein daneben die Zahlen ab, etwa ein Node-RED-Flow oder ein Skript, und übernimmt den Rest: JSON bauen, versiegeln, per PUT an das Relay.
Bauen. Versiegeln. Senden.
Mehr verlangt Andon nicht. Was eine Zahl bedeutet, sagt deine Quelle. Wie sie aussieht, entscheidet die App.
Dokument bauen
Ein JSON nach dem Schema: Kopf und Kacheln. Kacheln mit einem Urteil tragen einen status nach deinen Schwellen. Die App übernimmt das Urteil und rechnet nichts nach.
Dokument versiegeln
AES-256-GCM mit frischer Nonce. Ansichts-ID und kv fließen als AAD ein. Jede Sprache mit AES-GCM kann das. Mit dem Node-RED-Paket entfällt dieser Schritt.
Umschlag senden
Ein PUT an das Relay, der Schreibschlüssel als Bearer-Token. Das Relay drosselt je Ansicht.
Ein vollständiges Dokument je Ansicht.
Das Schema ist geschlossen: Ein unbekanntes Feld ist ein Fehler, keine Erweiterung. Prüfen muss das deine Quelle. Das Relay sieht nur den Umschlag, und die App überliest, was sie nicht kennt. Ein vertipptes Feld fehlt dann einfach auf dem Widget. Der Konfigurator prüft gegen das Schema. Für eigene Skripte nimm das Schema und einen Validator für JSON Schema 2020-12.
| Feld | Typ | Pflicht | Bedeutung |
|---|---|---|---|
| id | string (uuid) | ja | UUID der Ansicht, stabil über alle Uploads. Nicht zu verwechseln mit der Ansichts-ID des Relays (vw_ plus 26 Zeichen Base32): Die steht nur im Umschlag und im Pfad, nie im Dokument. |
| schemaVersion | const 1 | ja | Konstant 1, das Schema lehnt jeden anderen Wert ab. Bei einem höheren Wert rendert die App das Dokument nicht und fordert ein App-Update an. |
| name | string, 1–60 | ja | Anzeigename der Ansicht auf dem Gerät. |
| generatedAt | string (date-time, Z) | ja | Zeitpunkt der Datenerhebung, nicht des Uploads. Referenz für staleAfterSec und Ende offener Timeline-Segmente. |
| staleAfterSec | integer ≥ 1 | ja | Schwelle in Sekunden. Ist jetzt minus generatedAt größer, setzt die App den effektiven Status jeder Kachel auf stale, unabhängig vom gelieferten status. Die Werte werden weiter gerendert, in Grau. |
| tiles | array, minItems 1 | ja | Anzeigereihenfolge, die App sortiert nicht um. Nicht lesbare Einträge und unbekannte view-Werte rendert sie als Platzhalter, statt sie zu verwerfen. |
| $schema | string | nein | Für Editor-Support. Das Schema lässt das Feld ausdrücklich zu, als einzige Ausnahme im geschlossenen Objekt. Die App ignoriert es. |
{
"id": "3f6c2a8e-91d4-4b7a-bc15-0e7d5f2a9c41",
"schemaVersion": 1,
"name": "Line 1",
"generatedAt": "2026-09-26T13:30:05Z",
"staleAfterSec": 180,
"tiles": [
{ "id": "t_state", "view": "label", "title": "Line 1",
"status": "ok", "value": "Running",
"subtext": "Shift until 22:00" },
{ "id": "t_oee", "view": "gauge", "title": "OEE line 1",
"titleShort": "OEE L1", "status": "ok",
"value": 80.2, "unit": "%", "decimals": 1,
"range": { "min": 0, "max": 100 }, "target": 85,
"bands": [ { "to": 60, "status": "critical" },
{ "to": 80, "status": "warning" },
{ "to": 100, "status": "ok" } ] }
]
}Eine Ansicht ist eine Sprache. Das Dokument hat kein Sprachfeld, mit Absicht: name, Titel, Subtexte und Kategorien sind die Worte des Ortes, aus dem die Zahlen kommen. Die App zeigt, was sie bekommt, und übersetzt nichts. Brauchst du zwei Sprachen, sendest du zwei Ansichten: dieselben Zahlen, ein zweites Dokument, ein zweiter QR-Code. So machen es auch die Demo-Ansichten.
Gemeinsamkeiten aller Kacheln.
Halte Kachel-IDs stabil. Ein Widget merkt sich Ansicht und Kachel-ID, nicht den Inhalt. Änderst du eine ID, findet es seine Kachel nicht mehr. Vergibst du sie neu, zeigt es ohne Hinweis die neue Kachel.
| Feld | Typ | Pflicht | Bedeutung |
|---|---|---|---|
| id | string, [A-Za-z0-9._-], 1–64 | ja | Eindeutig im Dokument, stabil über alle Uploads. Die Eindeutigkeit prüft der Konfigurator, das Schema kann es nicht. |
| view | enum, siehe Kachelarten | ja | Die Kachelart. Legt fest, welche weiteren Felder Pflicht, erlaubt oder abgelehnt sind. Einen unbekannten Wert rendert die App als Platzhalter. |
| title | string, 1–60 | ja | Überschrift in App und Widget, im Widget einzeilig. |
| titleShort | string, 1–12 | nein | Ersetzt title im kleinen Widget und auf dem Sperrbildschirm. Fehlt es, steht dort title. Das engste Längenlimit im Format. |
| icon | string (Symbolname oder none) | nein | SF-Symbols-Name im Kopf. Fehlt er oder kennt das Gerät ihn nicht, nimmt die App das Standardsymbol der Kachelart. none blendet das Symbol aus. |
| status | enum: ok, warning, critical, stale | nein | Das Urteil der Quelle, bestimmt Farbe und Statussymbol. Fehlt er, ist die Kachel neutral, ein unbekannter Wert zählt als kein Status. stale setzt die App auch selbst, sobald staleAfterSec abgelaufen ist. |
| subtext | string, ≤ 80 | nein | Zusatzzeile, im kleinen Widget ausgeblendet. Uhrzeiten statt Dauern: „Seit 14:32“ bleibt wahr, „vor 3 Minuten“ nicht. |
Was jede Art braucht, erlaubt und ablehnt.
Die Matrix gibt das Schema wieder. Ein Strich heißt: Der Validator lehnt das Feld ab. Die App überliest es ohne Fehler.
| label | gauge | progress | line | bar | pie | donut | table | timeline | |
|---|---|---|---|---|---|---|---|---|---|
| value | ● | ● | ● | ○ | – | – | ○ | – | – |
| valueIcon | ○ | – | – | – | – | – | – | – | – |
| unit, decimals | ○ | ○ | ○ | ○ | ○ | ○ | ○ | – | – |
| range | – | ● | ● | ○ | – | – | – | – | – |
| target | – | ○ | ○ | ○ | ○ | – | – | – | – |
| bands | – | ○ | ○ | – | – | – | – | – | – |
| categories | – | – | – | – | ○ | ● | ● | – | – |
| series | – | – | – | ● | ● | ● | ● | – | – |
| statuses (in einer Serie) | – | – | – | – | ○ | – | – | – | – |
| columns, rows | – | – | – | – | – | – | – | ● | – |
| from, to, states, lanes | – | – | – | – | – | – | – | – | ● |
label
Die einzige Art, deren value ein Text sein darf („Läuft“), und die einzige mit valueIcon, dem Symbol vor dem Wert.
gauge und progress
Dieselben Daten, eine Zahl auf einer Skala, anders gezeichnet. Beide brauchen range. bands färben die Skala, target markiert das Ziel.
line
series als Zeitreihe, als samples oder als Raster. value ist optional und erscheint als aktueller Wert neben dem Diagramm.
bar
Der Modus folgt aus categories. Mit categories: values je Kategorie, mehrere Serien nebeneinander, statuses nur bei genau einer Serie. Kein Balken trägt den status der Kachel, denn die Reihenfolge kommt von dir. Ohne categories: Zeitreihen wie bei line. Bei einer Serie ist der letzte Balken jetzt und trägt den status der Kachel. Beide Modi zu mischen lehnt das Schema ab.
pie und donut
Anteile an einem Ganzen: genau eine Serie mit values, eine je Kategorie. donut zeigt value in der Mitte, pie nimmt kein value. Beide nehmen keine statuses, denn ihre Palette lässt die Ampelfarben bewusst aus. Negative Werte zeigt die App als fehlerhaft.
table und timeline
Tragen weder value noch unit noch decimals. Die Zahlen leben in Zellen und Segmenten und bringen ihre Formatierung dort mit.
Wie die Werte aussehen.
Zeitstempel
ISO 8601 in UTC, endet auf Z: 2026-09-22T18:30:05Z, Sekundenbruchteile sind erlaubt. Ein Offset wie +02:00 wird abgelehnt. Gilt für jeden Zeitstempel im Dokument.
"generatedAt": "2026-09-22T18:30:05Z"
Symbol
Ein SF-Symbols-Name aus Kleinbuchstaben und Ziffern, mit Punkten: server.rack, bolt.fill. none schaltet das Standardsymbol ab. Ob das Symbol existiert, prüft allein die App. Ein unbekannter Name fällt zurück: bei icon auf das Symbol der Kachelart, bei valueIcon auf das Statussymbol. SF Symbols ↗
"icon": "server.rack", "valueIcon": "none"
Skala und Bänder
Ein Band reicht bis to und trägt immer einen status, das erste beginnt bei range.min. Bänder gehören zur Skala, nicht zum Wert: Sie färben den Hintergrund von gauge und progress und sonst nichts. Die App leitet den Status der Kachel nie aus ihnen ab. Die Quelle setzt ihn selbst, passend zu den Bändern. Reicht das letzte Band nicht bis range.max, bleibt der Rest der Skala ungefärbt. Die Reihenfolge im Array ist egal, die App sortiert nach to.
"range": { "min": 0, "max": 100 }, "target": 85, "bands": [ { "to": 60, "status": "critical" }, { "to": 80, "status": "warning" }, { "to": 100, "status": "ok" } ]
Serien
Drei Formen. samples: Zeitstempel und Zahl je Punkt, für Messwerte, die kommen, wann sie kommen. Die Reihenfolge ist egal, die App sortiert nach Zeit. start, stepSec, points: ein gleichmäßiges Raster, viel kleiner auf der Leitung. values: eine Zahl je Kategorie in der Reihenfolge von categories. Genau so viele Zahlen wie categories, sonst zeigt die App die Kachel als fehlerhaft. Jede Serie braucht einen name. Ab zwei Serien zeigt ihn die Legende. statuses: optional neben values, nur bei bar mit categories und genau einer Serie. Ein Status je Balken in derselben Reihenfolge oder null für neutral, also die Akzentfarbe und nicht grau, denn grau heißt stale. Ampelfarben nur für Balken mit einem Urteil, der Rest bleibt null. Die Länge von statuses prüft das Schema nicht: Fehlende Einträge zählen als null, überzählige verwirft die App.
{ "name": "Actual",
"samples": [ ["2026-09-22T20:34:46Z", 81.75],
["2026-09-22T21:34:46Z", 80.32] ] }
{ "name": "Actual", "start": "2026-09-22T19:30:00Z",
"stepSec": 900, "points": [356, 344, 332] }
{ "name": "Today", "values": [21, 17, 31] }
{ "name": "Load", "values": [97, 85, 40],
"statuses": ["critical", "warning", null] }Tabelle
Höchstens 6 Spalten und 50 Zeilen. Das ist die Obergrenze, kein Ziel. Das Widget zeigt je nach Größe nur die ersten Zeilen, das kleine nur die erste und die letzte Spalte. Die ganze Tabelle zeigt die App. Einheit und Nachkommastellen gehören zur Spalte, die Zellen bleiben nackte Zahlen. Eine Zelle ist Text, Zahl oder null für „kein Wert“. null ist nicht 0. rows darf leer sein. Jede Zeile trägt genau so viele Zellen, wie es Spalten gibt. Das Schema kann das nicht prüfen, die App zeigt die Kachel sonst als fehlerhaft.
"columns": [ { "title": "Machine" }, { "title": "OEE", "unit": "%", "decimals": 1 } ], "rows": [ { "cells": ["Press 1", 42.7], "status": "warning" }, { "cells": ["Press 2", null] } ]
Zeitstrahl
states erklären, was vorkommen kann (bis 8, IDs bis 32 Zeichen). Ein Zustand ohne status wird aus der Kategorie-Palette gefärbt. lanes sind die Zeilen (bis 8, bis 200 Segmente). Ein Segment ist [start, state-id], aufsteigend, und dauert bis zum nächsten. Das letzte endet bei min(to, generatedAt), to darf in der Zukunft liegen. Verweist ein Segment auf einen unbekannten Zustand oder stehen die Segmente nicht aufsteigend, zeigt die App die Kachel als fehlerhaft. Ebenso, wenn to nicht nach from liegt oder eine Zustands-ID doppelt vorkommt.
"from": "2026-09-22T20:00:00Z", "to": "2026-09-23T04:00:00Z", "states": [ { "id": "run", "label": "Running", "status": "ok" }, { "id": "maint", "label": "Maintenance" } ], "lanes": [ { "name": "Press 1", "segments": [ ["2026-09-22T20:00:00Z", "maint"], ["2026-09-22T20:16:00Z", "run"] ] } ]
Eine Aussage, keine Dekoration.
Der Status ist das Urteil der Quelle, die App übernimmt es. Geweckt werden die Geräte nur, wenn der Umschlag notify: true trägt. Setz es, wenn eine Kachel ihren status ändert, hinzukommt oder wegfällt, sonst nicht. Ein status innerhalb einer Kachel, etwa in einer Tabellenzeile, zählt nicht. Pendelt ein Wert um eine Schwelle, gehört Hysterese in die Quelle: Jeder Weckruf kostet Akku.
Das Dokument hat kein Farbfeld. Segmente von pie und donut und Zeitstrahl-Zustände ohne status färbt die App aus einer festen Palette: acht Töne von Blau bis Violett. Grün, Gelb, Rot und Grau fehlen darin, sie sind für den Status reserviert. Bei mehr Kategorien als Farben beginnt sie von vorn. bar mit einer Serie: Akzentfarbe. Über die Zeit trägt der letzte Balken den status der Kachel, mit Kategorien färbt statuses jeden Balken. Mehrere Serien: je eine Farbe und eine Legende. Einen Status zeigt die Kachel als Streifen an ihrer linken Kante und als Symbol im Kopf. Die Zahl selbst bleibt in der normalen Textfarbe.
| ok | grün | Alles wie vorgesehen. |
| warning | gelb | Jemand sollte hinsehen, nichts ist kaputt. |
| critical | rot | Jemand muss jetzt handeln. |
| stale | grau | Der Wert ist alt oder fehlt. Sagt nichts über die Sache: Ein stummer Fühler ist stale, eine kaputte Maschine critical. |
| Standard | Akzentfarbe | Kein status gesetzt, also neutral: eine Zahl ohne Urteil. |
Selbst versiegeln, in jeder Sprache.
Das Relay prüft nur das Format: Es kennt den Inhaltsschlüssel nicht und sieht nie Klartext.
{
"v": 1,
"view": "vw_totavbtprh6rdpgg2m2f6vrwny",
"kv": 3,
"nonce": "fDcgsU7nTxZlN5Op",
"ct": "…",
"notify": true
}| Regel | Wert | Hinweis |
|---|---|---|
| Algorithmus | AES-256-GCM | 16-Byte-Tag am Ende von ct |
| Inhaltsschlüssel | 32 Byte | Entsteht bei dir, erreicht nie das Relay. |
| Nonce | 12 Byte | Kryptografisch zufällig, für jeden Umschlag neu, nie zweimal mit demselben Schlüssel. |
| AAD | andon-v1|<view>|<kv> | UTF-8, kv dezimal ohne führende Nullen. notify gehört nicht dazu: Es ist eine Anweisung ans Relay, kein Inhalt. |
| Klartext | JSON | Das Ansichtsdokument, Byte für Byte, wie deine Quelle es erzeugt. |
| Feldreihenfolge | v, view, kv, nonce, ct, notify | notify immer schreiben, auch als false. |
| Kodierung | Base64, mit Padding | Für nonce und ct, Standard-Alphabet, nicht URL-sicher. |
| Größe | ≤ 262144 Byte | Der ganze Umschlag, nicht der Klartext. |
| Rotation | kv + 1 | Neuer Inhaltsschlüssel, neuer QR-Code für alle Geräte. |
Ein PUT, ein Statuscode.
Das Relay prüft jeden Upload in der Reihenfolge der Tabelle. Die erste Prüfung, die scheitert, bestimmt die Antwort. Die Fehlerantwort ist JSON mit einem stabilen Code in error und einer Erklärung in message. Der gespeicherte Umschlag bleibt dann unverändert. Bei 429 sagt Retry-After, nach wie vielen Sekunden der nächste Upload angenommen wird. Besteht der Umschlag alle Prüfungen, antwortet das Relay mit 204.
# Upload: envelope by PUT, write secret as bearer token curl -sS --fail-with-body -X PUT "$ANDON_URL/v1/views/$ANDON_VIEW/envelope" \ -H "Authorization: Bearer $ANDON_WRITE_SECRET" \ -H "Content-Type: application/json" \ --data-binary @envelope.json # 204 = stored · 400 = bad envelope · 401 = wrong secret # 404 = unknown view · 413 = over 256 KiB · 429 = throttled, see Retry-After # Who is reading? Device names come back # encrypted (label_enc). curl -sS "$ANDON_URL/v1/views/$ANDON_VIEW/grants" \ -H "Authorization: Bearer $ANDON_WRITE_SECRET"
| Prüfung im Relay | Fehlercode | HTTP |
|---|---|---|
| Ansichts-ID im Pfad hat das richtige Format | not_found | 404 |
| Gesamtgröße höchstens 262144 Byte | envelope_too_large | 413 |
Bearer-Token im Authorization-Header vorhanden | unauthorized | 401 |
| Die Ansicht existiert | not_found | 404 |
| Schreibschlüssel passt zur Ansicht | unauthorized | 401 |
| Gültiges JSON-Objekt, nichts dahinter | envelope_invalid_json | 400 |
v vorhanden und gleich 1 | envelope_unsupported_version | 400 |
view vorhanden und gleich der Ansichts-ID im Pfad | envelope_view_mismatch | 400 |
kv Ganzzahl von 1 bis 2147483647 | envelope_invalid_kv | 400 |
nonce Base64, genau 12 Byte | envelope_invalid_nonce | 400 |
ct Base64, mindestens 16 Byte | envelope_invalid_ct | 400 |
notify fehlt, null oder boolesch | envelope_invalid_notify | 400 |
| Höchstens ein Upload je 10 Sekunden | throttled | 429 |
Der Kopplungslink.
Ein andon://-Link als QR-Code. Der Konfigurator erzeugt ihn für dich. Für alle, die ihn selbst bauen, stehen hier die Details.
andon://pair ?r=https%3A%2F%2Frelay.andon.app &v=vw_y4hhwcih4knngvkrpzjtrevaau &i=is_9Xk2QvT7Ld0sPmZaR4HcNy1BgWuEoIjF3rTxVbK6pQs &k=ZctIEt0J-GkaBRjav9PcP_qIVsmhuzoDGxGDHkkezjY &kv=1 # r relay URL, https, percent-encoded # v view ID vw_ + 26 characters base32 # i invite secret is_ + 43 characters base64url # k content key 43 characters base64url (256 bit) # kv key version integer from 1 # QR code: error correction M, quiet zone 4 modules, # at least 4 cm across, no logo
Nur https
r ist die Relay-Adresse ohne abschließenden Schrägstrich, percent-kodiert. Die App lehnt http:// ab und macht bei einem fehlerhaften Link keine Anfrage an das Relay.
Zwei Base64-Varianten
i und k sind base64url ohne Padding und werden nicht zusätzlich percent-kodiert. Anders als nonce und ct im Umschlag, die Standard-Base64 mit Padding sind.
Mehrfach gültig
Derselbe Einladungsschlüssel koppelt beliebig viele Geräte. Ein Code reicht für das ganze Team.
Sperren erneuert die Einladung
Wird ein Gerät gesperrt, erneuert das Relay den Einladungsschlüssel im selben Schritt. Alte Codes koppeln keine neuen Geräte mehr, gekoppelte bleiben.
Der Schlüssel steckt im Code
Wer den Code fotografiert hat, kann jeden Umschlag dieser Schlüsselversion öffnen. Erst ein neuer Inhaltsschlüssel mit kv plus eins macht ihn wertlos. Behandle ihn wie ein Passwort: nicht per Chat verschicken, nicht in Tickets, nicht auf Screenshots.
Die Routen, die eine Quelle braucht.
Antworten mit Inhalt sind JSON, Fehler kommen als error plus message mit stabilen Codes. 30 Tage nach dem letzten Upload löscht das Relay eine Ansicht, eine ohne Upload und ohne gekoppeltes Gerät schon nach 10.
| Route | Zweck | Antwort |
|---|---|---|
| Anlegen · ohne Schlüssel, begrenzt je IP | ||
| POST /v1/views | Ansicht anlegen. Die Schlüssel stehen nur in dieser Antwort, das Relay speichert nur Hashes. | 201 {id, write_secret, invite_secret} |
| Quelle · Bearer = Schreibschlüssel | ||
| PUT /v1/views/{id}/envelope | Umschlag senden | 204 |
| GET /v1/views/{id}/grants | Gekoppelte Geräte, Namen verschlüsselt (label_enc). Ein Gerät kann sich auch selbst entfernen. | 200 [grant] |
| DELETE /v1/views/{id}/grants/{device_id} | Gerät sperren, die Einladung wird im selben Schritt erneuert | 200 {invite_secret} |
| POST /v1/views/{id}/rotate | Schreib- oder Einladungsschlüssel erneuern, im Body secret: write oder invite | 200 {write_secret} | {invite_secret} |
| DELETE /v1/views/{id} | Ansicht löschen | 204 |
Ein Skript baut, eins versiegelt.
Der rohe Weg, von Hand: zwei Skripte, verbunden durch eine Pipe. So siehst du jeden Schritt. Das Node-RED-Paket nimmt dir das alles ab.
Quelle
Holt die Zahlen, setzt die Status und schreibt das Ansichtsdokument auf stdout. Kennt keinen Schlüssel.
#!/usr/bin/env bash # Server status as an Andon view: load, disk, services. Writes the document to # stdout; andon-seal.mjs or andon_seal.py seals and sends it. The status is decided HERE. set -euo pipefail now=$(date -u +%Y-%m-%dT%H:%M:%SZ) load=$(awk '{print $1}' /proc/loadavg) cores=$(nproc) disk=$(df --output=pcent / | tail -1 | tr -dc '0-9') pg_status=ok; pg_isready -q || pg_status=critical # Bands and status belong together: the same threshold for the colour and the verdict. load_pct=$(awk -v l="$load" -v c="$cores" 'BEGIN{printf "%.0f", l/c*100}') load_status=ok [ "$load_pct" -gt 70 ] && load_status=warning [ "$load_pct" -gt 90 ] && load_status=critical jq -n --arg now "$now" --arg host "$(hostname -s)" \ --argjson load_pct "$load_pct" --arg load_status "$load_status" \ --argjson disk "$disk" --arg pg "$pg_status" ' { id: "b7d41f92-3c58-4ad6-9e07-58c1f2a4d130", schemaVersion: 1, name: $host, generatedAt: $now, staleAfterSec: 180, tiles: [ { id: "t_pg", view: "label", title: "PostgreSQL", icon: "cylinder", status: $pg, value: (if $pg == "ok" then "Online" else "Down" end) }, { id: "t_load", view: "gauge", title: "Load per core", titleShort: "Load", status: $load_status, value: $load_pct, unit: "%", decimals: 0, range: { min: 0, max: 150 }, bands: [ { to: 70, status: "ok" }, { to: 90, status: "warning" }, { to: 150, status: "critical" } ] }, { id: "t_disk", view: "progress", title: "Disk /", titleShort: "Disk", status: (if $disk >= 90 then "critical" elif $disk >= 80 then "warning" else "ok" end), value: $disk, unit: "%", decimals: 0, range: { min: 0, max: 100 } } ] }'
Versiegeln
Liest das Dokument von stdin, versiegelt es und sendet es. Kennt deine Daten nicht.
#!/usr/bin/env node // Reads a view document from stdin, seals it (AES-256-GCM) and uploads it by PUT. // Configuration from the environment (ANDON_*). Node.js 18 or newer. import { createCipheriv, createHash, randomBytes } from 'node:crypto' import { readFileSync, writeFileSync, existsSync } from 'node:fs' const need = (k) => process.env[k] || (() => { throw new Error(k + ' is not set') })() const url = need('ANDON_URL').replace(/\/+$/, '') const view = need('ANDON_VIEW') const key = Buffer.from(need('ANDON_CONTENT_KEY'), 'base64url') if (key.length !== 32) throw new Error('ANDON_CONTENT_KEY must decode to 32 bytes') const kv = Number(process.env.ANDON_KEY_VERSION || 1) const cache = process.env.ANDON_CACHE || '/tmp/andon-' + view + '.fp' const doc = JSON.parse(readFileSync(0, 'utf8')) // Only a changed status wakes the devices: fingerprint of tile id + status. // ANDON_NOTIFY=always|never overrides it. No cache yet means: wake, to be safe. const fp = createHash('sha256').update(doc.tiles.map(t => t.id + '=' + (t.status ?? '-')).sort().join('\n')).digest('hex') const mode = process.env.ANDON_NOTIFY || 'auto' const notify = mode === 'always' || (mode !== 'never' && (!existsSync(cache) || readFileSync(cache, 'utf8') !== fp)) // Fresh 12-byte nonce, AAD "andon-v1|<view>|<kv>", 16-byte tag appended to the ciphertext. const nonce = randomBytes(12) const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 }) cipher.setAAD(Buffer.from('andon-v1|' + view + '|' + kv)) const ct = Buffer.concat([cipher.update(JSON.stringify(doc), 'utf8'), cipher.final(), cipher.getAuthTag()]) // Field order v, view, kv, nonce, ct, notify; nonce and ct as standard base64 with padding. const body = JSON.stringify({ v: 1, view, kv, nonce: nonce.toString('base64'), ct: ct.toString('base64'), notify }) if (Buffer.byteLength(body) > 262144) throw new Error('envelope larger than 256 KiB') const res = await fetch(url + '/v1/views/' + view + '/envelope', { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + need('ANDON_WRITE_SECRET') }, body, }) if (res.status === 204) { writeFileSync(cache, fp); console.log('ok', Buffer.byteLength(body), 'bytes, notify=' + notify) } else { console.error('relay answered', res.status, await res.text()); process.exit(1) }
Aufruf
Die Pipe verbindet beides. Die Werte kommen aus der .env des Konfigurators, als Umgebungsvariablen. Ein Timer wie cron wiederholt den Aufruf.
$ ./metrics.sh | node andon-seal.mjs ok 974 bytes, notify=true $ sleep 30; ./metrics.sh | node andon-seal.mjs ok 975 bytes, notify=false $
Prüfen, bevor du versiegelst.
Das Relay sieht nur den Umschlag, die App liest tolerant. Prüfen kann also nur deine Quelle: mit diesen Dateien und einem Validator für JSON Schema 2020-12.
Das verbindliche Schema des Ansichtsdokuments. Der Konfigurator prüft gegen dasselbe.
view-v1.sample.jsonEin vollständiges Ansichtsdokument mit jeder Kachelart, gültig nach dem Schema. Ein guter Startpunkt.
envelope-v1.schema.jsonDie Form des Umschlags, um Fehler früh zu finden. Ob view zum Pfad passt und ob die Größe stimmt, prüft erst das Relay.
Ansicht entwerfen. Koppeln. Verwalten.
Im Konfigurator stellst du die Kacheln zusammen und siehst sie wie auf dem iPhone, geprüft gegen das Schema. Dann legst du die Ansicht an, koppelst dein iPhone oder iPad per QR-Code und schickst einen ersten Test. Auch die Geräte verwaltest du dort. Der Inhaltsschlüssel verlässt deinen Browser nie.