Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Ride Control

Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 and Zwift Cog, with initial support for Zwift Click V2.
Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 and Zwift Cog, with standards-based support for Bluetooth FTMS trainers such as the Elite DIRETO XR-T and initial support for Zwift Click V2.

[Open Ride Control](https://ridecontrol.xyz)

Expand All @@ -11,7 +11,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- Manages the smart trainer, heart rate monitor, and the physical `+` Zwift Click V2 controller independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, one animated `Connecting...` label in device details and reconnect buttons, delayed recovery guidance for unusually long reconnects only while Chrome automatic reconnect is configured and a remembered device remains disconnected, and a green indicator once every paired device is ready. Ride Control currently exposes only the reliable `+` controller while retaining an extensible controller-slot model for future hardware support. Its role-specific Bluetooth filter selects the advertised right-side controller, the physical `+` button shifts up, and the blue `Y` button shifts down; the controller row briefly identifies those inputs as `+` and `−` while they are pressed. Pairing reads and remembers the controller's standard firmware revision and battery level when available, live Zwift battery notifications keep the percentage current, and the panel flags versions other than `1.2.0` with a direct link to the official Zwift Companion update instructions. The saved controller reconnects during any open session, including its initial or inactivity-triggered auto-pause, and keeps retrying after sleep so virtual shifts are ready when riding resumes. It may disconnect during an explicit manual pause or after the session ends to preserve its battery. The controller is not reported ready until its notification stream produces data, and Click presses made while the paired-devices panel is open stay in setup and do not shift the ride.
- Detects browsers outside the currently tested Chrome environment and replaces the pairing controls with a compatibility notice, while showing Chrome's automatic-reconnect setup steps directly in the paired-devices panel only when its persistent permission capability is unavailable and confirming when it is configured correctly.
- Shows each deployment's build time in the viewer's local timezone and links it to the GitHub pull request that produced the build, falling back to the closed pull-request list when no associated PR is available.
- Connects to compatible bike trainers and standard Bluetooth heart rate monitors through Web Bluetooth, remembers authorized devices, and restores the trainer, heart-rate monitor, and `+` Click controller from one browser permission snapshot after a reload. The trainer and heart-rate monitor begin reconnecting immediately and independently; the remembered Click controller joins those parallel attempts while a session is open and not manually paused. Trainers and the active Click controller keep advertisement discovery active through the GATT handshake so Chrome can react as soon as they broadcast, while heart-rate monitors use direct GATT retries because common HRMs do not reliably surface advertisements through Chrome's watcher. A shared coordinator deduplicates requests to the same physical device without letting a slow sensor block the others, and each device's service and notification setup stays sequential for reliable GATT communication.
- Connects to compatible bike trainers and standard Bluetooth heart rate monitors through Web Bluetooth, remembers authorized devices, and restores the trainer, heart-rate monitor, and `+` Click controller from one browser permission snapshot after a reload. Trainer pairing is based on the advertised Bluetooth FTMS capability instead of a vendor-specific name, allowing the same adapter to support Wahoo, Elite, and other standards-compliant trainers while keeping one active trainer for a ride. FTMS control commands wait for the trainer's matching acknowledgement and establish control with the standard Request Control and Start/Resume procedures before resistance is restored. The trainer and heart-rate monitor begin reconnecting immediately and independently; the remembered Click controller joins those parallel attempts while a session is open and not manually paused. Trainers and the active Click controller keep advertisement discovery active through the GATT handshake so Chrome can react as soon as they broadcast, while heart-rate monitors use direct GATT retries because common HRMs do not reliably surface advertisements through Chrome's watcher. A shared coordinator deduplicates requests to the same physical device without letting a slow sensor block the others, and each device's service and notification setup stays sequential for reliable GATT communication.
- Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes.
- Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback, shows smoothing progress inside the slider thumb, and records resistance changes alongside the other ride metrics.
- Offers original terrain workouts built as repeatable courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Courses explicitly support loops, point-to-point routes, and out-and-back routes; an out-and-back follows the supplied path to its turnaround, then reverses the same location and elevation data back to the start before repeating. Prairie Roll adds a non-intersecting, curving 15-mile loop of long, gradual rollers centered around 20% resistance and ranging from roughly 15–25%. Granite Switchbacks adds a sustained four-mile ascent whose hairpin corners briefly get steeper before immediately returning to the steady climbing grade, followed by a ridge and a descending sequence of five recovery rollers. Ridgeline Time Trial is a ten-mile out-and-back with a gradual five-mile, roughly 300-foot hillclimb to the turnaround and the identical terrain in reverse on the return. Every course begins flat without giving nearly level routes an unnecessarily long rollout: low-climb courses use about 0.4 km, moderate rollers use about 0.8 km, and climbing-focused courses retain a 1.5 km rollout. The course then automatically adjusts trainer resistance from the current grade, tracks the rider in compact, vertically aligned top-down and elevation views with a clearly labelled ridden-this-lap, ridden-this-trip, or ridden-this-route path and pulsing position markers while pedaling, and uses clear mid-contrast preview lines with a shared elevation scale so gentle rollers remain visibly low beside genuinely mountainous routes. It shows course distance progress to two decimal places alongside course percentage, current grade, and effective trainer resistance directly on the map, with grade and resistance values matching their graph colors, and derives cumulative ride climbing and downhill from course distance so those totals remain aligned with the advertised full-course climb. Elevation appears in feet with MPH or meters with KM/H, and terrain totals and progress are recorded with the session and preserved in saved history and TCX import/export. Currently open in-progress sessions resolve bundled workout IDs to the latest course definition, preventing stale geometry from lingering before a ride is saved; saved history keeps the exact workout snapshot used by that ride. A workout can be selected before riding or planned while viewing a completed session; ending a workout keeps it selected and previews it at 0% for the next session unless the rider clears or replaces it. A newly planned workout immediately replaces the prior course on the dashboard at 0% progress without changing the completed ride's recorded data. It then remains locked from the moment riding begins until that session ends; definition refreshes for that same workout remain allowed without opening a path to switch courses. Workout terrain remains the base load when Zwift Click is paired, allowing virtual gears to scale that resistance without losing the grade-driven course behavior.
Expand Down
11 changes: 8 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,20 @@ export const RECORDING_PAUSE_DELAY_MS = 10_000;
export const CONTROL_FLASH_MS = 180;
export const EMPTY_ROUTE = [] as const;

export const optionalServices: BluetoothServiceUUID[] = [
FITNESS_MACHINE,
export const TRAINER_OPTIONAL_SERVICES: BluetoothServiceUUID[] = [
CYCLING_POWER,
CYCLING_SPEED_AND_CADENCE,
HEART_RATE,
BATTERY,
DEVICE_INFORMATION,
];

export const FTMS_CONTROL_OPCODE = {
REQUEST_CONTROL: 0x00,
RESPONSE_CODE: 0x80,
SET_TARGET_RESISTANCE: 0x04,
START_OR_RESUME: 0x07,
} as const;

export const emptyMetrics: Metrics = {
cadence: 0,
calories: 0,
Expand Down
77 changes: 45 additions & 32 deletions src/hooks/use-trainer-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { useCallback, useEffect, useRef } from 'react';
import {
CHROME_BLUETOOTH_PERMISSION_MESSAGE,
emptyMetrics,
optionalServices,
FTMS_CONTROL_OPCODE,
WEB_BLUETOOTH_UNAVAILABLE_MESSAGE,
} from '../constants';
import {
isBluetoothChooserCancellation,
recordMetricActivity,
resistanceCommand,
selectRememberedKickr,
selectRememberedTrainer,
TRAINER_DEVICE_STORAGE_KEY,
} from '../lib/bluetooth';
import {
Expand All @@ -20,7 +20,7 @@ import {
import { errorMessage } from '../lib/errors';
import type { RememberedBluetoothDeviceCatalog } from '../lib/remembered-bluetooth-devices';
import { storedResistance } from '../lib/session';
import { connectTrainerDevice } from '../lib/trainer-device';
import { connectTrainerDevice, trainerRequestOptions } from '../lib/trainer-device';
import type { TrainerStore } from '../stores/trainer-store';
import { usePageHide } from './use-page-hide';

Expand Down Expand Up @@ -55,7 +55,9 @@ export function useTrainerConnection(
const unloading = useRef(false);
const lastPedalingAt = useRef(0);
const trainerReportsDistance = useRef(false);
const controlPoint = useRef<BluetoothRemoteGATTCharacteristic | undefined>(undefined);
const sendControlCommand = useRef<((bytes: readonly number[]) => Promise<void>) | undefined>(
undefined
);
const resistanceRange = useRef(store.get().resistanceRange);
const reconnectController = useRef(
createBluetoothReconnectController<BluetoothDevice>({
Expand All @@ -67,23 +69,30 @@ export function useTrainerConnection(
})
);

const writeControl = useCallback(
async (characteristic: BluetoothRemoteGATTCharacteristic | undefined, bytes: number[]) => {
if (!characteristic) {
setNotice('Connect the trainer before changing its settings.');
return;
const queueControlCommand = useCallback(
async (
send: ((bytes: readonly number[]) => Promise<void>) | undefined,
bytes: readonly number[]
) => {
if (!send) {
throw new Error('Connect the trainer before changing its settings.');
}
const action = async () => {
try {
await characteristic.writeValueWithResponse(new Uint8Array(bytes));
} catch (error) {
setNotice(`Trainer command failed: ${errorMessage(error)}`);
}
};
const action = () => send(bytes);
commandQueue.current = commandQueue.current.then(action, action);
await commandQueue.current;
},
[setNotice]
[]
);

const writeControl = useCallback(
async (send: typeof sendControlCommand.current, bytes: readonly number[]) => {
try {
await queueControlCommand(send, bytes);
} catch (error) {
setNotice(`Trainer command failed: ${errorMessage(error)}`);
}
},
[queueControlCommand, setNotice]
);

function connectionStopped(rediscover: boolean) {
Expand All @@ -107,7 +116,7 @@ export function useTrainerConnection(
disconnectRequested.current = false;
device.current = undefined;
store.actions.setDeviceName(undefined);
controlPoint.current = undefined;
sendControlCommand.current = undefined;
setMetrics(emptyMetrics);
lastPedalingAt.current = 0;
trainerReportsDistance.current = false;
Expand Down Expand Up @@ -140,7 +149,6 @@ export function useTrainerConnection(
rediscover,
resistanceRange.current,
{
onControlRejected: () => setNotice('Trainer did not accept that command.'),
onDisconnect: () => {
connectionCleanup.current();
handleTrainerDisconnected(selected);
Expand All @@ -160,7 +168,7 @@ export function useTrainerConnection(
return false;
}
connectionCleanup.current = nextConnection.cleanup;
controlPoint.current = nextConnection.controlPoint;
sendControlCommand.current = nextConnection.sendControlCommand;
resistanceRange.current = nextConnection.resistanceRange;
store.actions.setResistanceRange(nextConnection.resistanceRange);
const restored = storedResistance();
Expand All @@ -174,10 +182,14 @@ export function useTrainerConnection(
progress: 0,
to: restored,
});
await writeControl(controlPoint.current, [0]);
await new Promise((resolve) => window.setTimeout(resolve, 150));
await writeControl(
controlPoint.current,
await queueControlCommand(sendControlCommand.current, [
FTMS_CONTROL_OPCODE.REQUEST_CONTROL,
]);
await queueControlCommand(sendControlCommand.current, [
FTMS_CONTROL_OPCODE.START_OR_RESUME,
]);
await queueControlCommand(
sendControlCommand.current,
resistanceCommand(restored, resistanceRange.current)
);
if (connectionStopped(rediscover)) {
Expand All @@ -193,6 +205,9 @@ export function useTrainerConnection(
setNotice(`${selected.name ?? 'Trainer'} is connected and ready.`);
return true;
} catch (error) {
connectionCleanup.current();
connectionCleanup.current = () => undefined;
sendControlCommand.current = undefined;
if (selected.gatt?.connected) {
selected.gatt.disconnect();
}
Expand All @@ -216,13 +231,11 @@ export function useTrainerConnection(
disconnectRequested.current = false;
setConnectionPhase('pairing');
try {
const selected = await navigator.bluetooth.requestDevice({
filters: [{ namePrefix: 'KICKR' }],
optionalServices,
});
const selected = await navigator.bluetooth.requestDevice(trainerRequestOptions());
pendingDevice.current = selected;
pairedDevice.current = selected;
store.actions.setPairedDeviceName(selected.name);
localStorage.setItem(TRAINER_DEVICE_STORAGE_KEY, selected.id);
autoReconnect.current = true;
if (!(await connectDevice(selected))) {
scheduleBluetoothDeviceReconnect(reconnectController.current, selected);
Expand Down Expand Up @@ -258,7 +271,7 @@ export function useTrainerConnection(
device.current?.gatt?.disconnect();
device.current = undefined;
store.actions.setDeviceName(undefined);
controlPoint.current = undefined;
sendControlCommand.current = undefined;
setMetrics(emptyMetrics);
setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired');
}, [setConnectionPhase, setMetrics, store]);
Expand Down Expand Up @@ -289,7 +302,7 @@ export function useTrainerConnection(
pairedDevice.current = undefined;
store.actions.setDeviceName(undefined);
store.actions.setPairedDeviceName(undefined);
controlPoint.current = undefined;
sendControlCommand.current = undefined;
setMetrics(emptyMetrics);
setConnectionPhase('unpaired');
setNotice('Trainer removed from paired devices.');
Expand All @@ -299,7 +312,7 @@ export function useTrainerConnection(
const sendResistance = useCallback(
async (percent: number) => {
await writeControl(
controlPoint.current,
sendControlCommand.current,
resistanceCommand(percent, resistanceRange.current)
);
},
Expand Down Expand Up @@ -329,7 +342,7 @@ export function useTrainerConnection(
if (!rememberedDevices.devices) {
return;
}
const remembered = selectRememberedKickr(rememberedDevices.devices);
const remembered = selectRememberedTrainer(rememberedDevices.devices);
if (!remembered) {
setConnectionPhase('unpaired');
return;
Expand Down
7 changes: 4 additions & 3 deletions src/lib/bluetooth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,21 +132,22 @@ export function resistanceCommand(percent: number, range: Range): number[] {
return [0x04, scaled & 255, (scaled >> 8) & 255];
}

export async function findRememberedKickr(
export async function findRememberedTrainer(
bluetooth: Bluetooth = navigator.bluetooth,
storage: Pick<Storage, 'getItem'> = localStorage
): Promise<BluetoothDevice | undefined> {
const permitted = await loadRememberedBluetoothDevices(bluetooth);
return selectRememberedKickr(permitted, storage);
return selectRememberedTrainer(permitted, storage);
}

export function selectRememberedKickr(
export function selectRememberedTrainer(
permitted: readonly BluetoothDevice[],
storage: Pick<Storage, 'getItem'> = localStorage
): BluetoothDevice | undefined {
const savedDeviceId = storage.getItem(TRAINER_DEVICE_STORAGE_KEY);
return (
permitted.find((candidate) => candidate.id === savedDeviceId) ??
// Preserve automatic reconnect for people paired before trainer ids were stored.
permitted.find((candidate) => candidate.name?.toUpperCase().startsWith('KICKR'))
);
}
Expand Down
Loading