Skip to content

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.

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.

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 | null

useDevice(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.

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.

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
}
}

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.