Skip to content

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

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

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.

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 no mockMode, no jest.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.