FlowGlow Lighting
Smart-Lighting-Plattform mit IoT-Backend
End-to-End-Plattform für vernetzte Beleuchtungssysteme: ESP32-Firmware mit MQTT-Anbindung, Node-Consumer als Geräte-Backend, Supabase als Datenebene mit Realtime-Broadcast, Flutter-App für Endkunden und Angular-Admin-Dashboard für Service und Roll-outs.
01 Problem
FlowGlow wollte aus einer „dummen" smarten Leuchte ein vollständig vernetztes Produkt machen — Geräte-Provisioning per BLE, Live-Steuerung über App, belastbare Diagnose im Fehlerfall, Firmware-OTA mit Rollback und Anbindung an Smart-Home-Ökosysteme. Alles unter einer Architektur, die hunderte Geräte gleichzeitig verträgt, ohne dass jede Statusänderung das Backend unter Last setzt.
02 Lösung
Architektur in vier Schichten: ESP32-Firmware mit versionierten MQTT-Topics, ein Node-Consumer als Geräte-Backend, Supabase mit Postgres als Datenebene samt Realtime-Broadcast pro Nutzer, Flutter-App für Endkunden und ein Angular-Dashboard für Support, Firmware-Roll-outs und Diagnose. Bereitstellung über Coolify auf einem eigenen Hetzner-Server.
03 Ergebnis
Hunderte Geräte werden in Echtzeit überwacht, Firmware-Updates rollen in Wellen aus und Coredumps werden automatisch eingesammelt — der Support hat Symbol-Stacks vorliegen, bevor ein Kunde überhaupt anruft. Die Smart-Home-Anbindung an Google Home und Alexa als nächste Stufe ist Anfang 2026 live gegangen.
Architektur in vier Schichten
Die Plattform ist um vier klar getrennte Schichten gebaut, und jede Schicht spricht über ein einziges, dokumentiertes Protokoll mit den Nachbarn:
- Firmware (ESP32 / C++): baut nach dem Boot WLAN auf, holt Credentials aus dem NVS und verbindet sich per TLS mit dem Mosquitto-Broker. Sie veröffentlicht Statusänderungen auf
d/<id>/state, OTA-Events aufd/<id>/ota_events, Crash-Summaries aufd/<id>/coredumpund akzeptiert eingehende Kommandos aufd/<id>/cmd. - MQTT-Consumer (Node.js): abonniert alle Geräte-Topics, validiert die Payloads, dedupliziert, und schreibt das Ergebnis transaktional in Postgres. Veröffentlicht außerdem Kommandos in die andere Richtung, indem er aus einer
lamp_commands-Tabelle pollt. - Supabase / Postgres: ist die Single Source of Truth. RLS-Policies regeln Sichtbarkeit pro Kunde, ein
BEFORE INSERT-Trigger aufdevice_eventssnappt jeden Zustandsübergang, und einAFTER UPDATE-Trigger aufdevice_statebroadcastet die Änderung an genau den richtigen Nutzer-Kanal. - Clients: eine Flutter-App für Endkunden (iOS, Android), ein Angular-Dashboard für Support und Service. Beide reden ausschließlich GraphQL für Lese-Zugriffe und Realtime-Broadcast für Live-Updates — kein direkter MQTT-Pfad ins Frontend.
Die strikte Trennung bedeutet, dass jede Schicht einzeln deploybar ist: Firmware-Updates rollen über die Geräte, der Consumer ist ein zustandsloser Worker hinter Coolify, das Dashboard ist eine SPA, die Flutter-App ein App-Store-Build. Kein „Big-Deploy”-Moment im Kalender, kein Lock-Step zwischen Frontend und Backend.
Vorgehen
Drei Designentscheidungen ziehen sich durch das gesamte Backend und sind der Grund, weshalb das System auch mit Hunderten Geräten gleichzeitig stabil läuft.
Erstens: nichts blockiert. Der Consumer arbeitet rein async, jede Nachricht ist eine eigene Transaktion. Wenn Postgres einmal lahmt, stauen sich Nachrichten im Broker, nicht im Code. Zweitens: alles ist idempotent. Jeder Handler kann eine Nachricht beliebig oft empfangen, ohne dass dadurch falsche Daten entstehen. Drittens: Diagnose-Daten kommen automatisch. Stürzt ein Gerät ab, lädt es eigenständig einen ELF-Coredump hoch — niemand muss einen Kunden anrufen und „bitte schicken Sie mir das Log” sagen.
// Staged firmware rollout: 1% → 10% → 100% mit Health-Gate dazwischen.
export async function scheduleRollout(v: string) {
const stages = [0.01, 0.10, 1.00];
for (const share of stages) {
await rolloutTo(v, share);
await waitForHealthGate(v, { minHours: 24 });
}
} Tiefe Einblicke
Topic-Schema mit klaren Verträgen
Jede Topic-Familie hat ein Zod-Schema, das eingehende Nachrichten validiert, bevor sie auf die Datenbank treffen. Schema-Verstöße landen in einer Dead-Letter-Queue mit Kontext (Device-ID, Raw-Payload, Topic), statt das Backend zu kippen. Das war eine bewusste Reaktion auf die erste Generation: dort konnte eine fehlerhafte Firmware-Build das ganze Backend lahmlegen, weil sie unparsbare Payloads sendete.
import { z } from 'zod';
export const StateV1 = z.object({
device_id: z.string().uuid(),
fw_version: z.string(),
brightness: z.number().int().min(0).max(100),
color_kelvin: z.number().int().min(2200).max(6500),
on: z.boolean(),
seq: z.number().int(), // monotonic, for dedup
ts: z.iso.datetime(),
});
export type StateV1 = z.infer<typeof StateV1>; Atomic State Update als Postgres-RPC
State-Änderung und Audit-Event laufen in einer Transaktion durch eine SECURITY DEFINER-Funktion. Der BEFORE INSERT-Trigger auf device_events snappt den aktuellen device_state als previous_state — und sieht garantiert den Zustand vor dem Upsert, nie einen halbgeschriebenen Mix.
CREATE OR REPLACE FUNCTION public.process_device_state_update(
p_device_id uuid, p_event_data jsonb, p_state_data jsonb
) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
-- 1. Event zuerst — Trigger snappt previous_state aus device_state.
INSERT INTO device_events (device_id, event_type, event_data, source, source_id)
VALUES (p_device_id, 'state_change', p_event_data, 'device', p_device_id::text);
-- 2. Upsert device_state, nur Felder die im Payload stehen.
INSERT INTO device_state (device_id, power, brightness, rgb_color, online, last_seen)
VALUES (p_device_id,
(p_state_data->>'power')::boolean,
(p_state_data->>'brightness')::int,
(p_state_data->>'rgb_color')::int,
true, NOW())
ON CONFLICT (device_id) DO UPDATE SET
power = COALESCE((p_state_data->>'power')::boolean, device_state.power),
brightness = COALESCE((p_state_data->>'brightness')::int, device_state.brightness),
rgb_color = COALESCE((p_state_data->>'rgb_color')::int, device_state.rgb_color),
online = true,
last_seen = NOW();
END $$; Automatische Coredump-Erfassung mit Deduplication
Crasht ein Gerät, sendet es zuerst eine Summary auf d/<id>/coredump (Stack-Pointer, Reset-Reason, ELF-SHA). Der Consumer dedupliziert auf (elf_sha256, pc, cause). Bei einem neuen Crash schickt er einen get_coredump-Command zurück; das Gerät streamt dann den ELF in Chunks über coredump_raw und einen pre-crash Log-Tail über log_tail. Re-Occurrences bumpen nur einen Counter — wir vermeiden so unnötige Uploads bei einem Bug, der gerade 200 Geräte gleichzeitig trifft.
async function handleCoredumpSummary(deviceId: string, summary: CoredumpSummary) {
if (!summary?.elf_sha256 || !summary?.pc || summary?.cause === undefined) {
return; // unvollständige Summary verwerfen
}
const result = await db.upsertCrashSummary(deviceId, summary);
if (!result) return;
// Auto-trigger raw upload nur beim ersten Auftreten dieser Crash-Signatur.
if (result.isNew && result.rawElfStatus === 'pending') {
const customerId = await db.getDeviceCustomerId(deviceId);
const commandId = await db.queueGetCoredump(deviceId, customerId);
await db.markCrashUploadRequested(result.crashId, commandId);
assembler.registerExpectedUpload(commandId, deviceId, customerId);
}
} Per-User Broadcast statt postgres_changes
Der naive Weg in Supabase Realtime — postgres_changes — schickt jede WAL-Änderung durch den Realtime-Server, der pro verbundener Session Filter-Klauseln und RLS-Policies auswertet, bevor er den Event an die berechtigten Clients pusht. Korrekt ist das (RLS filtert serverseitig, keine Daten gehen an die falschen Clients) — aber bei mehreren Hundert Geräten und Sessions werden daraus pro WAL-Entry Hunderte Filter-Auswertungen, und der Realtime-Server wird zum Engpass. Stattdessen broadcastet ein Trigger jede device_state-Änderung gezielt auf user:<uid> mit private: true — die Adressierung passiert einmalig im Trigger, der Realtime-Server muss nicht mehr jede Session befragen. Die Flutter-App holt sich beim Subscribe via GraphQL einen Snapshot, danach reichen die Deltas.
@override
Future<void> subscribe() async {
await unsubscribe();
final userId = _supabase.auth.currentUser?.id;
if (userId == null) return;
// 1. Snapshot per GraphQL — die App startet immer mit aktuellem Stand.
await _fetchInitialStates(userId);
// 2. Per-User-Broadcast-Channel für Deltas.
_channel = _supabase
.channel('user:$userId', opts: const RealtimeChannelConfig(private: true))
.onBroadcast(event: 'device_state_change', callback: _onBroadcast)
.subscribe();
} Device-lokale Timer mit Reconnect-Rearming
Automationen wie „in 30 Minuten ausschalten” laufen lokal auf dem Gerät, nicht im Backend — das ist robuster gegen WAN-Aussetzer. Stirbt aber die WLAN-Verbindung mitten in einem Timer und das Gerät bootet neu, ist der Timer auf der Hardware weg. Der Consumer sieht den Online-Übergang, rechnet aus der DB-Spalte expires_at die verbleibenden Sekunden aus und re-armt jeden noch nicht abgelaufenen Timer mit derselben execution_id. Das Gerät überschreibt seinen Slot in-place — idempotent, egal wie oft die Re-Arm-Logik feuert.
private async rearmTimers(deviceId: string): Promise<void> {
const rows = await this.db.fetchActiveTimersForDevice(deviceId);
if (rows.length === 0) return;
for (const row of rows) {
const remainingMs = new Date(row.expires_at).getTime() - Date.now();
const seconds = Math.max(1, Math.floor(remainingMs / 1000));
await this.db.queueTimerArm(deviceId, row.automation_id, {
execution_id: row.execution_id, // gleicher Slot → idempotent
seconds,
inner_steps: row.inner_steps,
});
}
} Durchsatz, gerechnet in 118 Byte WebAssembly
Die ganze Architektur-Story oben — Per-User-Broadcast, Dedup im Consumer, idempotenter State — existiert, weil die naive Antwort (jedes Gerät, jede Änderung, Fan-out überall) nicht skaliert. Schieben Sie die Regler, um zu sehen, warum:
// 118 Bytes WebAssembly · läuft im Browser, kein JS-Fallback
Nachrichten / s
—
Datenrate
—
nach Dedup
—
// berechnet in WebAssembly
Was im Betrieb wichtig wurde
Drei Dinge, die im Lasttest nicht aufgefallen sind, sich aber im Realbetrieb als ausschlaggebend herausgestellt haben:
Ack-Topologie ist nicht symmetrisch. Geräte schicken Acks auf d/<id>/ack, aber je nach Quelle (Cloud-Command vs. lokaler Timer) ist die ID-Form anders: UUID für command_id, lokal generierte String-IDs für Timer-Fires. Der Consumer routet anhand des Feldes (command_id vs. timer_id), nicht über separate Topics — eine spätere Konsolidierung, die viel Komplexität rausgenommen hat.
OTA-Roll-outs scheitern selten am Update selbst, häufiger am Re-Verify. Nach einem erfolgreichen Flash bootet das Gerät, verifiziert die neue Partition und sendet ota_success. Hängt es zwischen Boot und Verify, betrachten wir das nach 10 Minuten als ota_failed und rollen zurück. Ohne diesen Watchdog hätte schon das eine oder andere Build die halbe Flotte ge-bricked.
Operations-Skripte greifen über dieselbe RLS-Ebene zu. Auch interne Skripte (Massen-Updates, Diagnose-Exports) gehen über die Supabase-Service-Role und werden in den device_events mit source = 'admin' protokolliert. Das hat sich beim Audit als Gold wert herausgestellt: jede Änderung an Kundendaten hat eine nachvollziehbare Quelle, auch wenn sie manuell ausgelöst war.
05 Entscheidungen
Schlüssel-Entscheidungen
MQTT-Topics von Anfang an versioniert
Jedes Topic trägt einen Versionssuffix (`d/<id>/state`, später `state/v2`). Wenn die Payload sich ändert, läuft eine neue Version parallel — bestehende Geräte sprechen weiter v1, neue Firmware sendet v2. Kein Big-Bang-Migrations fenster nötig und kein „erst alle Geräte updaten, dann das Backend".
Staged Firmware-Rollouts statt All-or-Nothing
Updates gehen in drei Wellen raus: 1 % der Flotte, dann 10 %, dann 100 %. Zwischen den Wellen läuft ein automatischer Health-Gate, der bei Anomalien (Crash-Rate, Heartbeat-Drop, fehlgeschlagene OTA-Events) den Rollout stoppt. So fängt ein kaputtes Build 1 % der Flotte ab, nicht 100 %.
Idempotente Message-Handler im Consumer
Jeder Consumer-Handler verarbeitet die gleiche Nachricht mehrfach ohne Side-Effects. Crash-Summaries werden auf `(elf_sha256, pc, cause)` dedupliziert, Timer-Acks finalisieren ein Execution-Row nur, wenn er noch im Status `running` ist. MQTT redelivers (QoS 1) und Reboot-Loops können dadurch keine Doppelbuchungen oder doppelten Zustandsänderungen erzeugen.
Atomic State Update statt zweier Round-Trips
Eine `process_device_state_update`-RPC schreibt das Event und upsertet den `device_state` in einer Transaktion. Der `BEFORE INSERT`-Trigger auf `device_events` snappt den vorherigen Zustand sauber als `previous_state` — kein Race zwischen „Event eingefügt" und „State bereits geändert". Klingt klein, ist im Realbetrieb der Unterschied zwischen sinnvollen und unbrauchbaren Audit-Logs.
Per-User Broadcast statt Postgres-Changes-Fan-out
Statt `postgres_changes`, wo der Realtime-Server pro WAL-Entry für jede verbundene Session Filter-Klauseln und RLS-Policies auswertet, broadcastet ein Trigger jede Statusänderung gezielt auf `user:<uid>` via `realtime.broadcast`. Die Flutter-App abonniert genau diesen einen Kanal. Das skaliert linearer (Aufwand pro Nachricht statt pro Session) und macht Berechtigungen einfach: ein Kanal pro Nutzer, einmalige Auth-Prüfung beim Subscribe.
Coolify auf eigener Hardware statt AWS IoT Core
Statt AWS IoT Core oder Azure IoT Hub: ein Hetzner-Server, Coolify als Orchestrierung, Mosquitto als Broker, Postgres für Daten. DSGVO-konform out of the box, monatliche Kosten unter 50 EUR und volle Kontrolle über Retention und Backups — wichtig in einem Markt, in dem Datenschutz Vertriebsargument ist.
07 Was hängenbleibt
- 01 MQTT-Topic-Versionierung von Tag 1 erspart Big-Bang-Migrationen.
- 02 Idempotenz im Consumer-Handler ist nicht optional, sondern Voraussetzung.
- 03 Staged Rollouts mit Health-Gates erkennen Probleme bei 1 % der Flotte, nicht bei 100 %.
- 04 Supabase Realtime Broadcast trägt deutlich weiter als der naive `postgres_changes`-Weg.
- 05 Coredumps mit ELF-Symbolen sind im Field der Unterschied zwischen „App crasht halt manchmal" und einem fixen Bugticket.
- 06 Ein einziger gemeinsamer Broker-Codepfad in C++ und TypeScript ist überschaubarer als zwei „cloud-native" Stacks, sobald die Geräte wirklich in Kundenhand sind.