From b98fc00e942eebcb19ff53fe70e6b4678c8999a1 Mon Sep 17 00:00:00 2001 From: Ride Control Date: Wed, 22 Jul 2026 10:09:57 -0700 Subject: [PATCH] Support Elite and generic FTMS trainers --- README.md | 4 +- src/constants.ts | 11 ++- src/hooks/use-trainer-connection.ts | 77 ++++++++++------- src/lib/bluetooth.ts | 7 +- src/lib/trainer-device.ts | 124 +++++++++++++++++++++++++--- tests/bluetooth.test.ts | 14 ++-- tests/trainer-device.test.ts | 59 ++++++++++++- 7 files changed, 237 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index ecd677a..ca7e3d2 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/src/constants.ts b/src/constants.ts index 545a7df..dc1dd0a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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, diff --git a/src/hooks/use-trainer-connection.ts b/src/hooks/use-trainer-connection.ts index 7166635..e21551d 100644 --- a/src/hooks/use-trainer-connection.ts +++ b/src/hooks/use-trainer-connection.ts @@ -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 { @@ -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'; @@ -55,7 +55,9 @@ export function useTrainerConnection( const unloading = useRef(false); const lastPedalingAt = useRef(0); const trainerReportsDistance = useRef(false); - const controlPoint = useRef(undefined); + const sendControlCommand = useRef<((bytes: readonly number[]) => Promise) | undefined>( + undefined + ); const resistanceRange = useRef(store.get().resistanceRange); const reconnectController = useRef( createBluetoothReconnectController({ @@ -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) | 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) { @@ -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; @@ -140,7 +149,6 @@ export function useTrainerConnection( rediscover, resistanceRange.current, { - onControlRejected: () => setNotice('Trainer did not accept that command.'), onDisconnect: () => { connectionCleanup.current(); handleTrainerDisconnected(selected); @@ -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(); @@ -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)) { @@ -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(); } @@ -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); @@ -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]); @@ -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.'); @@ -299,7 +312,7 @@ export function useTrainerConnection( const sendResistance = useCallback( async (percent: number) => { await writeControl( - controlPoint.current, + sendControlCommand.current, resistanceCommand(percent, resistanceRange.current) ); }, @@ -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; diff --git a/src/lib/bluetooth.ts b/src/lib/bluetooth.ts index 233a2af..7d10933 100644 --- a/src/lib/bluetooth.ts +++ b/src/lib/bluetooth.ts @@ -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 = localStorage ): Promise { const permitted = await loadRememberedBluetoothDevices(bluetooth); - return selectRememberedKickr(permitted, storage); + return selectRememberedTrainer(permitted, storage); } -export function selectRememberedKickr( +export function selectRememberedTrainer( permitted: readonly BluetoothDevice[], storage: Pick = 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')) ); } diff --git a/src/lib/trainer-device.ts b/src/lib/trainer-device.ts index 5e5f460..b83a321 100644 --- a/src/lib/trainer-device.ts +++ b/src/lib/trainer-device.ts @@ -5,9 +5,11 @@ import { CYCLING_POWER_MEASUREMENT, CYCLING_SPEED_AND_CADENCE, FITNESS_MACHINE, + FTMS_CONTROL_OPCODE, INDOOR_BIKE_DATA, OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, SUPPORTED_RESISTANCE_LEVEL_RANGE, + TRAINER_OPTIONAL_SERVICES, } from '../constants'; import type { Metrics, Range } from '../types'; import { @@ -25,18 +27,122 @@ import { withBluetoothOperationTimeout } from './bluetooth-operation'; import { withPromiseTimeout } from './promise-timeout'; interface TrainerDeviceCallbacks { - onControlRejected: () => void; onDisconnect: () => void; onMetrics: (metrics: Partial, reportsDistance: boolean) => void; } export interface TrainerDeviceConnection { cleanup: () => void; - controlPoint: BluetoothRemoteGATTCharacteristic; resistanceRange: Range; + sendControlCommand: (bytes: readonly number[]) => Promise; startOptionalMetrics: () => void; } +interface PendingControlProcedure { + opcode: number; + reject: (error: Error) => void; + resolve: () => void; +} + +const FTMS_RESULT = { + CONTROL_NOT_PERMITTED: 0x05, + INVALID_PARAMETER: 0x03, + OP_CODE_NOT_SUPPORTED: 0x02, + OPERATION_FAILED: 0x04, + SUCCESS: 0x01, +} as const; + +const FTMS_RESULT_MESSAGE: Readonly> = { + [FTMS_RESULT.CONTROL_NOT_PERMITTED]: 'control not permitted', + [FTMS_RESULT.INVALID_PARAMETER]: 'invalid parameter', + [FTMS_RESULT.OP_CODE_NOT_SUPPORTED]: 'operation not supported', + [FTMS_RESULT.OPERATION_FAILED]: 'operation failed', +}; + +const FTMS_COMMAND_NAME: Readonly> = { + [FTMS_CONTROL_OPCODE.REQUEST_CONTROL]: 'Request Control', + [FTMS_CONTROL_OPCODE.SET_TARGET_RESISTANCE]: 'Set Target Resistance', + [FTMS_CONTROL_OPCODE.START_OR_RESUME]: 'Start or Resume', +}; + +export function trainerRequestOptions(): RequestDeviceOptions { + return { + filters: [{ services: [FITNESS_MACHINE] }], + optionalServices: TRAINER_OPTIONAL_SERVICES, + }; +} + +function ftmsControlError(opcode: number, result: number): Error { + const command = FTMS_COMMAND_NAME[opcode] ?? `command 0x${opcode.toString(16)}`; + const resultMessage = FTMS_RESULT_MESSAGE[result] ?? `result 0x${result.toString(16)}`; + return new Error(`Trainer rejected ${command}: ${resultMessage}.`); +} + +function createControlPointProcedure(controlPoint: BluetoothRemoteGATTCharacteristic): { + cancel: () => void; + handleIndication: (event: Event) => void; + send: (bytes: readonly number[]) => Promise; +} { + let pending: PendingControlProcedure | undefined; + const handleIndication = (event: Event) => { + const value = characteristicValue(event); + if ( + !(pending && value) || + value.byteLength < 3 || + value.getUint8(0) !== FTMS_CONTROL_OPCODE.RESPONSE_CODE || + value.getUint8(1) !== pending.opcode + ) { + return; + } + const procedure = pending; + pending = undefined; + const result = value.getUint8(2); + if (result === FTMS_RESULT.SUCCESS) { + procedure.resolve(); + } else { + procedure.reject(ftmsControlError(procedure.opcode, result)); + } + }; + const cancel = () => { + const procedure = pending; + pending = undefined; + procedure?.reject(new Error('Trainer connection closed during a control command.')); + }; + const send = async (bytes: readonly number[]) => { + const [opcode] = bytes; + if (opcode === undefined) { + throw new Error('Trainer control command cannot be empty.'); + } + if (pending) { + throw new Error('Another trainer control command is still in progress.'); + } + let procedure: PendingControlProcedure | undefined; + const response = new Promise((resolve, reject) => { + procedure = { opcode, reject, resolve }; + pending = procedure; + }); + const acknowledged = withBluetoothOperationTimeout( + response, + 'Fitness machine control response' + ); + try { + await Promise.all([ + withBluetoothOperationTimeout( + controlPoint.writeValueWithResponse(Uint8Array.from(bytes)), + 'Fitness machine control write' + ), + acknowledged, + ]); + } catch (error) { + if (pending === procedure) { + pending = undefined; + } + throw error; + } + }; + return { cancel, handleIndication, send }; +} + async function readResistanceRange(service: BluetoothRemoteGATTService) { return (await service.getCharacteristic(SUPPORTED_RESISTANCE_LEVEL_RANGE)).readValue(); } @@ -111,7 +217,7 @@ export async function connectTrainerDevice( device: BluetoothDevice, rediscover: boolean, fallbackRange: Range, - { onControlRejected, onDisconnect, onMetrics }: TrainerDeviceCallbacks + { onDisconnect, onMetrics }: TrainerDeviceCallbacks ): Promise { const server = await connectGatt(device, rediscover); const service = await withBluetoothOperationTimeout( @@ -136,14 +242,10 @@ export async function connectTrainerDevice( const parsed = parseIndoorBikeData(value); onMetrics(parsed.metrics, parsed.reportsDistance); }); + const controlProcedure = createControlPointProcedure(controlPoint); const controlPointNotifications = createBluetoothNotificationSubscription( controlPoint, - (event) => { - const value = characteristicValue(event); - if (value?.getUint8(0) === 0x80 && value.getUint8(2) !== 0x01) { - onControlRejected(); - } - } + controlProcedure.handleIndication ); try { await withBluetoothOperationTimeout( @@ -175,6 +277,7 @@ export async function connectTrainerDevice( const cleanupRequiredServices = combineBluetoothCleanups( bikeDataNotifications.cleanup, controlPointNotifications.cleanup, + controlProcedure.cancel, () => device.removeEventListener('gattserverdisconnected', onDisconnect) ); const cleanup = () => { @@ -209,14 +312,15 @@ export async function connectTrainerDevice( }; return { cleanup, - controlPoint, resistanceRange, + sendControlCommand: controlProcedure.send, startOptionalMetrics, }; } catch (error) { combineBluetoothCleanups( bikeDataNotifications.cleanup, controlPointNotifications.cleanup, + controlProcedure.cancel, () => device.removeEventListener('gattserverdisconnected', onDisconnect) )(); throw error; diff --git a/tests/bluetooth.test.ts b/tests/bluetooth.test.ts index 97c3866..4e30f70 100644 --- a/tests/bluetooth.test.ts +++ b/tests/bluetooth.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { characteristicValue, connectGatt, - findRememberedKickr, + findRememberedTrainer, parseCrankCadence, parseIndoorBikeData, recordMetricActivity, @@ -82,7 +82,7 @@ describe('Bluetooth data utilities', () => { expect(resistanceCommand(50, { max: 30, min: -10 })).toEqual([4, 100, 0]); }); - test('prefers a saved device and otherwise reconnects only a named KICKR', async () => { + test('prefers a saved trainer and preserves the legacy KICKR fallback', async () => { const devices = [ { id: 'one', name: 'Other' }, { id: 'two', name: 'KICKR CORE' }, @@ -90,14 +90,16 @@ describe('Bluetooth data utilities', () => { const bluetooth = { getDevices: async () => devices, } as Bluetooth; - expect(await findRememberedKickr(bluetooth, { getItem: () => 'one' })).toBe(devices[0]); - expect(await findRememberedKickr(bluetooth, { getItem: () => null })).toBe(devices[1]); + expect(await findRememberedTrainer(bluetooth, { getItem: () => 'one' })).toBe(devices[0]); + expect(await findRememberedTrainer(bluetooth, { getItem: () => null })).toBe(devices[1]); expect( - await findRememberedKickr({ getDevices: async () => [devices[0]] } as Bluetooth, { + await findRememberedTrainer({ getDevices: async () => [devices[0]] } as Bluetooth, { getItem: () => null, }) ).toBeUndefined(); - expect(await findRememberedKickr({} as Bluetooth, { getItem: () => null })).toBeUndefined(); + expect( + await findRememberedTrainer({} as Bluetooth, { getItem: () => null }) + ).toBeUndefined(); }); test('loads and selects all saved device types from one permitted-device catalog', async () => { diff --git a/tests/trainer-device.test.ts b/tests/trainer-device.test.ts index 6d3ee80..b8ed229 100644 --- a/tests/trainer-device.test.ts +++ b/tests/trainer-device.test.ts @@ -6,13 +6,30 @@ import { CYCLING_SPEED_AND_CADENCE, FITNESS_MACHINE, FITNESS_MACHINE_STATUS, + FTMS_CONTROL_OPCODE, INDOOR_BIKE_DATA, SUPPORTED_RESISTANCE_LEVEL_RANGE, } from '../src/constants'; -import { connectTrainerDevice } from '../src/lib/trainer-device'; +import { connectTrainerDevice, trainerRequestOptions } from '../src/lib/trainer-device'; function notificationCharacteristic() { const listeners = new Set(); + const writes: number[][] = []; + const emit = (bytes: readonly number[]) => { + const buffer = Uint8Array.from(bytes); + const event = { + target: { + value: new DataView(buffer.buffer), + }, + } as unknown as Event; + for (const listener of listeners) { + if (typeof listener === 'function') { + listener(event); + } else { + listener.handleEvent(event); + } + } + }; return { characteristic: { addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => @@ -20,12 +37,31 @@ function notificationCharacteristic() { removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => listeners.delete(listener), startNotifications: async () => undefined, + writeValueWithResponse: (value: BufferSource) => { + const bytes = ArrayBuffer.isView(value) + ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + : new Uint8Array(value); + writes.push([...bytes]); + return Promise.resolve(); + }, } as unknown as BluetoothRemoteGATTCharacteristic, + emit, listeners, + writes, }; } describe('trainer device connection', () => { + test('discovers trainers by FTMS service instead of vendor name', () => { + const options = trainerRequestOptions(); + if (!('filters' in options)) { + throw new Error('Expected an FTMS service filter.'); + } + expect(options.filters).toEqual([{ services: [FITNESS_MACHINE] }]); + expect(options.optionalServices).toContain(CYCLING_POWER); + expect(options.optionalServices).toContain(CYCLING_SPEED_AND_CADENCE); + }); + test('does not block a ready trainer on optional service discovery', async () => { const bikeData = notificationCharacteristic(); const controlPoint = notificationCharacteristic(); @@ -86,14 +122,31 @@ describe('trainer device connection', () => { true, { max: 100, min: 0 }, { - onControlRejected: () => undefined, onDisconnect: () => undefined, onMetrics: () => undefined, } ); - expect(connection.controlPoint).toBe(controlPoint.characteristic); expect(connection.resistanceRange).toEqual({ max: 100, min: 0 }); + const requestControl = connection.sendControlCommand([FTMS_CONTROL_OPCODE.REQUEST_CONTROL]); + controlPoint.emit([ + FTMS_CONTROL_OPCODE.RESPONSE_CODE, + FTMS_CONTROL_OPCODE.REQUEST_CONTROL, + 0x01, + ]); + await requestControl; + expect(controlPoint.writes).toEqual([[FTMS_CONTROL_OPCODE.REQUEST_CONTROL]]); + + const rejectedStart = connection.sendControlCommand([FTMS_CONTROL_OPCODE.START_OR_RESUME]); + controlPoint.emit([ + FTMS_CONTROL_OPCODE.RESPONSE_CODE, + FTMS_CONTROL_OPCODE.START_OR_RESUME, + 0x05, + ]); + await expect(rejectedStart).rejects.toThrow( + 'Trainer rejected Start or Resume: control not permitted.' + ); + connection.cleanup(); releasePowerService?.(power); await new Promise((resolve) => setTimeout(resolve, 0));