// Entwickler

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.

// ÜBERBLICK

Bauen. Versiegeln. Senden.

Mehr verlangt Andon nicht. Was eine Zahl bedeutet, sagt deine Quelle. Wie sie aussieht, entscheidet die App.

01

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.

02

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.

03

Umschlag senden

Ein PUT an das Relay, der Schreibschlüssel als Bearer-Token. Das Relay drosselt je Ansicht.

// STRUKTUR · KOPF

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.

FeldTypPflichtBedeutung
idstring (uuid)jaUUID 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.
schemaVersionconst 1jaKonstant 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.
namestring, 1–60jaAnzeigename der Ansicht auf dem Gerät.
generatedAtstring (date-time, Z)jaZeitpunkt der Datenerhebung, nicht des Uploads. Referenz für staleAfterSec und Ende offener Timeline-Segmente.
staleAfterSecinteger ≥ 1jaSchwelle 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.
tilesarray, minItems 1jaAnzeigereihenfolge, die App sortiert nicht um. Nicht lesbare Einträge und unbekannte view-Werte rendert sie als Platzhalter, statt sie zu verwerfen.
$schemastringneinFür Editor-Support. Das Schema lässt das Feld ausdrücklich zu, als einzige Ausnahme im geschlossenen Objekt. Die App ignoriert es.
view.json
{
  "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.

// STRUKTUR · JEDE KACHEL

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.

FeldTypPflichtBedeutung
idstring, [A-Za-z0-9._-], 1–64jaEindeutig im Dokument, stabil über alle Uploads. Die Eindeutigkeit prüft der Konfigurator, das Schema kann es nicht.
viewenum, siehe KachelartenjaDie Kachelart. Legt fest, welche weiteren Felder Pflicht, erlaubt oder abgelehnt sind. Einen unbekannten Wert rendert die App als Platzhalter.
titlestring, 1–60jaÜberschrift in App und Widget, im Widget einzeilig.
titleShortstring, 1–12neinErsetzt title im kleinen Widget und auf dem Sperrbildschirm. Fehlt es, steht dort title. Das engste Längenlimit im Format.
iconstring (Symbolname oder none)neinSF-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.
statusenum: ok, warning, critical, staleneinDas 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.
subtextstring, ≤ 80neinZusatzzeile, im kleinen Widget ausgeblendet. Uhrzeiten statt Dauern: „Seit 14:32“ bleibt wahr, „vor 3 Minuten“ nicht.
// STRUKTUR · KACHELARTEN

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.

labelgaugeprogresslinebarpiedonuttabletimeline
value●●●○––○––
valueIcon○––––––––
unit, decimals○○○○○○○––
range–●●○–––––
target–○○○○––––
bands–○○––––––
categories––––○●●––
series–––●●●●––
statuses (in einer Serie)––––○––––
columns, rows–––––––●–
from, to, states, lanes––––––––●
● Pflicht○ erlaubt– abgelehnt

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.

// STRUKTUR · FORMATE

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"] ] } ]
// STRUKTUR · STATUS

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.

okgrünAlles wie vorgesehen.
warninggelbJemand sollte hinsehen, nichts ist kaputt.
criticalrotJemand muss jetzt handeln.
stalegrauDer Wert ist alt oder fehlt. Sagt nichts über die Sache: Ein stummer Fühler ist stale, eine kaputte Maschine critical.
StandardAkzentfarbeKein status gesetzt, also neutral: eine Zahl ohne Urteil.
// TRANSPORT · UMSCHLAG

Selbst versiegeln, in jeder Sprache.

Das Relay prüft nur das Format: Es kennt den Inhaltsschlüssel nicht und sieht nie Klartext.

envelope
{
  "v": 1,
  "view": "vw_totavbtprh6rdpgg2m2f6vrwny",
  "kv": 3,
  "nonce": "fDcgsU7nTxZlN5Op",
  "ct": "…",
  "notify": true
}
RegelWertHinweis
AlgorithmusAES-256-GCM16-Byte-Tag am Ende von ct
Inhaltsschlüssel32 ByteEntsteht bei dir, erreicht nie das Relay.
Nonce12 ByteKryptografisch zufällig, für jeden Umschlag neu, nie zweimal mit demselben Schlüssel.
AADandon-v1|<view>|<kv>UTF-8, kv dezimal ohne führende Nullen. notify gehört nicht dazu: Es ist eine Anweisung ans Relay, kein Inhalt.
KlartextJSONDas Ansichtsdokument, Byte für Byte, wie deine Quelle es erzeugt.
Feldreihenfolgev, view, kv, nonce, ct, notifynotify immer schreiben, auch als false.
KodierungBase64, mit PaddingFür nonce und ct, Standard-Alphabet, nicht URL-sicher.
Größe≤ 262144 ByteDer ganze Umschlag, nicht der Klartext.
Rotationkv + 1Neuer Inhaltsschlüssel, neuer QR-Code für alle Geräte.
// TRANSPORT · UPLOAD

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.

bash
# 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 RelayFehlercodeHTTP
Ansichts-ID im Pfad hat das richtige Formatnot_found404
Gesamtgröße höchstens 262144 Byteenvelope_too_large413
Bearer-Token im Authorization-Header vorhandenunauthorized401
Die Ansicht existiertnot_found404
Schreibschlüssel passt zur Ansichtunauthorized401
Gültiges JSON-Objekt, nichts dahinterenvelope_invalid_json400
v vorhanden und gleich 1envelope_unsupported_version400
view vorhanden und gleich der Ansichts-ID im Pfadenvelope_view_mismatch400
kv Ganzzahl von 1 bis 2147483647envelope_invalid_kv400
nonce Base64, genau 12 Byteenvelope_invalid_nonce400
ct Base64, mindestens 16 Byteenvelope_invalid_ct400
notify fehlt, null oder booleschenvelope_invalid_notify400
Höchstens ein Upload je 10 Sekundenthrottled429
// TRANSPORT · KOPPLUNG

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

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.

02

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.

03

Mehrfach gültig

Derselbe Einladungsschlüssel koppelt beliebig viele Geräte. Ein Code reicht für das ganze Team.

04

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.

05

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.

// TRANSPORT · API

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.

RouteZweckAntwort
Anlegen · ohne Schlüssel, begrenzt je IP
POST /v1/viewsAnsicht 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}/envelopeUmschlag senden204
GET /v1/views/{id}/grantsGekoppelte 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 erneuert200 {invite_secret}
POST /v1/views/{id}/rotateSchreib- oder Einladungsschlüssel erneuern, im Body secret: write oder invite200 {write_secret} | {invite_secret}
DELETE /v1/views/{id}Ansicht löschen204
// VORLAGEN · SKRIPTE

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.

01

Quelle

Holt die Zahlen, setzt die Status und schreibt das Ansichtsdokument auf stdout. Kennt keinen Schlüssel.

metrics.sh
#!/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 } }
  ]
}'
02

Versiegeln

Liest das Dokument von stdin, versiegelt es und sendet es. Kennt deine Daten nicht.

andon-seal.mjs
#!/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) }
03

Aufruf

Die Pipe verbindet beides. Die Werte kommen aus der .env des Konfigurators, als Umgebungsvariablen. Ein Timer wie cron wiederholt den Aufruf.

Terminal
$ ./metrics.sh | node andon-seal.mjs
ok 974 bytes, notify=true
$ sleep 30; ./metrics.sh | node andon-seal.mjs
ok 975 bytes, notify=false
$ 

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.