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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,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, direct **Cancel pairing** and **Stop connecting** actions during stalled attempts, immediate local removal when **Forget** is chosen even if the Bluetooth link is failing, 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. Cancelling invalidates the pending attempt so a late browser selection or GATT completion cannot restore it. 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. A tiny static deployment marker is revalidated at most once per hour; when it differs from the running bundle, a persistent notice offers to reload into the latest version without automatically interrupting a ride.
- 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. Each browser chooser filters by the required advertised service, so trainer pairing shows FTMS hardware while heart-rate pairing shows standard heart-rate monitors. The trainer adapter is based on capability instead of a vendor-specific name, allowing the same path 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. Runtime resistance updates are coalesced to the newest target and sent at most twice per second, preventing ramps and live terrain feedback from building a stale command backlog on slower trainers. A timed-out control response or disconnected GATT write invalidates the old command path and triggers a clean automatic reconnect instead of repeatedly writing through a dead characteristic. 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. Each browser chooser filters by the required advertised service, so trainer pairing shows FTMS hardware while heart-rate pairing shows standard heart-rate monitors. The trainer adapter is based on capability instead of a vendor-specific name, allowing the same path 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. Runtime resistance updates are coalesced to the newest target and sent at most twice per second, preventing ramps and live terrain feedback from building a stale command backlog on slower trainers. A timed-out control response or disconnected GATT write invalidates the old command path and triggers a clean automatic reconnect instead of repeatedly writing through a dead characteristic. 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. Offline remembered devices keep retrying while the page remains open, with bounded attempts so a stale browser request cannot stall the loop; background heart-rate probes use a shorter timeout so a monitor that wakes up gets a fresh connection attempt promptly. Starting a new session re-arms every remembered device that is not already connected, while **Disconnect**, **Stop connecting**, and closing the page cancel current retry work. 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.
- Opens the linkable Profile view as a slide-out tray with shared, keyboard-accessible tabs that separate Personal details from Bikes while leaving room for future sections such as Premium and Teams. `/profile?tab=personal` and `/profile?tab=bikes` link directly to each section, browser history follows tab changes, and plain `/profile` safely defaults to Personal details. Switching tabs preserves every unsaved form edit. Profile data remains in IndexedDB on the current device and includes name, profile image, rider weight, an inclusive free-form sex or gender identity field that remembers saved custom entries in a separately labelled, removable suggestion group without relying on browser autofill, the app-wide Imperial or Metric display preference, and multiple named bikes. Every bike can store its own prepared image, manufacturer, model, color, purchase date, weight, front-chainring teeth, and rear-cassette teeth; rider and bike images share the same JPEG/PNG/WebP validation, browser-side resizing and compression, 32 MB source ceiling, 512-pixel edge, and 512 KB prepared-image ceiling. Removing a bike, profile image, or bike image requires explicit confirmation. 1×11, 1×12, 2×, and other valid drivetrains are supported up to 24 total combinations. Selecting the active bike immediately supplies that bike's mass and ordered virtual gear ratios to trainer physics. Existing single-bike and multi-bike profiles migrate automatically. Every actual rider-weight change is timestamped in the profile without adding duplicates for unchanged saves or unit conversions; the tray graphs the complete series with current weight and net change while retaining the complete local history for future encrypted sync. Weight follows the selected pounds or kilograms display while calculations use canonical kilograms, and the browser warns before reloading while the open profile contains unsaved changes. Each ride captures an immutable, physics-only snapshot of rider weight plus the active bike's identity, weight, chainrings, and cassette before recording begins, preserves it through active-session recovery and continuation, and round-trips it through Ride Control TCX files so later bike edits do not rewrite historical settings. Those physics fields and the active-bike selection lock after recording begins and unlock when the session ends; names, images, identity, display units, and descriptive bike metadata remain editable. Identity, rider name, and images never affect workout calculations or enter session history. Future cloud storage and synchronization will be offered as a premium feature.
- 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.
Expand Down
11 changes: 11 additions & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from '.
import { activeRiderPhysicsProfile, type RiderPhysicsProfile } from './lib/profile';
import type { ProfileTab } from './lib/profile-tab';
import { sessionHasRecordedData, sessionNeedsUnloadWarning } from './lib/session';
import { createSessionDeviceReconnectController } from './lib/session-device-reconnect';
import { loadSessionHistoryView, type SessionHistoryView } from './lib/session-history-view';
import { requestUnloadConfirmation } from './lib/unload';
import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome';
Expand Down Expand Up @@ -564,6 +565,16 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
useEffect(() => {
click.setConnectionActive(clickConnectionActive);
}, [click.setConnectionActive, clickConnectionActive]);
const sessionDeviceConnections = useRef([trainer, heartRate, click]);
const sessionDeviceReconnect = useMemo(createSessionDeviceReconnectController, []);
sessionDeviceConnections.current = [trainer, heartRate, click];
useEffect(() => {
sessionDeviceReconnect.reconnectForSession(
session.startedAt,
session.ended,
sessionDeviceConnections.current
);
}, [session.ended, session.startedAt, sessionDeviceReconnect]);
const dashboardKeyboardEnabled = activeOverlay === undefined && !workflow.saveDialogOpen;
clickShiftRef.current = shiftHandlerUnlessBlocked(
gearControl.shiftGear,
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const CHROME_BLUETOOTH_PERMISSION_MESSAGE = 'Chrome returned no site-auth
export const WEB_BLUETOOTH_UNAVAILABLE_MESSAGE =
'Web Bluetooth requires current Chrome or Edge on localhost or HTTPS.';
export const BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS = 30_000;
export const HEART_RATE_RECONNECT_PROBE_TIMEOUT_MS = 5000;
export const BLUETOOTH_TRAINER_SETUP_TIMEOUT_MS = 30_000;
export const BLUETOOTH_OPERATION_TIMEOUT_MS = 5000;
export const OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS = 1000;
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/use-trainer-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ export function useTrainerConnection(
setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired');
}, [setConnectionPhase, setMetrics, store]);

function reconnect() {
const reconnect = useCallback(() => {
if (!pairedDevice.current) {
return;
}
Expand All @@ -367,7 +367,7 @@ export function useTrainerConnection(
autoReconnect.current = true;
reconnectController.current.reset(selected.id);
reconnectBluetoothDeviceNow(reconnectController.current, selected);
}
}, []);

const forget = useCallback(async () => {
const selected = pairedDevice.current;
Expand Down
23 changes: 20 additions & 3 deletions src/lib/heart-rate-device.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { BATTERY, HEART_RATE, OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS } from '../constants';
import {
BATTERY,
BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS,
HEART_RATE,
HEART_RATE_RECONNECT_PROBE_TIMEOUT_MS,
OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS,
} from '../constants';
import { connectGatt } from './bluetooth';
import { startBluetoothNotifications } from './bluetooth-notifications';
import { withBluetoothOperationTimeout } from './bluetooth-operation';
Expand All @@ -18,6 +24,11 @@ export interface HeartRateDeviceConnection {
cleanup: () => void;
}

interface HeartRateDeviceConnectionTiming {
operationTimeoutMs?: number;
reconnectProbeTimeoutMs?: number;
}

async function readBatteryLevel(server: BluetoothRemoteGATTServer): Promise<number> {
const batteryValue = await (
await (await server.getPrimaryService(BATTERY)).getCharacteristic(BATTERY_LEVEL)
Expand All @@ -29,9 +40,15 @@ export async function connectHeartRateDevice(
device: BluetoothDevice,
rediscover: boolean,
{ onBattery, onDisconnect, onHeartRate }: HeartRateDeviceCallbacks,
operationTimeoutMs?: number
{
operationTimeoutMs,
reconnectProbeTimeoutMs = HEART_RATE_RECONNECT_PROBE_TIMEOUT_MS,
}: HeartRateDeviceConnectionTiming = {}
): Promise<HeartRateDeviceConnection> {
const server = await connectGatt(device, rediscover);
const server = await connectGatt(device, rediscover, {
directTimeoutMs: BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS,
reconnectProbeTimeoutMs,
});
const service = await withBluetoothOperationTimeout(
server.getPrimaryService(HEART_RATE),
'Heart rate service discovery',
Expand Down
41 changes: 41 additions & 0 deletions src/lib/session-device-reconnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export interface RememberedSessionDeviceConnection {
busy: boolean;
connected: boolean;
paired: boolean;
reconnect: () => void;
}

export interface SessionDeviceReconnectController {
reconnectForSession: (
startedAt: number,
ended: boolean,
connections: readonly RememberedSessionDeviceConnection[]
) => number;
}

export function reconnectDisconnectedSessionDevices(
connections: readonly RememberedSessionDeviceConnection[]
): number {
let reconnectCount = 0;
for (const connection of connections) {
if (!(connection.paired && !connection.connected && !connection.busy)) {
continue;
}
connection.reconnect();
reconnectCount += 1;
}
return reconnectCount;
}

export function createSessionDeviceReconnectController(): SessionDeviceReconnectController {
let lastSessionStartedAt: number | undefined;
return {
reconnectForSession: (startedAt, ended, connections) => {
if (ended || lastSessionStartedAt === startedAt) {
return 0;
}
lastSessionStartedAt = startedAt;
return reconnectDisconnectedSessionDevices(connections);
},
};
}
40 changes: 33 additions & 7 deletions tests/device-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,13 @@ describe('reconnect controller', () => {
expect(advertisementWatches).toBe(0);
});

test('retries until connected and then clears its pending work', async () => {
test('keeps retrying through a long absence until the device connects', async () => {
const callbacks: Array<() => void | Promise<void>> = [];
let attempts = 0;
const controller = createReconnectController<string>({
attempt: () => {
attempts += 1;
return Promise.resolve(attempts === 2);
return Promise.resolve(attempts === 8);
},
canRetry: () => true,
clearTimer: () => undefined,
Expand All @@ -132,11 +132,37 @@ describe('reconnect controller', () => {
});
controller.start('device', 'target');
expect(controller.isPending('device')).toBeTrue();
await callbacks.shift()?.();
expect(attempts).toBe(1);
expect(controller.isPending('device')).toBeTrue();
await callbacks.shift()?.();
expect(attempts).toBe(2);
for (let attempt = 1; attempt <= 8; attempt += 1) {
await callbacks.shift()?.();
expect(attempts).toBe(attempt);
expect(controller.isPending('device')).toBe(attempt < 8);
}
expect(controller.isPending('device')).toBeFalse();
});

test('does not restart after cancellation during an in-flight attempt', async () => {
const callbacks: Array<() => void | Promise<void>> = [];
let finishAttempt: ((connected: boolean) => void) | undefined;
const controller = createReconnectController<string>({
attempt: () =>
new Promise<boolean>((resolve) => {
finishAttempt = resolve;
}),
canRetry: () => true,
delayForAttempt: () => 100,
setTimer: ((callback: () => void) => {
callbacks.push(callback);
return callbacks.length;
}) as typeof setTimeout,
});

controller.start('device', 'target');
const attempt = callbacks.shift()?.();
controller.cancel('device', true);
finishAttempt?.(false);
await attempt;

expect(callbacks).toHaveLength(0);
expect(controller.isPending('device')).toBeFalse();
});

Expand Down
21 changes: 11 additions & 10 deletions tests/devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,22 +89,21 @@ describe('paired device protocols', () => {
gatt: {
connect: () => {
attempts += 1;
return attempts === 1
? Promise.reject(new Error('stale browser connection'))
: Promise.resolve(server);
return attempts === 1 ? new Promise(() => undefined) : Promise.resolve(server);
},
disconnect: () => undefined,
},
id: 'remembered-heart-rate',
removeEventListener: () => undefined,
} as unknown as BluetoothDevice;
const callbacks = {
onBattery: () => undefined,
onDisconnect: () => undefined,
onHeartRate: () => undefined,
};
await expect(connectHeartRateDevice(device, true, callbacks)).rejects.toThrow(
'stale browser connection'
);
await expect(
connectHeartRateDevice(device, true, callbacks, { reconnectProbeTimeoutMs: 1 })
).rejects.toThrow('Bluetooth device connection timed out.');
const connection = await connectHeartRateDevice(device, true, callbacks);
expect(attempts).toBe(2);
expect(notificationsStarted).toBe(1);
Expand Down Expand Up @@ -143,12 +142,14 @@ describe('paired device protocols', () => {
onHeartRate: () => undefined,
};

await expect(connectHeartRateDevice(device, false, callbacks, 1)).rejects.toThrow(
'Bluetooth notification setup timed out.'
);
await expect(
connectHeartRateDevice(device, false, callbacks, { operationTimeoutMs: 1 })
).rejects.toThrow('Bluetooth notification setup timed out.');
expect(listeners.size).toBe(0);

const connection = await connectHeartRateDevice(device, false, callbacks, 100);
const connection = await connectHeartRateDevice(device, false, callbacks, {
operationTimeoutMs: 100,
});
expect(notificationAttempts).toBe(2);
expect(listeners.size).toBe(1);
connection.cleanup();
Expand Down
Loading