Skip to content

Host integration

Everything an app needs to plug the SDK into its own infrastructure enters through config, once — not through hook arguments, not through a callback bag, not through a store swap. This guide covers the five host-integration seams: telemetry, error reporting, diagnostics, athlete parameters, and the imperative useConsoleClient escape hatch. All of them are narrow, typed, and optional; zero config is still a working config.

config.telemetry is a host-owned sink the SDK calls out to — construction-time dependency injection with no add/remove lifecycle and no subscription. It has two optional methods: event(e) receives the typed SdkTelemetryEvent union, and log(level, message, context?) receives diagnostic lines for your file/remote logger.

import { createConsoleConfig } from "@rogue/console-sdk";
import type { SdkTelemetryEvent } from "@rogue/console-sdk";
const config = createConsoleConfig({
telemetry: {
event(e: SdkTelemetryEvent) {
switch (e.type) {
case "connect.succeeded":
analytics.track("console_connected", {
model: e.model,
ms: e.durationMs,
});
break;
case "session.completed":
analytics.track("workout_done", {
seconds: e.durationSeconds,
aborted: e.aborted,
});
break;
// connect.failed | device.disconnected | session.started
// | capture.gap | firmware.update — all typed, never stringly.
}
},
log(level, message, context) {
remoteLogger.write(level, message, context);
},
},
});

SdkTelemetryEvent is an exhaustive, never-stringly-typed union, versioned additively — a switch over e.type narrows each variant’s fields. Because the sink is method-invoked, a this-bound sink (a ring-buffer class instance, say) works unchanged. A sink that throws is contained by the runtime’s reporter, so a bug in your telemetry can never break the SDK’s control flow.

Error reporting: onError, once per failure

Section titled “Error reporting: onError, once per failure”

config.onError(error, context) is the cross-cutting failure sink — for a global toast, crash reporting, or an error-rate metric. It fires exactly once per failure (once per error instance, not once per subscribed component), and it complements the state channels rather than replacing them: a failure that also lands on a hook’s error field or a session snapshot still reports here once.

import { createConsoleConfig } from "@rogue/console-sdk";
const config = createConsoleConfig({
onError: (error, context) => {
crashReporter.record(error, {
code: error.code, // the namespaced ConsoleSdkErrorCode
domain: context.domain, // 'connect' | 'command' | 'session' | 'firmware' | …
deviceId: error.deviceId ?? context.deviceId,
sessionId: context.sessionId,
});
},
});

The second argument, ErrorContext, says where the failure originated (domain, plus deviceId / sessionId when applicable). Two documented non-failures are deliberately excluded — a cancelled connect (connect/cancelled) and firmware cancel() never reach onError, because cancelling is not a failure (see error handling).

For a hardware-debugging screen, opt into raw-packet capture. It is off by default and never writes files:

import { createConsoleConfig } from "@rogue/console-sdk";
import type { RawPacketInfo } from "@rogue/console-sdk";
const config = createConsoleConfig({
diagnostics: {
rawPackets: true,
onRawPacket: (packet: RawPacketInfo) => devLogFile.append(packet.hex), // optional log tap
},
});

With rawPackets: true, a bounded in-memory ring backs useDiagnostics() — the reactive reader for a diagnostics screen:

import { useDiagnostics } from "@rogue/console-sdk";
function DiagnosticsScreen() {
const { packets, parseSuccessRate, clear } = useDiagnostics({
capacity: 100,
});
return (
<>
<Header rate={parseSuccessRate} onClear={clear} />
{packets.map((p) => (
<PacketRow key={p.at} dir={p.direction} hex={p.hex} parsed={p.parsed} />
))}
</>
);
}

Each RawPacketInfo carries a timestamp, the deviceId, a direction ('rx'/'tx'), the bytes as base64 and hex, and a parsed outcome (the decoded command name, or why decoding failed). parseSuccessRate is a well-defined 0 when the ring is empty — never NaN. When rawPackets is off, useDiagnostics() returns a stable empty state with a no-op clear(), so a dev screen renders harmlessly in production without a guard.

Athlete parameters: what the console needs

Section titled “Athlete parameters: what the console needs”

config.athlete carries the handful of athlete facts the console firmware uses — chiefly weight, which drives its calorie computation — plus a default display-unit preference. It is pushed to consoles on connect. There are no defaults: when the block is absent, nothing is pushed.

import { createConsoleConfig } from "@rogue/console-sdk";
const config = createConsoleConfig({
athlete: {
weightKg: 82, // feeds the console's calorie model
heightCm: 181,
sex: "male",
units: { pace: "per500m", distance: "metric" }, // default display units pushed on connect
},
});

This is the console-facing remnant of a profile — not profile management. Forms, persistence, and any server round-trip for the athlete’s profile stay in your app; the SDK only forwards the parameters the hardware consumes. Because a config change is a clean recreate, updating athlete params means building a new config object (see provider setup).

useConsoleClient: the imperative escape hatch

Section titled “useConsoleClient: the imperative escape hatch”

Hooks are the primary surface, but some call sites are not React function components — a class component, a non-React utility, an NFC/QR entry point that fires before any screen mounts. useConsoleClient() resolves the current provider’s ConsoleClient: snapshot reads plus rejecting commands, for exactly those cases.

import { useConsoleClient } from "@rogue/console-sdk";
function useDeepLinkConnect() {
const client = useConsoleClient();
return async (consoleId: string) => {
// Snapshot read — point-in-time, NOT reactive.
const already = client.devices.list({ connected: true });
if (already.length > 0) return already[0];
// Rejecting command — imperative callers want exceptions, not mirrored state.
return client.devices.connect({ consoleId });
};
}

The client has two surfaces: client.devices (list(query?) / get(id) snapshot reads; connect / disconnect / forget / controls(id) rejecting commands) and client.session (current() snapshot; start / end rejecting cores).

The contract, stated plainly:

  • It is not a singleton. Each provider assembles its own client, stable for that provider’s lifetime. useConsoleClient() returns the same object every render.
  • Reads are point-in-time, not reactive. client.devices.list() and client.session.current() read the current snapshot once — they do not subscribe. For live updates use useDevices / useWorkoutSession. The client deliberately has no subscribe surface.
  • Commands reject. Unlike the hooks’ never-rejecting bare verbs, client.devices.connect() and client.session.start() throw the typed error on failure — because imperative callers want a try/catch, not a state field to poll.

Reach for it only when a hook won’t fit; if you are in a function component, prefer the hooks.

Every seam above is set once on the config and never re-passed through hook arguments, navigation callbacks, or a redux bridge. That is the whole host-integration story: narrow typed config in, no lifecycle to manage, no readiness flag to poll (useConsoleSdk().status is reactive), and provider unmount is disposal. Cross-cutting host concerns enter here and nowhere else.