Skip to content

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 distinct cancelled variant with no thrown FirmwareError — a state you render, not an exception you catch.

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.

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.

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() 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 machine idle (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 FirmwareError on the machine’s error variant, which you render (error + retry). You do not catch these; they are a state.

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.

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.

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.