Multi-console
Multi-console support is not a feature you opt into — it is the shape of the
whole SDK. There is one set of primitives, and they are fleet-shaped: every
per-device hook takes a DeviceId, session.devices is always an array, and
start() programs a RoleAssignment. A single console is simply the case where
the fleet has one member.
This guide proves the binding constraint end to end: connecting a second console requires zero API changes in your code. The same screen that ran one rower this morning runs a rower + bike pair this afternoon — not one line different.
One screen, any N: the circuit
Section titled “One screen, any N: the circuit”The screen below works identically for 1, 2, 3, or 4 machines. It queries the
connected consoles, builds a RoleAssignment from them with roleMap(), and
maps over session.devices to render. Nothing here names “the console”; the
arrays simply grow.
import { useDevices, useDevice, useWorkoutSession, useWorkoutControls, useLiveMetrics, roleMap,} from "@rogue/console-sdk";import type { DeviceId, IntervalSpec, Modality } from "@rogue/console-sdk";
function CircuitScreen({ intervals }: { intervals: readonly IntervalSpec[] }) { const machines = useDevices({ connected: true }); const session = useWorkoutSession(); const { start, end } = useWorkoutControls();
if (session.phase === "idle") { return ( <StartButton disabled={machines.length === 0} // 1 machine ⇒ 1 role; N machines ⇒ N roles — the SAME call. onPress={() => start({ kind: "intervals", intervals, devices: roleMap(machines) }) } /> ); }
return ( <> {session.phase !== "completed" && session.phase !== "failed" && session.phase !== "aborted" && session.devices.map((d) => ( <MachineTile key={d.deviceId} deviceId={d.deviceId} role={d.role} /> ))} {session.phase === "resting" && session.rest.kind === "untilUserContinues" && ( <Button title="Continue" onPress={session.rest.continueFromRest} /> )} {(session.phase === "running" || session.phase === "resting") && ( <EndButton onPress={end} disabled={!session.progress.canEndEarly} /> )} </> );}
function MachineTile({ deviceId, role,}: { deviceId: DeviceId; role: Modality;}) { const power = useLiveMetrics(deviceId, (m) => m.power.watts); // re-renders on power only const device = useDevice(deviceId); return ( <Tile title={`${device?.displayName} · ${role}`} value={power} degraded={device?.connection.status === "reconnecting"} /> );}roleMap and RoleAssignment
Section titled “roleMap and RoleAssignment”A session is keyed by role, and a role is a modality (rower, bike,
ski, rhino). RoleAssignment is exactly that map:
type RoleAssignment = Partial<Record<Modality, DeviceId>>;roleMap(devices) builds one from a device list. It ignores any device
reporting no modality, so you can pass a full
useDevices() result through unfiltered. Because a session cannot contain two
devices of the same modality, roleMap throws a SessionError
'session/invalid-plan' if two consoles share a modality — catch it (or
pre-filter) to offer a “which rower?” picker:
import { roleMap, isSessionError } from "@rogue/console-sdk";
try { await start({ kind: "intervals", intervals, devices: roleMap(machines) });} catch (e) { if (isSessionError(e) && e.code === "session/invalid-plan") { promptWhichMachine(); // two of the same modality — let the user choose }}The one-device-per-modality invariant holds even when the fleet is configured
with onePerModality: false: that policy governs connection; a single
session is still one device per role.
Broadcast vs sequential (relay), at the fleet level
Section titled “Broadcast vs sequential (relay), at the fleet level”The interval choreography is chosen by an explicit mode discriminator on the
intervals plan — not inferred from role — and the two modes never mix in one
plan:
-
mode: 'broadcast'(the default) — every machine in the session runs each interval simultaneously, and intervals are role-less. A role-less4 × 500 mplan makes a rower and a bike each cover 500 m at the same time. This is exactly what makes N=1 code valid at N=2 by definition, and it is the choreography every snippet on this page uses.progress.activeDeviceIdisnull.// Two athletes racing — both machines run each interval at once.start({kind: "intervals",devices: roleMap(machines), // role-less intervals ⇒ broadcastintervals,rounds: 3,}); -
mode: 'sequential'— one athlete moves machine-to-machine; only the active role’s console runs at a time while the others hold (the turn-based relay). Every interval is role-scoped, andprogress.activeDeviceIdnames whose turn it is.// One athlete: bike 1000 m → rower 500 m → ski 500 m, one leg live at a time.start({kind: "intervals",mode: "sequential",devices: { bike: bikeId, rower: rowerId, ski: skiId },intervals: [{ role: "bike", work: { kind: "distance", meters: 1000 } },{ role: "rower", work: { kind: "distance", meters: 500 } },{ role: "ski", work: { kind: "distance", meters: 500 } },],});The hand-off between legs is driven by the completed leg’s
rest. With the defaultuntilUserContinueshold, the relay advances when the athlete either moves to the next machine (its first motion ends the hold) or taps Continue (rest.continueFromRest()) on the just-finished console. A{ kind: 'none' }rest hands off immediately with no wait — the bike → rower → ski intra-round transition of a Mikko-style triangle. See the Rests section for all three behaviors.
Both modes go through the same start() call, the same IntervalSpec[], and
the same devices — sequential is one additive field, never a second hook or a
parallel API. mode is picked explicitly: the normalizer defaults it to
'broadcast' only when it is omitted and every interval is role-less. A plan
whose mode and roles disagree — a role-scoped plan with no mode, a
broadcast plan that scopes any interval to a role, or a sequential plan
with an interval missing its role — is rejected with 'session/invalid-plan'
before it arms anything.
For a builder that authors both modes (and the role-scoped Mikko’s Triangle
relay) from UI state and enforces this coherence before start(), see the
Building a workout guide — the worked example is
the example app’s WorkoutBuilderScreen, which drives the same fleet path this
guide describes.
A second console joins mid-session — zero API change
Section titled “A second console joins mid-session — zero API change”Here is the constraint made concrete. It is the same story the executing-workouts guide tells for one machine, now scaled — and the walkthrough is a description of behavior, not new code, because there is no new code.
Picture the athlete mid-round on the rower. They wheel an Echo Bike over and tap Connect in the app header — which just opens the same connect screen from the connecting-devices guide.
- Scanning during a live session is the same call.
useDeviceScan()mounts; the fleet policy applies its in-workout cooldown internally. Nothing in your code knows or cares that a session is running. - Connecting is the same call.
connect(item)on the bike row. The fleet grows to two. The auto-reconnect toast, the manage-devices screen, every machine tile — everything written for one console — now renders the bike wherever its query matches, because they were all written against arrays and queries, never against “the console.” - The running session is untouched. A session’s device roster is an armed
transaction (pre-arm plus rollback is what makes multi-machine starts
reliable), so the rower’s
4 × 500 mcontinues exactly as programmed;session.devicesstill has its one entry. The bike sitsreadyin the fleet:useDevices({ connected: true })now returns two devices, and a fleet strip rendered from it shows both. - The next workout uses both — with the code above, unchanged.
CircuitScreenalready querieduseDevices({ connected: true })and passedroleMap(machines). Its button now reads “Start on 2 machines”; pressing it arms a rower + bike pair through the samestart()call with the same input. The role-less intervals broadcast, so both machines run each 500 m simultaneously — code that worked at N=1 is defined to be valid at N=2, never a'session/invalid-plan'surprise.WorkoutScreenrenders twoMachineTiles becausesession.devices.mapalways rendered N. Live metrics, drop/reconnect degradation, splits, reconciliation, and the result’s metric series all attribute per device through the sameDeviceResult[]/WorkoutMetricSeries[]arrays that had one entry this morning.
Not one import, hook call, type, or component signature changed between N=1 and N=2 — the arrays grew. That is the entire scaling story, and it is the binding constraint made concrete: single-console usage was never a different API, only a smaller fleet.