Firmware updates
A connected console can check whether newer firmware exists and, if so, install
it over BLE. Two hooks cover the whole feature: useFirmwareStatus(deviceId)
reads installed-vs-latest, and useFirmwareUpdate(deviceId) drives the update as
a typed state machine. Both are per-device — like everything in the SDK, firmware
is fleet-shaped, so a list of consoles checks and updates each by id.
Two things make this surface unusual, and both are load-bearing:
- The update machine is provider-owned. Unmounting the screen mid-update does
not kill the transfer; a remounted
useFirmwareUpdate(id)re-attaches to the same live progress. - Cancelling is not an error.
cancel()drives the machine to a distinctcancelledvariant with no thrownFirmwareError— a state you render, not an exception you catch.
Checking for an update: useFirmwareStatus
Section titled “Checking for an update: useFirmwareStatus”useFirmwareStatus is the SDK’s one request-shaped read. It returns a
QueryResult — a discriminated union over status (pending / success /
error) with matching isPending / isSuccess / isError flags — so loading
and failure render next to live content rather than throwing a blank screen.
The manifest fetch is the SDK’s only network touch (see
the firmware config below).
import { useFirmwareStatus } from "@rogue/console-sdk";import type { DeviceId } from "@rogue/console-sdk";
function FirmwareCard({ deviceId }: { deviceId: DeviceId }) { const fw = useFirmwareStatus(deviceId);
switch (fw.status) { case "pending": return <Spinner label="Checking for updates…" />; case "error": // The last-known status (if any) is still on fw.data — stale-while-error. return ( <RetryRow message="Couldn't reach the update server" onRetry={fw.refresh} /> ); case "success": return fw.data.updateAvailable ? ( <UpdateBanner version={fw.data.latest?.version} notes={fw.data.releaseNotes} /> ) : ( <UpToDate installed={fw.data.installed.gd} /> ); }}fw.data is a FirmwareStatus: installed carries the device’s two firmware
versions (the main gd processor, always present; the optional rf radio, or
null), latest is the newest release on the configured channel (or null when
the manifest has no entry for this model), and updateAvailable comes from a
real segment-wise version comparison, never parseInt fuzzing — 1.10.3 is
correctly newer than 1.9.9.
Stale-while-error and per-model caching
Section titled “Stale-while-error and per-model caching”The error variant keeps the last-known data, so a transient manifest failure
never blanks a card that was already populated — you render the retry affordance
beside the stale status. Call fw.refresh() (the caller-friendly alias of
refetch()) to force a re-read.
Status is cached per model + channel inside the provider-owned engine, so
listing N devices of the same model triggers exactly one manifest fetch, not
N. For off-screen FlatList rows, pass { enabled: false } to hold the inert
pending state and issue no fetch until the row scrolls into view.
Running an update: useFirmwareUpdate
Section titled “Running an update: useFirmwareUpdate”useFirmwareUpdate(deviceId) returns a FirmwareUpdateResult — a discriminated
union whose every variant carries only the actions legal in that state. There
is no cancel() to call on an idle machine and no retry() on a running one,
because those methods are not in the type there. Render it with an exhaustive
switch:
import { useFirmwareUpdate } from "@rogue/console-sdk";import type { DeviceId } from "@rogue/console-sdk";
function FirmwareUpdater({ deviceId }: { deviceId: DeviceId }) { const update = useFirmwareUpdate(deviceId);
switch (update.status) { case "idle": // start() rejects if no update is available or the BLE bus is busy. return <Button title="Update" onPress={() => update.start()} />; case "updating": return ( <UpdateProgress phase={update.phase} // 'downloading' | 'verifying' | 'transferring' | 'finalizing' value={update.progress} // 0..1, weighted across phases onCancel={update.cancel} // synchronous; drives → 'cancelled' /> ); case "done": return <Done installed={update.installed} onDismiss={update.reset} />; case "cancelled": return <Resume onPress={update.reset} />; // reset() → 'idle' so you can offer "resume" case "error": return ( <RetryUpdate error={update.error} onRetry={update.retry} onDismiss={update.reset} /> ); }}The phase on the updating variant labels which leg is running so a progress
card can say what the engine is doing: pulling the image (downloading),
MD5-checking it (verifying), writing chunks over BLE (transferring — the long
leg), then the device applying and rebooting (finalizing). progress is a
single weighted 0..1 fraction across all phases, so one bar animates the whole
update.
start / retry failure semantics
Section titled “start / retry failure semantics”start() and retry() share one engine core, and the failure split matters:
-
A pre-flight failure — no update available, the device is not connected, or the BLE bus is occupied (
command/busy) — rejects the returned promise and leaves the machineidle(nothing was committed). Catch it to render the reason:const onPress = async () => {try {await update.start();} catch (e) {if (isCommandError(e) && e.code === "command/busy")toast("Finish the workout first");}}; -
A transfer-time failure — a download, verification, or transfer error once the update is running — resolves the promise and lands the typed
FirmwareErroron the machine’serrorvariant, which you render (error+retry). You do not catch these; they are a state.
Cancelling is a variant, not an error
Section titled “Cancelling is a variant, not an error”cancel() on the updating variant is synchronous and drives the machine to the
cancelled variant — with no FirmwareError, no rejected promise.
Cancellation is a normal outcome the user asked for, distinct from failure by
type: cancelled and error are different variants, so you can never
accidentally treat a deliberate cancel as a fault. From cancelled, reset()
returns the machine to idle so the UI can offer “resume”. This is the same
cancellation-is-not-an-error doctrine the SDK applies everywhere (see
error handling).
The engine is provider-owned — updates survive unmount
Section titled “The engine is provider-owned — updates survive unmount”The update machine lives on the provider-owned firmware engine, not on the
hook. That has a concrete consequence: if the athlete navigates away from the
update screen mid-transfer and comes back, the transfer never stopped, and a
freshly mounted useFirmwareUpdate(id) re-reads the live updating snapshot off
the firmware store and re-attaches to the same in-flight update — same phase,
same progress. You write no effect, no ref, and no cleanup to make this work; it
falls out of where the state lives. An unmounted screen is not a cancelled
update — only cancel() is.
Fleet-wide updating status
Section titled “Fleet-wide updating status”While an update runs, the device’s connection reflects it: device.connection.status
becomes 'updating' with a since and progress, so every screen renders
that console consistently — a tile elsewhere in the app shows “updating”, not a
stale “ready”. Because a session must never fight an OTA transfer for the BLE bus,
device.isConnected stays true during updating (the link is alive), and
workout arming is rejected with command/busy while a device is updating.
One BLE-heavy operation runs at a time, provider-wide: start() likewise rejects
with command/busy if a workout is active or another device is already updating.
See the connection lifecycle for the full state
machine.
Configuring the firmware channel
Section titled “Configuring the firmware channel”The firmware manifest fetch is the only network call the SDK makes. It is
unauthenticated and goes through config.firmware.fetch ?? global fetch. Point it
at a channel and (optionally) a manifest URL or a custom fetch:
import { createConsoleConfig } from "@rogue/console-sdk";
const config = createConsoleConfig({ firmware: { channel: "prod", // 'prod' (default) | 'dev' // manifestUrl defaults to the channel's production CDN root; override for a proxy. // fetch defaults to the global fetch; inject one for tests or a corporate proxy. },});There is no auth, no token, and nothing else on the wire — the SDK never phones
home for anything but this manifest. A manifest that cannot be fetched surfaces as
firmware/manifest-unavailable on the useFirmwareStatus error variant (with
the last-known status retained), recoverable via refresh(). For tests and demos,
inject firmware.fetch or use the in-memory manifest stub that
createTestConfig() wires up — no real network is
touched.
Reference
Section titled “Reference”useFirmwareStatus·FirmwareStatus·FirmwareStatusResult·FirmwareStatusOptions·FirmwareVersions·FirmwareLatest·QueryResultuseFirmwareUpdate·FirmwareUpdateResult·FirmwareUpdatePhaseFirmwareError·isFirmwareError·ConsoleConfigOptions- Related: Connection lifecycle · Error handling · Testing & mocking.