Interfejs do sprzętu wygląda jak zwykły formularz: przycisk, suwak, przełącznik. Tyle że po drugiej stronie jest urządzenie, które bywa wyłączone, poza zasięgiem, odpowiada z opóźnieniem albo zmienia stan samo. Dobry UI musi to uczciwie pokazać. Poniżej zasady, których pilnuję przy takich panelach. Przykłady kodu są w React i TypeScript, ale te same reguły dotyczą aplikacji natywnej. W WireAmbi, mojej aplikacji na Androida do sterowania taśmą LED w aucie, kilka z nich wyszło dopiero przy testach. Opisuję to we wpisie o taśmie LED i protokole bez dokumentacji.
1. Połączenie to maszyna stanów, a nie isConnected: boolean
Flaga true/false szybko prowadzi do dziwnych sytuacji: aktywny przycisk „Połącz” w trakcie łączenia, dwa równoległe połączenia, brak informacji o ponawianiu. Lepiej nazwać stany i dozwolone przejścia:
CONNECT CONNECTED
DISCONNECTED -------- CONNECT CONNECTED
DISCONNECTED ----------> CONNECTING ---------------- CONNECTING ------------------> CONNECTED
| ^ |
FAILED, limit prób | | RETRY | CONNECTION_LOST
wyczerpany | | (po odczekaniu) |
+---------------+ | |
| | | |
v FAILED, | | |
ERROR są jeszcze| | |
| próby v | |
| RECONNECTING <------------------------+
|
+--- CONNECT - CONNECTED
| ^ |
FAILED, limit prób | | RETRY | CONNECTION_LOST
wyczerpany | | (po odczekaniu) |
+---------------+ | |
| | | |
v FAILED, | | |
ERROR są jeszcze| | |
| próby v | |
| RECONNECTING <------------------------+
|
+--- CONNECT ---> CONNECTING
DISCONNECT z dowolnego stanu - CONNECTING
DISCONNECT z dowolnego stanu ---> DISCONNECTED
Każdy stan ma swój wygląd:
Stan | Co widzi użytkownik | Kontrolki |
|---|---|---|
rozłączony | przycisk „Połącz” | nieaktywne albo tryb offline |
łączenie | wskaźnik postępu, numer próby, „Anuluj” | nieaktywne |
połączony | nazwa urządzenia, wyraźny status | aktywne |
ponowne łączenie | „Utracono połączenie, ponawiam…”, „Spróbuj teraz” | zablokowane |
błąd | co się stało i co można zrobić | „Spróbuj ponownie” |
Hook poniżej trzyma logikę w reducerze, a efekty uboczne wynikają tylko z bieżącego stanu. Transport jest wstrzykiwany, więc ten sam kod obsłuży BLE i WebSocket.
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; // zwraca "unsubscribe"
}
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]);
// Efekty uboczne wynikają wyłącznie z bieżącego stanu.
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" });
},
};
}
Wykładniczy backoff oszczędza urządzenie i baterię, a po wyczerpaniu limitu prób decyzję podejmuje użytkownik.
2. Opóźnienia: optimistic UI czy potwierdzenie z urządzenia?
Optimistic UI zmienia interfejs od razu i cofa zmianę, gdy urządzenie jej nie przyjmie. Pasuje do akcji tanich i odwracalnych, jak jasność czy kolor. Czekanie na potwierdzenie („w toku” aż do odpowiedzi) wybieramy dla akcji z fizycznymi skutkami lub trudnych do cofnięcia: silnik, grzałka, zapis konfiguracji, aktualizacja firmware’u. Reguła: im droższa pomyłka, tym bardziej czekamy na urządzenie.
Druga zasada: źródłem prawdy jest urządzenie. Stan zmienia się też bez UI (przycisk fizyczny, drugi telefon, restart), więc subskrybuj zmiany (notyfikacje BLE, wiadomości WebSocket) i po każdym połączeniu odczytuj pełny stan.
Skrajny przypadek to sterownik taśmy w WireAmbi, który w ogóle nie odsyła stanu. Aplikacja musi sama pamiętać, co ustawiła, a jedynym potwierdzeniem jest udany zapis Bluetooth. Stan zgłasza dopiero brama led-auto na Raspberry Pi, która w aucie trzyma połączenie z taśmą.
3. Trzy warstwy feedbacku
Natychmiastowy, lokalny: kontrolka reaguje od razu, bez czekania na sieć („przyjąłem intencję”).
Potwierdzony: urządzenie odpowiedziało; wystarczy subtelny znacznik ✓.
Timeout: każda komenda ma termin ważności. Po nim wracamy do ostatniej potwierdzonej wartości i proponujemy ponowienie, ale nie krzyczymy błędem po każdej zgubionej ramce podczas przeciągania suwaka. Wartości timeoutów lepiej zmierzyć niż zgadywać.
4. BLE a Wi‑Fi z perspektywy UI
Bluetooth Low Energy w przeglądarce (Web Bluetooth):
navigator.bluetooth.requestDevice()otwiera systemowe okno wyboru urządzenia i wymaga HTTPS oraz gestu użytkownika (MDN). Pierwsze połączenie musi więc wynikać z kliknięcia.getDevices()(ponowne użycie wybranych wcześniej urządzeń) jest w Chrome wciąż za flagą, więc po przeładowaniu strony użytkownik zwykle wybiera urządzenie od nowa.Zasięg jest krótki, zerwania są normalne; „ponowne łączenie” będzie częste.
Wsparcie (28.09.2026, MDN i caniuse): API eksperymentalne, spoza Baseline. Działa w Chrome, Edge i Operze na desktopie, w Chrome na Androidzie i Samsung Internet; nie działa w Firefoksie ani Safari (także iOS). Według „Implementation Status” Web Bluetooth CG Chrome na Linuksie ma implementację częściową, niewspieraną i za flagą. Potrzebna jest ścieżka awaryjna: aplikacja natywna, inny transport albo czytelny komunikat.
Do częstych zmian (suwaki) jest
writeValueWithoutResponse(), do ważnych komendwriteValueWithResponse().
Wi‑Fi w sieci lokalnej:
Urządzenie trzeba najpierw wprowadzić do sieci; ten onboarding bywa najtrudniejszym ekranem aplikacji.
WebSocket nadaje się do dwukierunkowej wymiany i wypychania zmian stanu, HTTP do pojedynczych komend i konfiguracji.
Strona z publicznego HTTPS łącząca się z urządzeniem w LAN trafia w Chrome na Local Network Access: od Chrome 142 wymaga to zgody użytkownika, a kolejne wersje rozszerzają te ograniczenia na inne rodzaje połączeń. Po zgodzie Chrome łagodzi blokadę mixed content dla adresów prywatnych i domen
.local. Dla UI to kolejny stan: „odmówiono dostępu do sieci lokalnej”. Innych przeglądarek pod tym kątem nie sprawdzałem.
WireAmbi łączy się z taśmą bezpośrednio przez BLE. Gdy w aucie działa brama led-auto, telefon rozmawia z nią przez szyfrowany kanał WireLink, bo sterownik taśmy przyjmuje tylko jedno połączenie naraz.
5. Sterowanie ciągłe: suwaki jasności i koloru
Przeciągany suwak generuje lawinę zdarzeń input. Wysyłanie każdego tworzy kolejkę: suwak dawno puszczony, a lampa wciąż „dogania” stare wartości. Debounce (wysyłka po zatrzymaniu) usuwa kolejkę, ale zabiera podgląd na żywo. Najlepiej działa throttling „ostatnia wartość wygrywa”:
wysyłamy najwyżej co N milisekund,
w danej chwili w drodze jest najwyżej jedna komenda,
z wartości zebranych w międzyczasie wysyłamy tylko najnowszą, bez kolejki,
ostatnia wartość po puszczeniu suwaka zawsze dociera do urządzenia.
// Throttling "ostatnia wartość wygrywa": bez kolejki, max 1 komenda w drodze.
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())); // wyślij nowszą, jeśli czeka
}
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));
});
}
withTimeout owiń wywołanie wewnątrz send, żeby zawieszona komenda nie blokowała kolejnych. W przykładzie błąd jest tylko ogłaszany; cofnięcie suwaka do confirmed to jedna linijka.
import { useEffect, useMemo, useState } from "react";
import { createLatestValueSender } from "./latestValueSender";
type Props = {
confirmed: number; // ostatnia wartość potwierdzona przez urządzenie
disabled: boolean; // np. gdy połączenie !== "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]); // zmiana z urządzenia
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); // natychmiastowy lokalny feedback
setError(null);
sender.push(v);
}}
/>
<output htmlFor="brightness">{local}% {pending ? "(czekam na potwierdzenie)" : "✓"}</output>
<p role="status">{error ?? ""}</p>
</div>
);
}
Interwał 80 ms to tylko przykład, dobierz go pomiarem. Funkcja send musi być stabilna (np. z useCallback), bo inaczej każdy render tworzy nowy obiekt wysyłający i throttling przestaje działać. Warto też ignorować wartości z urządzenia w trakcie przeciągania, bo inaczej suwak „skacze” pod palcem.
W WireAmbi tę rolę pełni bufor na jeden element: kolory liczone na bieżąco trafiają do niego i zostaje tylko najnowszy, a kolejny zapis Bluetooth idzie dopiero po potwierdzeniu poprzedniego przez stos. Wcześniej była tam kolejka bez limitu i im dłużej grała muzyka, tym starsze kolory pokazywała taśma.
6. Dostępność
Natywny
<input type="range">daje obsługę klawiatury zgodną ze wzorcem slidera WAI-ARIA APG (strzałki, Home/End, Page Up/Down).aria-valuetextpozwala przeczytać „50%, wysyłanie” zamiast gołej liczby.Status połączenia i błędy ogłaszaj przez
role="status"lubaria-live="polite"(WCAG 2.2, kryterium 4.1.3), ale nie każdy krok suwaka.Nie przekazuj statusu samym kolorem (kryterium 1.4.1): zielona kropka zawsze z tekstem „Połączono”.
Obok koła kolorów daj pole HEX lub presety.
7. Tryb offline
„Offline” to brak internetu albo brak urządzenia. navigator.onLine mówi tylko o interfejsie sieciowym; true nie gwarantuje nawet internetu (MDN), a o urządzeniu nie mówi nic. Sterowanie lokalne (BLE, LAN) nie powinno zależeć od internetu, więc interfejs warto serwować z cache. Gdy urządzenie jest niedostępne, pokaż ostatni znany stan jako nieaktualny („ostatnio widziane o…”) i nie kolejkuj po cichu komend z fizycznymi skutkami.
8. Bezpieczeństwo
Web Bluetooth wymaga bezpiecznego kontekstu, zgody na konkretne urządzenie i podlega Permissions Policy
bluetooth(MDN). To baza, nie zamiennik zabezpieczeń w urządzeniu.„Lokalna” nie znaczy „bezpieczna”: Local Network Access powstało m.in. przeciw atakom CSRF stron na routery. Urządzenie powinno uwierzytelniać klienta (kod parowania, token).
Waliduj w firmware (zakresy, limit częstotliwości komend); ograniczenia w UI to wygoda, nie zabezpieczenie.
Akcje niebezpieczne wymagają potwierdzenia, a urządzenie powinno mieć bezpieczne zachowanie po utracie połączenia.
Od czego zaczynam
Najwięcej kłopotów w takich panelach bierze się z tego, że interfejs pokazuje stan, którego urządzenie nie potwierdziło. Dlatego zaczynam od maszyny stanów połączenia i od decyzji, które akcje mogą działać optymistycznie, a suwaki, tryb offline i dostępność dokładam na tej podstawie. Jeśli potrzebujesz panelu do własnego urządzenia, zajrzyj na strony aplikacje webowe, aplikacje na Androida i elektronika.
Źródła
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
