Przejdź do treści
Wszystkie wpisy

22 października 20269 min czytania

Interfejs do sterowania urządzeniem: stany, opóźnienia i suwaki

Po drugiej stronie przycisku jest urządzenie, które bywa wyłączone, poza zasięgiem albo odpowiada z opóźnieniem. Opisuję, jak projektuję stany połączenia, potwierdzenia, suwaki i tryb offline, żeby panel nie wprowadzał w błąd.

Interfejs do sterowania urządzeniem: stany, opóźnienia i suwaki

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:

tekst
               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.

TypeScript
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

  1. Natychmiastowy, lokalny: kontrolka reaguje od razu, bez czekania na sieć („przyjąłem intencję”).

  2. Potwierdzony: urządzenie odpowiedziało; wystarczy subtelny znacznik ✓.

  3. 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 komend writeValueWithResponse().

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.

TypeScript
// 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.

TSX
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-valuetext pozwala przeczytać „50%, wysyłanie” zamiast gołej liczby.

  • Status połączenia i błędy ogłaszaj przez role="status" lub aria-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

Napisz