Connecting devices
This guide covers discovery and connection: reactive Bluetooth state, declarative scanning, querying the one unified device list, all four ways to name a connect target, and reading connect failures as data. It is written against a fleet — “a console”, never “the console” — because that is the only model there is.
Bluetooth and permissions gate every connect
Section titled “Bluetooth and permissions gate every connect”Two reactive hooks tell you whether you can connect at all:
import { useBluetoothState, useBlePermissions } from '@rogue/console-sdk';
const bluetooth = useBluetoothState(); // 'unknown' | 'unsupported' | 'unauthorized' | 'off' | 'on'const [permissions, requestPermissions] = useBlePermissions();useBluetoothState() starts 'unknown' and settles to the adapter state; the
Android 12+ / legacy-location / iOS permission matrix is entirely inside the
transport, so you branch on bluetooth === 'off' and permissions.status, never
on platform. requestPermissions() prompts once, is idempotent, and never
rejects. The first-connection tutorial
shows the full BluetoothGate component.
Scanning is declarative and refcounted
Section titled “Scanning is declarative and refcounted”There is no startScan()/stopScan(). Mount useDeviceScan() and a scan runs;
unmount it and the scan stops. Scanning is refcounted across every mounted
useDeviceScan in your tree — the transport scans once for the first retainer
and stops when the last releases. Gate it declaratively with enabled:
import { useDeviceScan } from '@rogue/console-sdk';
function ScanWhileFocused() { const isFocused = useIsFocused(); const { isScanning, error } = useDeviceScan({ enabled: isFocused }); // results surface on useDevices() presence — never as a callback}The hook returns only scan status ({ isScanning, error }); the devices
themselves surface on the unified list, next.
One list: useDevices and useDevice
Section titled “One list: useDevices and useDevice”Discovered, saved, and connected are facets of one Device record, not three
lists to reconcile. useDevices(query) returns the whole unified list, filtered
and in a deterministic, stable order (connected → saved → discovered-only; never
live-RSSI-sorted, so rows never jump under a finger). Every field of the query is
a conjunctive predicate; omit a field to match everything on that axis.
import { useDevices, useDevice } from '@rogue/console-sdk';
// Everything the SDK knows about — saved, nearby, connected.const all = useDevices();
// A connect screen: nearby-right-now OR already connected.const nearby = useDevices({ available: true });
// Just the connected consoles.const machines = useDevices({ connected: true });
// The N=1 convenience — first match in fleet order, or null.const rower = useDevice({ modality: 'rower', connected: true }); // Device | nulluseDevice(query) is one-line sugar for useDevices(query)[0] ?? null — the
same primitives, not a separate single-console API. Both return their zero state
([] / null) from the first render, so you can map or read immediately.
Reading connection state
Section titled “Reading connection state”Every device carries its connection lifecycle as data on device.connection —
there is no onDisconnect callback anywhere in the SDK. Branch on status:
function ConnectionBadge({ device }: { device: Device }) { const c = device.connection; switch (c.status) { case 'disconnected': return c.cause === 'never-connected' ? null : <Text>Disconnected</Text>; case 'connecting': return <Spinner label={c.step} />; // link → negotiating → identifying → clock-sync case 'ready': return <Dot color={c.link === 'unresponsive' ? 'amber' : 'green'} />; case 'reconnecting': return <Text>Reconnecting (attempt {c.attempt})…</Text>; case 'updating': return <Progress value={c.progress} />; }}The full state machine — including why reconnecting is degraded-not-dead and
how via provenance drives reconnect toasts — is in
Connection lifecycle.
Connecting: four ways to name a target
Section titled “Connecting: four ways to name a target”useConnectDevice() gives you the paired-verb connect. The bare connect(target)
never rejects (failure routes into errors, keyed by device); connectAsync
is the rejecting twin for imperative flows. A ConnectTarget is any of:
import { useConnectDevice } from '@rogue/console-sdk';
const { connect, connectAsync, isPending, pendingIds, errors, reset } = useConnectDevice();
// 1. A Device row you already rendered.connect(device);
// 2. A DeviceId string.connect(device.id);
// 3. A user-visible console id — awaits discovery (NFC / QR / label).connect({ consoleId: '400123' });
// 4. The strongest-signal device of a model or modality.connect({ nearest: 'echo-bike' });connect({ nearest: 'rower' });Forms 3 and 4 do not need the device to be on the list yet — the engine awaits a
matching advertisement, so an NFC tap can connect({ consoleId }) before any scan
UI has shown the device. For imperative NFC/QR flows, prefer the rejecting twin:
async function onTag(consoleId: string) { try { const device = await connectAsync({ consoleId }); navigation.navigate('Session', { deviceId: device.id }); } catch (error) { // typed ConnectError — handle it imperatively here }}Connect failures are data
Section titled “Connect failures are data”Because connect never rejects, failures arrive in errors — one entry per
failed device, so two rows tapped quickly each report their own failure without
overwriting each other. reset(id?) clears one device’s error, or all.
import { isConnectError } from '@rogue/console-sdk';
for (const { deviceId, error } of errors) { switch (error.code) { case 'connect/device-limit': // The fleet is already at maxDevices — prompt to disconnect one. break; case 'connect/modality-conflict': // A console of this modality is already connected (onePerModality). // error.conflictingDeviceId names it → offer a "swap machines" action. break; case 'connect/timeout': case 'connect/not-found': // Transient — offer retry. break; }}connect/device-limit and connect/modality-conflict are the two policy errors
worth a bespoke UX. The modality conflict carries the conflicting device so you
can offer a one-tap “use this machine instead” that disconnects the incumbent and
connects the new one. Concurrent connects are legal and each tracked
independently — pendingIds lists the in-flight devices whose id is known, and
isPending is true while any connect (including an unresolved { consoleId })
is in flight.