Transport & BLE
The transport is the single place hardware exists in the SDK. Everything above it — stores, engines, hooks — works in terms of typed frames and device ids, never BLE. This page explains how ble-plx’s event-driven world becomes React state without an event emitter, what the public transport contract is, and why that one seam is simultaneously the testing seam and the future-migration seam.
The problem: ble-plx is event-driven, React is not
Section titled “The problem: ble-plx is event-driven, React is not”react-native-ble-plx is callback-and-listener shaped: you register for
notifications, adapter-state changes, and disconnects, and it calls you back. The
SDK’s hard rule is the opposite — no event emitters, no listener registries, no
addListener/on/off anywhere, public or internal. Consumers receive data
only as reactive hook return values.
The transport is where those two worlds meet, and the meeting happens at the
lowest possible boundary: ble-plx’s callbacks are adapted into SDK stores right
at the transport edge, and from there up it is plain useSyncExternalStore
reactivity. A BLE notification never travels through the SDK as an event — it is
turned into store state at the door.
The seam: a factory plus a delegate
Section titled “The seam: a factory plus a delegate”The public contract is two pieces. A ConsoleTransport is a factory — a
function the SDK core invokes exactly once per provider lifetime. The SDK hands
that factory a TransportDelegate: a plain object of sink functions the
transport calls as hardware events occur.
type ConsoleTransport = ( delegate: TransportDelegate,) => ConsoleTransportInstance;
interface TransportDelegate { onAdapterChange(state: BluetoothState): void; // radio on/off/unauthorized/… onDeviceSeen(seen: SeenDevice): void; // a matching advertisement onLinkDown(deviceId: DeviceId, cause: LinkDownCause): void; // a link dropped (not user-requested) onFrame(deviceId: DeviceId, bytes: Uint8Array): void; // a notification frame arrived}This looks like pub/sub but is deliberately not pub/sub. A constructor-time
delegate handed to a single, known consumer with no add/remove, no
subscriber set, and no routing is dependency injection. There is exactly one
delegate, provided once, and the transport calls its four methods; nothing
subscribes, and nothing can unsubscribe. That distinction is what keeps the SDK
within hard rule 4 while still receiving hardware events.
The delegate’s four calls become React state through the store layer: an
onAdapterChange drives useBluetoothState(); an onDeviceSeen feeds the scan
store behind useDeviceScan / useDevices; an onLinkDown moves a device’s
connection state; an onFrame is decoded and coalesced into live-metrics and
session state. None of these is ever exposed as a callback you register.
Frame coalescing
Section titled “Frame coalescing”A busy console can push frames faster than a screen should re-render. The transport
boundary and the stores above it coalesce frames to at most one commit per
animation frame, so a chatty device never storms React. A component that selected
one field (useLiveMetrics(id, m => m.power.watts)) re-renders only when that
field changes, and even a torrent of frames collapses to one re-render per frame.
This is why the live-metrics surface can be a plain hook return value rather than a
throttled subscription you manage.
The transport instance
Section titled “The transport instance”The factory returns a ConsoleTransportInstance — the live object with the
per-device operations, all keyed by DeviceId because a transport manages N
concurrent links (multi-console is the model, not a variant): startScan /
stopScan, connect / disconnect, write, the permission calls, and dispose.
Crucially, the field-proven hard parts live inside the implementation as contracts, never as consumer choreography: connect de-duplication, the connect timeout with cancellation (so a slow attempt cannot leave a phantom link), the disconnect fallback ladder, the Android 12+ permission matrix, the MTU-517 request and negotiation, the write with/without-response fallback, notify-on-new-device scan surfacing, and availability TTLs. A consumer of the SDK sees none of this; a consumer writing a transport owns it.
Writing a custom transport
Section titled “Writing a custom transport”Because the seam is public, you can implement your own transport — for a different BLE library, a bench harness, or a proxy. You implement the factory, honor the delegate contract, and inject it via config:
import { createConsoleConfig } from "@rogue/console-sdk";import type { ConsoleTransport } from "@rogue/console-sdk";
const myTransport: ConsoleTransport = (delegate) => { // Wire your BLE stack's callbacks to delegate.onFrame / onDeviceSeen / // onLinkDown / onAdapterChange, and return the instance the SDK drives. return { startScan: async (filter) => { /* … */ }, stopScan: async () => { /* … */ }, connect: async (deviceId, { timeoutMs }) => { /* … */ return { mtu: 247, deviceInfo: { serial: null, firmware: null, hardware: null }, }; }, disconnect: async (deviceId) => { /* … */ }, write: async (deviceId, bytes, opts) => { /* … */ }, permissionStatus: async () => ({ status: "granted", canAskAgain: true }), requestPermissions: async () => ({ status: "granted", canAskAgain: true }), dispose: async () => { /* … */ }, };};
const config = createConsoleConfig({ transport: myTransport });The internal details the SDK relies on — GATT UUIDs, the device catalog, the
protocol codec, the ATT-overhead MTU math — are intentionally not part of this
contract. The ScanFilter names device models, not UUIDs; TransportLink reports
the negotiated MTU and the SDK’s codec fragments against min(negotiated − ATT overhead, catalog write size) internally. You supply bytes and links; the SDK owns
the protocol.
One seam, three jobs
Section titled “One seam, three jobs”Because there is exactly one transport contract, three things that are usually separate concerns collapse into the same mechanism:
- Production. The default transport,
createBleTransport(), wraps ble-plx and is applied lazily inside the provider — the inert config never constructs it. - Testing & demo.
createSimulatedTransport()(from@rogue/console-sdk/testing) implements the same contract and speaks real protocol bytes, so every engine and code path runs unchanged against it. Mocking is therefore a config swap, not a mode flag — there is nomockMode, nojest.mock, no module remapping. - Migration. Swapping the underlying BLE library, or moving to a future
transport, is another implementation of this same factory. No forked code paths,
no
if (useNewBle)branches — the seam absorbs it.
That the testing seam and the migration seam are the same seam is not a
coincidence; it is the point. One public contract with a single known consumer is
what lets “mock the hardware”, “swap the BLE stack”, and “run the real radio” all
be the same move: choose a different transport in config.
Reference
Section titled “Reference”ConsoleTransport·ConsoleTransportInstance·TransportDelegate·TransportLinkSeenDevice·ScanFilter·BluetoothState·LinkDownCause·BlePermissionStatuscreateSimulatedTransport·useBluetoothState·useBlePermissions- Related: Architecture · Connection lifecycle · Testing & mocking · Connecting devices.