Eine Bedienoberfläche für Hardware sieht aus wie ein gewöhnliches Formular: Button, Schieberegler, Schalter. Nur dass auf der anderen Seite ein Gerät steht, das ausgeschaltet sein kann, außer Reichweite ist, verzögert antwortet oder seinen Zustand von selbst ändert. Eine gute UI muss das ehrlich zeigen. Im Folgenden die Grundsätze, auf die ich bei solchen Panels achte. Die Codebeispiele sind in React und TypeScript, aber dieselben Regeln gelten für eine native App. In WireAmbi, meiner Android-App zur Steuerung eines LED-Streifens im Auto, zeigten sich einige davon erst bei den Tests. Ich beschreibe das im Beitrag über den LED-Streifen und das undokumentierte Protokoll.
1. Die Verbindung ist ein Zustandsautomat und kein isConnected: boolean
Ein true/false-Flag führt schnell zu merkwürdigen Situationen: ein aktiver Button „Verbinden“ mitten im Verbindungsaufbau, zwei parallele Verbindungen, keine Information über Wiederholungsversuche. Besser ist es, die Zustände und die erlaubten Übergänge zu benennen:
CONNECT CONNECTED
DISCONNECTED -------- CONNECT CONNECTED
DISCONNECTED ----------> CONNECTING ---------------- CONNECTING ------------------> CONNECTED
| ^ |
FAILED, Versuchslimit | | RETRY | CONNECTION_LOST
erreicht | | (nach Wartezeit) |
+---------------+ | |
| | | |
v FAILED, | | |
ERROR noch Versuche | |
| übrig v | |
| RECONNECTING <------------------------+
|
+--- CONNECT - CONNECTED
| ^ |
FAILED, Versuchslimit | | RETRY | CONNECTION_LOST
erreicht | | (nach Wartezeit) |
+---------------+ | |
| | | |
v FAILED, | | |
ERROR noch Versuche | |
| übrig v | |
| RECONNECTING <------------------------+
|
+--- CONNECT ---> CONNECTING
DISCONNECT aus jedem Zustand - CONNECTING
DISCONNECT aus jedem Zustand ---> DISCONNECTED
Jeder Zustand bekommt sein eigenes Erscheinungsbild:
Zustand | Was der Nutzer sieht | Bedienelemente |
|---|---|---|
getrennt | Button „Verbinden“ | deaktiviert oder Offline-Modus |
Verbindungsaufbau | Fortschrittsanzeige, Nummer des Versuchs, „Abbrechen“ | deaktiviert |
verbunden | Gerätename, deutlicher Status | aktiv |
erneuter Verbindungsaufbau | „Verbindung verloren, neuer Versuch …“, „Jetzt versuchen“ | gesperrt |
Fehler | was passiert ist und was man tun kann | „Erneut versuchen“ |
Der folgende Hook hält die Logik in einem Reducer, und Seiteneffekte ergeben sich nur aus dem aktuellen Zustand. Der Transport wird injiziert, sodass derselbe Code BLE und WebSocket bedient.
import { useEffect, useReducer } from "react";
export type ConnState =
| { status: "disconnected" }
| { status: "connecting"; attempt: number }
| { status: "connected" }
| { status: "reconnecting"; attempt: number; retryInMs: number }
| { status: "error"; message: string };
export type ConnEvent =
| { type: "CONNECT" | "CONNECTED" | "CONNECTION_LOST" | "RETRY" | "DISCONNECT" }
| { type: "FAILED"; message: string };
export interface DeviceTransport {
connect(signal: AbortSignal): Promise<void>;
disconnect(): void;
onConnectionLost(cb: () => void): () => void; // gibt "unsubscribe" zurück
}
const MAX_ATTEMPTS = 5;
const backoffMs = (n: number) => Math.min(1000 * 2 ** (n - 1), 15_000);
export function connReducer(s: ConnState, e: ConnEvent): ConnState {
if (e.type === "DISCONNECT") return { status: "disconnected" };
switch (s.status) {
case "disconnected":
case "error":
return e.type === "CONNECT" ? { status: "connecting", attempt: 1 } : s;
case "connecting":
if (e.type === "CONNECTED") return { status: "connected" };
if (e.type !== "FAILED") return s;
return s.attempt < MAX_ATTEMPTS
? { status: "reconnecting", attempt: s.attempt + 1, retryInMs: backoffMs(s.attempt) }
: { status: "error", message: e.message };
case "connected":
return e.type === "CONNECTION_LOST" ? { status: "reconnecting", attempt: 1, retryInMs: 0 } : s;
case "reconnecting":
return e.type === "RETRY" ? { status: "connecting", attempt: s.attempt } : s;
}
}
export function useDeviceConnection(transport: DeviceTransport) {
const [state, dispatch] = useReducer(connReducer, { status: "disconnected" });
useEffect(() => transport.onConnectionLost(() => dispatch({ type: "CONNECTION_LOST" })), [transport]);
// Seiteneffekte ergeben sich ausschließlich aus dem aktuellen Zustand.
useEffect(() => {
if (state.status === "connecting") {
const ctrl = new AbortController();
transport
.connect(ctrl.signal)
.then(() => dispatch({ type: "CONNECTED" }))
.catch((err: unknown) => {
if (!ctrl.signal.aborted) dispatch({ type: "FAILED", message: String(err) });
});
return () => ctrl.abort();
}
if (state.status === "reconnecting") {
const t = setTimeout(() => dispatch({ type: "RETRY" }), state.retryInMs);
return () => clearTimeout(t);
}
return undefined;
}, [state, transport]);
return {
state,
connect: () => dispatch({ type: "CONNECT" }),
disconnect: () => {
transport.disconnect();
dispatch({ type: "DISCONNECT" });
},
};
}
Ein exponentielles Backoff schont Gerät und Akku, und ist das Versuchslimit erschöpft, entscheidet der Nutzer.
2. Verzögerungen: Optimistic UI oder Bestätigung vom Gerät?
Optimistic UI ändert die Oberfläche sofort und nimmt die Änderung zurück, wenn das Gerät sie nicht annimmt. Das passt zu günstigen, umkehrbaren Aktionen wie Helligkeit oder Farbe. Auf die Bestätigung warten („in Arbeit“ bis zur Antwort) wählen wir bei Aktionen mit physischen Folgen oder solchen, die sich schwer zurücknehmen lassen: Motor, Heizelement, Speichern der Konfiguration, Firmware-Update. Die Regel: Je teurer ein Fehler, desto mehr warten wir auf das Gerät.
Zweiter Grundsatz: Die Quelle der Wahrheit ist das Gerät. Der Zustand ändert sich auch ohne die UI (physische Taste, zweites Telefon, Neustart). Abonnieren Sie daher die Änderungen (BLE-Benachrichtigungen, WebSocket-Nachrichten) und lesen Sie nach jedem Verbindungsaufbau den vollständigen Zustand aus.
Ein Extremfall ist der Streifencontroller in WireAmbi, der überhaupt keinen Zustand zurückmeldet. Die App muss selbst im Gedächtnis behalten, was sie eingestellt hat, und die einzige Bestätigung ist ein erfolgreicher Bluetooth-Schreibvorgang. Den Zustand meldet erst das Gateway led-auto auf dem Raspberry Pi, das im Auto die Verbindung zum Streifen hält.
3. Drei Ebenen des Feedbacks
Sofort, lokal: Das Bedienelement reagiert unmittelbar, ohne auf das Netz zu warten („Absicht entgegengenommen“).
Bestätigt: Das Gerät hat geantwortet; ein dezentes Häkchen ✓ genügt.
Timeout: Jeder Befehl hat ein Ablaufdatum. Danach kehren wir zum letzten bestätigten Wert zurück und bieten einen neuen Versuch an, aber wir melden nicht bei jedem verlorenen Frame während des Ziehens am Regler einen Fehler. Timeout-Werte misst man besser, als sie zu raten.
4. BLE und WLAN aus Sicht der UI
Bluetooth Low Energy im Browser (Web Bluetooth):
navigator.bluetooth.requestDevice()öffnet den Geräteauswahldialog des Systems und erfordert HTTPS sowie eine Nutzergeste (MDN). Die erste Verbindung muss also aus einem Klick hervorgehen.getDevices()(Wiederverwendung zuvor ausgewählter Geräte) steht in Chrome weiterhin hinter einem Flag, daher wählt der Nutzer nach dem Neuladen der Seite das Gerät meist erneut aus.Die Reichweite ist kurz, Abbrüche sind normal; der „erneute Verbindungsaufbau“ wird häufig vorkommen.
Unterstützung (28.09.2026, MDN und caniuse): Die API ist experimentell und nicht Teil der Baseline. Sie funktioniert in Chrome, Edge und Opera auf dem Desktop, in Chrome auf Android und in Samsung Internet; in Firefox und Safari (auch unter iOS) funktioniert sie nicht. Laut „Implementation Status“ der Web Bluetooth CG ist die Implementierung in Chrome unter Linux nur teilweise vorhanden, nicht unterstützt und hinter einem Flag. Man braucht einen Ausweichweg: eine native App, einen anderen Transport oder eine verständliche Meldung.
Für häufige Änderungen (Schieberegler) gibt es
writeValueWithoutResponse(), für wichtige BefehlewriteValueWithResponse().
WLAN im lokalen Netz:
Das Gerät muss zuerst ins Netz eingebunden werden; dieses Onboarding ist oft der schwierigste Bildschirm der App.
WebSocket eignet sich für den bidirektionalen Austausch und das Pushen von Zustandsänderungen, HTTP für einzelne Befehle und die Konfiguration.
Eine Seite aus dem öffentlichen HTTPS, die sich mit einem Gerät im LAN verbindet, läuft in Chrome in Local Network Access: Ab Chrome 142 erfordert das die Zustimmung des Nutzers, und weitere Versionen dehnen diese Einschränkungen auf andere Verbindungsarten aus. Nach der Zustimmung lockert Chrome die Blockade von Mixed Content für private Adressen und
.local-Domains. Für die UI ist das ein weiterer Zustand: „Zugriff auf das lokale Netz verweigert“. Andere Browser habe ich in dieser Hinsicht nicht geprüft.
WireAmbi verbindet sich direkt per BLE mit dem Streifen. Läuft im Auto das Gateway led-auto, spricht das Telefon über den verschlüsselten Kanal WireLink mit ihm, weil der Streifencontroller nur eine Verbindung gleichzeitig annimmt.
5. Kontinuierliche Steuerung: Regler für Helligkeit und Farbe
Ein gezogener Regler erzeugt eine Lawine von input-Ereignissen. Jedes davon zu senden, erzeugt eine Warteschlange: Der Regler ist längst losgelassen, und die Lampe „holt“ noch alte Werte „ein“. Debounce (Senden nach dem Stillstand) beseitigt die Warteschlange, nimmt aber die Live-Vorschau weg. Am besten funktioniert Throttling nach dem Prinzip „der letzte Wert gewinnt“:
wir senden höchstens alle N Millisekunden,
zu einem Zeitpunkt ist höchstens ein Befehl unterwegs,
von den zwischenzeitlich gesammelten Werten senden wir nur den neuesten, ohne Warteschlange,
der letzte Wert nach dem Loslassen des Reglers kommt immer beim Gerät an.
// Throttling "der letzte Wert gewinnt": keine Warteschlange, max. 1 Befehl unterwegs.
export function createLatestValueSender<T>(
send: (value: T) => Promise<void>,
minIntervalMs: number,
onError: (e: unknown) => void,
) {
let pending: { value: T } | null = null;
let inFlight = false;
let lastSentAt = -Infinity;
let timer: ReturnType<typeof setTimeout> | null = null;
function pump(): void {
if (inFlight || pending === null || timer !== null) return;
const wait = lastSentAt + minIntervalMs - Date.now();
if (wait > 0) {
timer = setTimeout(() => ((timer = null), pump()), wait);
return;
}
const { value } = pending;
pending = null;
inFlight = true;
lastSentAt = Date.now();
send(value)
.catch(onError)
.finally(() => ((inFlight = false), pump())); // neueren Wert senden, falls einer wartet
}
return {
push(value: T) {
pending = { value };
pump();
},
cancel() {
if (timer !== null) clearTimeout(timer);
timer = null;
pending = null;
},
};
}
export function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`Brak odpowiedzi po ${ms} ms`)), ms);
p.then(resolve, reject).finally(() => clearTimeout(t));
});
}
Umhüllen Sie den Aufruf innerhalb von send mit withTimeout, damit ein hängender Befehl die folgenden nicht blockiert. Im Beispiel wird der Fehler nur gemeldet; den Regler auf confirmed zurückzusetzen, ist eine einzige Zeile.
import { useEffect, useMemo, useState } from "react";
import { createLatestValueSender } from "./latestValueSender";
type Props = {
confirmed: number; // letzter vom Gerät bestätigter Wert
disabled: boolean; // z. B. wenn Verbindung !== "connected"
send: (value: number) => Promise<void>;
};
export function BrightnessSlider({ confirmed, disabled, send }: Props) {
const [local, setLocal] = useState(confirmed);
const [error, setError] = useState<string | null>(null);
const sender = useMemo(
() => createLatestValueSender(send, 80, () => setError("Urządzenie nie przyjęło zmiany")),
[send],
);
useEffect(() => () => sender.cancel(), [sender]);
useEffect(() => setLocal(confirmed), [confirmed]); // Änderung vom Gerät
const pending = local !== confirmed;
return (
<div>
<label htmlFor="brightness">Jasność</label>
<input
id="brightness"
type="range"
min={0}
max={100}
value={local}
disabled={disabled}
aria-valuetext={`${local}%${pending ? ", wysyłanie" : ""}`}
onChange={(e) => {
const v = e.currentTarget.valueAsNumber;
setLocal(v); // sofortiges lokales Feedback
setError(null);
sender.push(v);
}}
/>
<output htmlFor="brightness">{local}% {pending ? "(czekam na potwierdzenie)" : "✓"}</output>
<p role="status">{error ?? ""}</p>
</div>
);
}
Das Intervall von 80 ms ist nur ein Beispiel, wählen Sie es durch Messung. Die Funktion send muss stabil sein (z. B. mit useCallback), sonst erzeugt jedes Rendern ein neues Sende-Objekt und das Throttling funktioniert nicht mehr. Außerdem lohnt es sich, Werte vom Gerät während des Ziehens zu ignorieren, weil der Regler sonst unter dem Finger „springt“.
In WireAmbi übernimmt diese Rolle ein Puffer für ein einzelnes Element: Laufend berechnete Farben landen darin und nur die neueste bleibt erhalten, und der nächste Bluetooth-Schreibvorgang geht erst nach der Bestätigung des vorherigen durch den Stack raus. Zuvor gab es dort eine unbegrenzte Warteschlange, und je länger die Musik lief, desto ältere Farben zeigte der Streifen.
6. Barrierefreiheit
Das native
<input type="range">bietet eine Tastaturbedienung gemäß dem Slider-Muster der WAI-ARIA APG (Pfeiltasten, Home/End, Page Up/Down).aria-valuetexterlaubt es, „50 %, wird gesendet“ statt einer nackten Zahl vorzulesen.Melden Sie Verbindungsstatus und Fehler über
role="status"oderaria-live="polite"(WCAG 2.2, Kriterium 4.1.3), aber nicht jeden Schritt des Reglers.Vermitteln Sie den Status nicht allein über die Farbe (Kriterium 1.4.1): ein grüner Punkt immer mit dem Text „Verbunden“.
Neben dem Farbrad sollten ein HEX-Feld oder Presets stehen.
7. Offline-Modus
„Offline“ bedeutet: kein Internet oder kein Gerät. navigator.onLine sagt nur etwas über die Netzwerkschnittstelle; true garantiert nicht einmal das Internet (MDN), und über das Gerät sagt es gar nichts. Die lokale Steuerung (BLE, LAN) sollte nicht vom Internet abhängen, deshalb lohnt es sich, die Oberfläche aus dem Cache auszuliefern. Ist das Gerät nicht erreichbar, zeigen Sie den zuletzt bekannten Zustand als veraltet an („zuletzt gesehen um …“) und stellen Sie Befehle mit physischen Folgen nicht stillschweigend in eine Warteschlange.
8. Sicherheit
Web Bluetooth erfordert einen sicheren Kontext und die Zustimmung für ein bestimmtes Gerät und unterliegt der Permissions Policy
bluetooth(MDN). Das ist die Grundlage, kein Ersatz für Schutzmaßnahmen im Gerät.„Lokal“ heißt nicht „sicher“: Local Network Access entstand unter anderem gegen CSRF-Angriffe von Webseiten auf Router. Das Gerät sollte den Client authentifizieren (Kopplungscode, Token).
Validieren Sie in der Firmware (Wertebereiche, Begrenzung der Befehlsfrequenz); Einschränkungen in der UI sind Komfort, keine Absicherung.
Gefährliche Aktionen erfordern eine Bestätigung, und das Gerät sollte nach einem Verbindungsverlust ein sicheres Verhalten haben.
Womit ich anfange
Die meisten Probleme bei solchen Panels entstehen dadurch, dass die Oberfläche einen Zustand zeigt, den das Gerät nicht bestätigt hat. Deshalb beginne ich mit dem Zustandsautomaten der Verbindung und mit der Entscheidung, welche Aktionen optimistisch laufen dürfen, und Regler, Offline-Modus und Barrierefreiheit baue ich darauf auf. Wenn Sie ein Panel für Ihr eigenes Gerät brauchen, schauen Sie auf die Seiten Web-Apps, Android-Apps und Elektronik.
Quellen
MDN: Web Bluetooth API – https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API
MDN: Bluetooth.requestDevice() – https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice
MDN browser-compat-data (api.Bluetooth) – https://github.com/mdn/browser-compat-data/blob/main/api/Bluetooth.json
Can I use: Web Bluetooth – https://caniuse.com/web-bluetooth
Web Bluetooth CG: Implementation Status – https://github.com/WebBluetoothCG/web-bluetooth/blob/main/implementation-status.md
Chrome for Developers: New permission prompt for Local Network Access – https://developer.chrome.com/blog/local-network-access
Chrome Platform Status: Local network access restrictions – https://chromestatus.com/feature/5152728072060928
MDN: Navigator.onLine – https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
WAI-ARIA APG: Slider Pattern – https://www.w3.org/WAI/ARIA/apg/patterns/slider/
MDN: aria-live – https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live
WCAG 2.2: Understanding SC 4.1.3 Status Messages – https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html
WCAG 2.2: Understanding SC 1.4.1 Use of Color – https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html
