Skip to content

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.

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"}
/>
);
}

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-less 4 × 500 m plan 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.activeDeviceId is null.

    // Two athletes racing — both machines run each interval at once.
    start({
    kind: "intervals",
    devices: roleMap(machines), // role-less intervals ⇒ broadcast
    intervals,
    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, and progress.activeDeviceId names 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 default untilUserContinues hold, 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 devicessequential 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 m continues exactly as programmed; session.devices still has its one entry. The bike sits ready in 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. CircuitScreen already queried useDevices({ connected: true }) and passed roleMap(machines). Its button now reads “Start on 2 machines”; pressing it arms a rower + bike pair through the same start() 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. WorkoutScreen renders two MachineTiles because session.devices.map always rendered N. Live metrics, drop/reconnect degradation, splits, reconciliation, and the result’s metric series all attribute per device through the same DeviceResult[] / 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.