From 5d002dc0baa65b7a95b679663ef1dc4a16cf543a Mon Sep 17 00:00:00 2001 From: Ride Control Date: Sat, 5 Sep 2026 18:49:58 -0700 Subject: [PATCH] fix(bluetooth): rediscover remembered heart-rate monitors --- README.md | 11 +++- src/hooks/use-heart-rate-monitor.ts | 1 - tests/device-connection.test.ts | 89 +++++++++++++++++++++++++---- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5000105..201c342 100644 --- a/README.md +++ b/README.md @@ -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. 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. +- 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, heart-rate monitors, and the active Click controller share advertisement discovery through the GATT handshake so Chrome can rediscover remembered hardware as it broadcasts. Bounded direct GATT retries remain the fallback when advertisements are not delivered or watching is unavailable or fails. 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. - Reports a heart-rate monitor ready only after its first valid measurement. If readings stop for 10 seconds, the app closes the stale connection and retries the remembered monitor without requiring it to be forgotten or paired again. Disconnects during setup abort the attempt, and canceled or late connection work cannot tear down its replacement. - 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. @@ -120,6 +120,15 @@ Persistent Web Bluetooth permissions are disabled by default in current Chromium The paired-devices panel detects Chrome's persistent reconnect capability and replaces these setup steps with a configured confirmation when it is available. +Remembered permission alone does not guarantee that Chrome can connect to a device: it may still +need advertisement discovery to refresh that device's availability. In a live TRACKR HR recovery, +direct GATT retries repeatedly reported “Bluetooth Device is no longer in range” until advertisement +watching was enabled on the same remembered device; it then connected and delivered heart-rate +measurements without a refresh or re-pair. Heart-rate monitors therefore use the same discovery +policy as trainers, alongside bounded direct GATT retries that do not depend on an advertisement +arriving or watching succeeding. This recovery was observed on that device in Chrome, not verified +across all heart-rate hardware. + ## License Copyright (C) 2026 Ride Control. diff --git a/src/hooks/use-heart-rate-monitor.ts b/src/hooks/use-heart-rate-monitor.ts index d1c452a..2614dd0 100644 --- a/src/hooks/use-heart-rate-monitor.ts +++ b/src/hooks/use-heart-rate-monitor.ts @@ -41,7 +41,6 @@ export function useHeartRateMonitor( connectDeviceRef.current?.(selected, true) ?? Promise.resolve(false), canRetry: () => autoReconnect.current && !forgotten.current, onWaiting: () => setPhase('reconnecting'), - watchAdvertisements: false, }) ); const handleDisconnect = useCallback((selected: BluetoothDevice) => { diff --git a/tests/device-connection.test.ts b/tests/device-connection.test.ts index 6588a7d..8a39cfd 100644 --- a/tests/device-connection.test.ts +++ b/tests/device-connection.test.ts @@ -83,37 +83,102 @@ describe('reconnect controller', () => { expect([1, 2, 3, 4, 5].map(bluetoothReconnectDelay)).toEqual([250, 500, 1000, 2000, 2000]); }); - test('connects an HRM directly without starting advertisement discovery', async () => { + test('rediscovers a remembered HRM before reconnecting its out-of-range GATT device', async () => { const callbacks: Array<() => void | Promise> = []; - let attempts = 0; - let advertisementWatches = 0; + let advertisementListener: EventListener | undefined; + let watchSignal: AbortSignal | undefined; + let observedBroadcast = false; + const gatt = { + connect: () => { + if (!observedBroadcast) { + return Promise.reject( + new DOMException('Bluetooth Device is no longer in range.', 'NetworkError') + ); + } + gatt.connected = true; + return Promise.resolve(gatt); + }, + connected: false, + }; const device = { + addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => { + advertisementListener = listener as EventListener; + }, + gatt, id: 'heart-rate', - watchAdvertisements: () => { - advertisementWatches += 1; + removeEventListener: () => { + advertisementListener = undefined; + }, + watchAdvertisements: ({ signal }: { signal?: AbortSignal }) => { + watchSignal = signal; return Promise.resolve(); }, + watchingAdvertisements: false, } as unknown as BluetoothDevice; const controller = createBluetoothReconnectController({ - attempt: () => { - attempts += 1; - return Promise.resolve(true); - }, + attempt: async (remembered) => (await remembered.gatt?.connect())?.connected ?? false, canRetry: () => true, + clearTimer: () => undefined, setTimer: ((callback: () => void) => { callbacks.push(callback); return callbacks.length; }) as typeof setTimeout, - watchAdvertisements: false, }); reconnectBluetoothDeviceNow(controller, device); await callbacks[0]?.(); + expect(gatt.connected).toBeFalse(); + expect(controller.isPending(device.id)).toBeTrue(); - expect(attempts).toBe(1); - expect(advertisementWatches).toBe(0); + if (watchSignal && !watchSignal.aborted && advertisementListener) { + observedBroadcast = true; + advertisementListener({} as Event); + } + await callbacks.at(-1)?.(); + expect(gatt.connected).toBeTrue(); + expect(controller.isPending(device.id)).toBeFalse(); }); + test.each(['rejects', 'emits no events'] as const)( + 'keeps directly retrying a remembered device when advertisement discovery %s', + async (watchOutcome) => { + const callbacks: Array<() => void | Promise> = []; + let attempts = 0; + const device = { + addEventListener: () => undefined, + id: 'heart-rate', + removeEventListener: () => undefined, + watchAdvertisements: () => + watchOutcome === 'rejects' + ? Promise.reject( + new DOMException('Discovery unavailable', 'NotSupportedError') + ) + : Promise.resolve(), + watchingAdvertisements: false, + } as unknown as BluetoothDevice; + const controller = createBluetoothReconnectController({ + attempt: () => { + attempts += 1; + return Promise.resolve(attempts === 3); + }, + canRetry: () => true, + clearTimer: () => undefined, + setTimer: ((callback: () => void) => { + callbacks.push(callback); + return callbacks.length; + }) as typeof setTimeout, + }); + + reconnectBluetoothDeviceNow(controller, device); + for (let attempt = 1; attempt <= 3; attempt += 1) { + await callbacks.shift()?.(); + expect(attempts).toBe(attempt); + expect(controller.isPending(device.id)).toBe(attempt < 3); + } + expect(callbacks).toHaveLength(0); + } + ); + test('keeps retrying through a long absence until the device connects', async () => { const callbacks: Array<() => void | Promise> = []; let attempts = 0;