Skip to content

Live metrics & controls

A connected console streams a normalized frame at about 2 Hz. This guide covers reading it without over-rendering, the shape of the LiveMetrics frame, setting display units, bridging heart-rate, and the stream-reset rules that make a stale-data flash impossible.

useLiveMetrics(deviceId) returns the whole latest frame, or null when there is no device or no frame yet. But for tiles, always pass a selector — the tile then re-renders only when its selected value changes, so a watts change never re-renders the pace tile. Frames are coalesced to at most one per animation frame inside the store, so a chatty console never storms React.

import { useLiveMetrics } from '@rogue/console-sdk';
import type { DeviceId } from '@rogue/console-sdk';
function LiveTiles({ deviceId }: { deviceId: DeviceId }) {
const pace = useLiveMetrics(deviceId, (m) => m.pace.current); // number | null
const watts = useLiveMetrics(deviceId, (m) => m.power.watts); // isolated re-render
const cals = useLiveMetrics(deviceId, (m) => m.energy.calories);
return (
<Row>
<Tile label="Pace" value={pace} />
<Tile label="Watts" value={watts} />
<Tile label="Calories" value={cals} />
</Row>
);
}

The selector receives a non-null frame — the hook short-circuits the “no device / no frame yet” case itself and returns null without calling your selector, so you never write m?.pace. Pass a third isEqual argument for derived object/array selections that should compare by value.

There is no startMonitoring/stopMonitoring. Monitoring is refcounted the same way scanning is: a mounted useLiveMetrics(id) retains the device’s stream for its lifetime, and the transport monitor runs while at least one hook (or an active session) needs it. Out-of-session frames flow freely — a connected console streams to any mounted tile with no workout running.

Every firmware quirk is normalized at parse time, so the frame you read is clean: deciseconds arrive as fractional seconds, the rest-countdown sentinel is gone, and native bike paces ride alongside the normalized per-500m pace. The frame is a set of typed blocks:

// state: { workout: 'idle' | 'working' | 'resting' | 'finished', workoutType, round, isInRest }
// time: { elapsedSeconds, splitSeconds, restRemainingSeconds, ... }
// distance: { totalMeters, splitMeters, stageMeters }
// pace: { current, average, segment, native: { per1000m, perMile } } // seconds/500m
// power: { watts, averageWatts, segmentWatts }
// cadence: { rate, averageRate, strokeCount } // SPM or RPM
// energy: { calories, workoutCalories, caloriesPerHour, ... }
// heartRateBpm, batteryPercent, lastSplit, extended // extended: V2+ tail

pace.current and its siblings are always normalized seconds-per-500m; pace.native carries the firmware’s own per-1000m / per-mile paces (bikes), which match the console screen exactly. Branch on state.workout for a typed workout state — there are no FINISH = 0 magic numbers.

useConsoleControls(deviceId) exposes the per-device console controls. In M2 the surface is deliberately minimal — setDisplayUnits only. It is a bare verb: it never rejects (a command on a non-ready device is a dev-warned no-op routed to onError), so a picker’s onChange is safe fire-and-forget. The reactive result lands on device.displayUnits.

import { useConsoleControls } from '@rogue/console-sdk';
function UnitPicker({ device }: { device: Device }) {
const { setDisplayUnits, setDisplayUnitsAsync } = useConsoleControls(device.id);
return (
<Picker
value={device.displayUnits}
onChange={(units) => setDisplayUnits(units)} // fire-and-forget; never rejects
/>
);
}

Use the rejecting twin setDisplayUnitsAsync when you need to await the write or handle its failure imperatively. Per-device pace-unit derivation is handled inside the engine.

The SDK does not own watch connectivity — your app feeds it a bpm. Mount useHeartRateBroadcast(bpm) and while it is mounted, each fresh reading is fanned out to every ready console (deduped and rate-limited per device) and recorded into the active session’s heart-rate track. There is zero cleanup: unmount removes the registration.

import { useHeartRateBroadcast } from '@rogue/console-sdk';
function HeartRateBridge() {
const bpm = useWatchHeartRate(); // your app-owned watch integration
useHeartRateBroadcast(bpm); // fans out to all ready consoles while mounted
return null;
}

Pass { devices: [id] } to restrict the fan-out to a subset; null/undefined bpm sends nothing.

Stream reset — why stale data cannot flash

Section titled “Stream reset — why stale data cannot flash”

The live stream resets to null at two moments: when a session starts, and when a completed session is dismissed. Because useLiveMetrics returns null on a reset frame (and your selector is never called on it), a tile shows its empty state rather than a leftover number from the previous workout — a stale-data flash is structurally impossible, not something you guard against.