diff --git a/README.md b/README.md index 766d511..f468a22 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,15 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 ## Features - Welcomes first-time visitors with a concise introduction, open-source and local-data privacy details, a direct source-code link, and an optional “Don't show again” preference stored in the browser; the welcome screen remains available from the Ride Control footer link. -- Manages the smart trainer, heart rate monitor, and both Zwift Click V2 controllers independently from one paired-devices tray that slides smoothly into and out of view, with blue pulsing dots and a consistent reconnecting status for controllers awaiting connection and a green indicator once every paired device is ready; keeps the `+` controller above the `−` controller, automatically identifies each physical side, connects both controllers concurrently, routes mirrored Bluetooth notifications only to that side, glows only its row as it is pressed, remembers its identity, continuously retries saved Click connections after a refresh or controller sleep, and keeps the sleeping-controller display stable between retry attempts; stalled attempts can be retried immediately, and Click presses made while this panel is open stay in setup and do not shift the ride. +- Manages the smart trainer, heart rate monitor, and both Zwift Click V2 controllers 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; keeps the `+` controller above the `−` controller, automatically identifies each physical side, connects both controllers concurrently, routes mirrored Bluetooth notifications only to that side, glows only its row as it is pressed, remembers its identity, continuously retries saved Click connections after a refresh or controller sleep, and keeps the sleeping-controller display stable between retry attempts; stalled attempts can be retried immediately, and Click presses made while this 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 automatically reconnects when possible. +- 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 both Click controllers from one browser permission snapshot after a reload. Every remembered device begins reconnecting immediately and independently. Trainers and Click controllers 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 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 on aligned, smoothly curved top-down and elevation views with a clearly labelled ridden-this-lap or ridden-this-trip 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 percentage, current grade, and terrain resistance directly on the map, 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. Restored or currently open sessions always resolve bundled workout IDs to the latest course definition, preventing older embedded map geometry from lingering after a course update. A workout can be selected before riding or planned while viewing a completed session; 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. Paired shift controllers can remain connected and are ignored while a workout controls resistance. Workout terrain is modeled separately from trainer control so virtual shifting can be integrated later without changing course data. +- 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 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 on aligned, smoothly curved top-down and elevation views with a clearly labelled ridden-this-lap or ridden-this-trip 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 percentage, current grade, and effective trainer resistance directly on the map, 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. Restored or currently open sessions always resolve bundled workout IDs to the latest course definition, preventing older embedded map geometry from lingering after a course update. A workout can be selected before riding or planned while viewing a completed session; 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. - Downloads terrain workouts as standard GPX 1.1 files containing geographic track points and elevation data, with names and descriptions readable by ordinary GPX tools and Ride Control extensions for stable ids, difficulty, baseline resistance, exact workout distance, and loop or out-and-back course type. Valid GPX tracks or routes from other tools can be imported into a custom library saved only on the current device; closed paths become loops automatically, while open paths become out-and-back workouts with a generated return leg. Their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and missing Ride Control metadata receives safe defaults. Stable workout ids—or a route fingerprint for third-party GPX—prevent built-in or previously imported workouts from being uploaded again. Imported entries can be removed, large tracks are sampled for efficient riding, and the geographic source model is ready for a future workout editor. -- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired and no terrain workout is active; shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows then make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance. +- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired, including during terrain workouts. Shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows use an evenly spaced proportional gear curve with matching visual feedback; gear 12 preserves the terrain load, gear 24 is roughly twice as hard, and gear 1 is roughly half as hard. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record and graph the gear together with workout elevation and progress. - Automatically records while pedaling, auto-pauses during inactivity, supports manual pause and resume, and allows a session to end at any time—even before trainer data arrives. Finishing a ride smoothly returns a connected trainer to 10% resistance; if it is disconnected, 10% is remembered and applied when it reconnects. - Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with large, high-visibility live metric cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. Workout elevation is recorded across the entire ride, so the course profile repeats in the graph for every completed loop. - Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported. @@ -43,10 +43,17 @@ Open in current Chrome. Ride session data is held in a per-app TanStack Store and changed through atomic domain actions. The `useSession` adapter owns recording timers and durable browser persistence while exposing the existing session controller API to the interface. Each Bluetooth hook exposes one explicit -connection phase instead of independently managed status flags. Shared reconnect scheduling and -notification subscriptions live in plain controllers, while device-specific adapters own GATT -setup for heart-rate monitors, trainers, and Click controllers. Bluetooth objects and timers stay -outside shared application state. The application component coordinates focused dashboard regions, +connection phase instead of independently managed status flags. A single remembered-device catalog +loads browser-authorized devices once, then the trainer, heart-rate, and Click adapters start their +advertisement-driven reconnections together. One GATT coordinator deduplicates overlapping requests +to the same physical device while allowing the trainer, heart-rate monitor, and controllers to +connect in parallel. Each device sequences its own service setup, so a sleeping device cannot block +an awake one and concurrent ATT requests cannot fight over one connection. +Shared bounded connection probes, required service and notification +setup, persistent advertisement-driven wake detection, retry backoff, scheduling, and notification subscriptions live in +plain domain controllers, while device-specific adapters own their GATT services. Bluetooth objects +and timers stay outside shared application state. The application component coordinates focused +dashboard regions, uses an explicit overlay state for mutually exclusive trays, renders every side tray through one animated and accessible shell, and delegates save/discard/start/continue transitions to a store-backed session workflow. Temporary form inputs remain local React state. @@ -55,9 +62,11 @@ course keeps its editable metadata, map geometry, elevation, and resistance base individual JSON definition under `src/workouts`; shared factories derive the normalized runtime course geometry and terrain behavior. Course geometry produces grade, elevation, current and completed course counts, map position, and a bounded resistance -target. Recorded elevation appears alongside the other session graphs for the full ride and repeats -the course profile on every loop or out-and-back trip, while route progress stays portable in saved sessions and TCX -files and leaves room to compose the same terrain with virtual gearing later. +target. Virtual gearing applies one shared proportional ratio curve to that target, so terrain +changes ramp smoothly while button-driven gear changes remain immediate. Recorded elevation appears +alongside the other session graphs for the full ride and repeats the course profile on every loop or +out-and-back trip, while route progress and selected gear stay portable in saved sessions and TCX +files. Shared domain utilities own unit conversions, numeric bounds, storage keys, metric presentation, and repeated dialog and keyboard behavior so those rules stay consistent across views and exports. diff --git a/scripts/check-tailwind.ts b/scripts/check-tailwind.ts index 3962946..aa0b676 100644 --- a/scripts/check-tailwind.ts +++ b/scripts/check-tailwind.ts @@ -1,10 +1,15 @@ import { spawn } from 'node:child_process'; import { readdir, readFile } from 'node:fs/promises'; import { extname, join, relative, resolve as resolvePath } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; const DIAGNOSTIC_TIMEOUT_MS = 30_000; const CONTENT_LENGTH_PATTERN = /Content-Length: (\d+)/i; +const DIAGNOSTIC_CANARY = 'export const Canary = () =>
;'; +const TAILWIND_CSS_SYNTAX_PATTERN = + /@(?:apply|config|custom-variant|import|plugin|source|tailwind|theme|utility|variant)\b/; +const LEADING_IMPORTANT_MODIFIER_PATTERN = /^!/; +const TRAILING_IMPORTANT_MODIFIER_PATTERN = /!$/; const LANGUAGE_IDS = new Map([ ['.css', 'css'], ['.html', 'html'], @@ -72,6 +77,28 @@ interface PublishedDiagnostics { uri: string; } +interface SourceDocument { + file: string; + text: string; + uri: string; +} + +interface ClassToken { + className: string; + position: { character: number; line: number }; + utility: string; + variant: string; +} + +interface HoverResponse { + contents: { value: string }; +} + +interface DiagnosticFailure { + diagnostic: Diagnostic; + file: string; +} + interface Deferred { promise: Promise; reject: (reason: Error) => void; @@ -137,6 +164,120 @@ function diagnosticLevel(severity: number | undefined): string { return 'notice'; } +function positionAt(text: string, offset: number) { + const beforeOffset = text.slice(0, offset); + const lastLineBreak = beforeOffset.lastIndexOf('\n'); + return { + character: offset - lastLineBreak - 1, + line: beforeOffset.split('\n').length - 1, + }; +} + +function utilityParts(className: string) { + let bracketDepth = 0; + let lastVariantSeparator = -1; + for (const [index, character] of [...className].entries()) { + if (character === '[') { + bracketDepth += 1; + } else if (character === ']') { + bracketDepth -= 1; + } else if (character === ':' && bracketDepth === 0) { + lastVariantSeparator = index; + } + } + return { + utility: className + .slice(lastVariantSeparator + 1) + .replace(LEADING_IMPORTANT_MODIFIER_PATTERN, '') + .replace(TRAILING_IMPORTANT_MODIFIER_PATTERN, ''), + variant: className.slice(0, lastVariantSeparator + 1), + }; +} + +function classLists(text: string): ClassToken[][] { + const attributePattern = + /\b(?:class|className)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([\s\S]*?)`\s*\})/g; + const lists: ClassToken[][] = []; + for (const attribute of text.matchAll(attributePattern)) { + const value = attribute[1] ?? attribute[2] ?? attribute[3]; + const attributeStart = attribute.index ?? 0; + const valueStart = attributeStart + attribute[0].indexOf(value); + const tokens = [...value.matchAll(/\S+/g)].map((token) => ({ + className: token[0], + position: positionAt(text, valueStart + (token.index ?? 0)), + ...utilityParts(token[0]), + })); + lists.push(tokens); + } + return lists; +} + +function containsTailwindSyntax(document: SourceDocument): boolean { + return ( + classLists(document.text).some((tokens) => tokens.length > 0) || + (extname(document.file) === '.css' && TAILWIND_CSS_SYNTAX_PATTERN.test(document.text)) + ); +} + +async function scopedBorderColorConflicts( + documents: SourceDocument[], + request: (method: string, params?: unknown) => Promise +): Promise { + const failures: DiagnosticFailure[] = []; + for (const document of documents) { + for (const tokens of classLists(document.text)) { + const colorTokens = ( + await Promise.all( + tokens + .filter( + (token) => + token.utility.startsWith('border-') || + token.utility.startsWith('divide-') + ) + .map(async (token) => { + const hover = (await request('textDocument/hover', { + position: { + character: token.position.character + 1, + line: token.position.line, + }, + textDocument: { uri: document.uri }, + })) as HoverResponse | null; + if (hover === null) { + return null; + } + return hover.contents.value.includes('border-color:') ? token : null; + }) + ) + ).filter((token): token is ClassToken => token !== null); + + for (const divideToken of colorTokens.filter((token) => + token.utility.startsWith('divide-') + )) { + const borderTokens = colorTokens.filter( + (token) => + token.utility.startsWith('border-') && token.variant === divideToken.variant + ); + if (borderTokens.length === 0) { + continue; + } + const borderNames = borderTokens + .map((token) => `'${token.className}'`) + .join(' and '); + failures.push({ + diagnostic: { + code: 'cssConflict', + message: `'${divideToken.className}' applies the same CSS property as ${borderNames}.`, + range: { start: divideToken.position }, + severity: 2, + }, + file: document.file, + }); + } + } + } + return failures; +} + async function main() { const workspace = process.cwd(); const workspaceUri = pathToFileURL(workspace).href; @@ -149,8 +290,9 @@ async function main() { } ); const pendingRequests = new Map>(); - const diagnostics = new Map(); const diagnosticWaiters = new Map>(); + const documentReadyWaiters = new Map>(); + const projectReady = deferred(); const serverReady = deferred(); let nextRequestId = 1; let output = Buffer.alloc(0); @@ -185,11 +327,21 @@ async function main() { if (message.method === 'textDocument/publishDiagnostics') { const published = message.params as PublishedDiagnostics; - diagnostics.set(published.uri, published.diagnostics); diagnosticWaiters.get(published.uri)?.resolve(published.diagnostics); return; } + if (message.method === '@/tailwindCSS/documentReady') { + const params = message.params as { uri: string }; + documentReadyWaiters.get(params.uri)?.resolve(); + return; + } + + if (message.method === '@/tailwindCSS/projectReloaded') { + projectReady.resolve(); + return; + } + if (message.method === '@/tailwindCSS/serverReady') { serverReady.resolve(); return; @@ -282,48 +434,124 @@ async function main() { send({ method: 'initialized', params: {} }); await withTimeout(serverReady.promise, 'Tailwind language server readiness'); + const canaryUri = pathToFileURL(resolvePath('src/tailwind-diagnostic-canary.tsx')).href; + const canaryDocumentReady = deferred(); + const canaryWaiter = deferred(); + documentReadyWaiters.set(canaryUri, canaryDocumentReady); + diagnosticWaiters.set(canaryUri, canaryWaiter); + send({ + method: 'textDocument/didOpen', + params: { + textDocument: { + languageId: 'typescriptreact', + text: DIAGNOSTIC_CANARY, + uri: canaryUri, + version: 1, + }, + }, + }); + await Promise.all([ + withTimeout(projectReady.promise, 'Tailwind project initialization'), + withTimeout(canaryDocumentReady.promise, 'Tailwind canary document readiness'), + ]); + send({ + method: 'textDocument/didChange', + params: { + contentChanges: [{ text: `${DIAGNOSTIC_CANARY}\n` }], + textDocument: { uri: canaryUri, version: 2 }, + }, + }); + const canaryDiagnostics = await withTimeout( + canaryWaiter.promise, + 'Tailwind conflict canary diagnostics' + ); + if (!canaryDiagnostics.some((diagnostic) => diagnostic.code === 'cssConflict')) { + throw new Error( + 'Tailwind CSS conflict diagnostics are unavailable; the check cannot be trusted.' + ); + } + diagnosticWaiters.delete(canaryUri); + documentReadyWaiters.delete(canaryUri); + send({ + method: 'textDocument/didClose', + params: { textDocument: { uri: canaryUri } }, + }); + const files = [ ...(await sourceFiles(resolvePath('src'))), resolvePath('index.html'), ].sort(); - await Promise.all( - files.map(async (file) => { - const uri = pathToFileURL(file).href; - const waiter = deferred(); - diagnosticWaiters.set(uri, waiter); - send({ - method: 'textDocument/didOpen', - params: { - textDocument: { - languageId: LANGUAGE_IDS.get(extname(file)) ?? 'plaintext', - text: await readFile(file, 'utf8'), - uri, - version: 1, - }, + const documents: SourceDocument[] = await Promise.all( + files.map(async (file) => ({ + file, + text: await readFile(file, 'utf8'), + uri: pathToFileURL(file).href, + })) + ); + const documentsToCheck = documents.filter(containsTailwindSyntax); + const documentWaiters = documentsToCheck.map(({ file, text, uri }) => { + const diagnosticsReady = deferred(); + const documentReady = deferred(); + diagnosticWaiters.set(uri, diagnosticsReady); + documentReadyWaiters.set(uri, documentReady); + send({ + method: 'textDocument/didOpen', + params: { + textDocument: { + languageId: LANGUAGE_IDS.get(extname(file)) ?? 'plaintext', + text, + uri, + version: 1, }, - }); - await withTimeout( - waiter.promise, - `Tailwind diagnostics for ${relative(workspace, file)}` - ); - }) + }, + }); + return { diagnosticsReady, documentReady, file, text, uri }; + }); + await Promise.all( + documentWaiters.map(({ documentReady, file }) => + withTimeout( + documentReady.promise, + `Tailwind document readiness for ${relative(workspace, file)}` + ) + ) ); + const reportedDiagnostics: Array<{ + diagnostics: Diagnostic[]; + file: string; + }> = []; + for (const { diagnosticsReady, file, text, uri } of documentWaiters) { + send({ + method: 'textDocument/didChange', + params: { + contentChanges: [{ text: `${text}\n` }], + textDocument: { uri, version: 2 }, + }, + }); + reportedDiagnostics.push({ + diagnostics: await withTimeout( + diagnosticsReady.promise, + `Tailwind diagnostics for ${relative(workspace, file)}` + ), + file, + }); + } - const failures = [...diagnostics.entries()] - .flatMap(([uri, fileDiagnostics]) => + const failures = [ + ...reportedDiagnostics.flatMap(({ diagnostics: fileDiagnostics, file }) => fileDiagnostics .filter( (diagnostic) => diagnostic.severity === undefined || diagnostic.severity <= 2 ) - .map((diagnostic) => ({ diagnostic, file: fileURLToPath(uri) })) - ) - .sort( - (left, right) => - left.file.localeCompare(right.file) || - left.diagnostic.range.start.line - right.diagnostic.range.start.line || - left.diagnostic.range.start.character - right.diagnostic.range.start.character - ); + .map((diagnostic) => ({ diagnostic, file })) + ), + ...(await scopedBorderColorConflicts(documentsToCheck, request)), + ].sort( + (left, right) => + left.file.localeCompare(right.file) || + left.diagnostic.range.start.line - right.diagnostic.range.start.line || + left.diagnostic.range.start.character - right.diagnostic.range.start.character + ); if (failures.length > 0) { for (const { diagnostic, file } of failures) { diff --git a/src/app.tsx b/src/app.tsx index 50785b1..7201c85 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -17,6 +17,7 @@ import { WorkoutPanel } from './components/workout-panel'; import { WorkoutProgress } from './components/workout-progress'; import { useGearControl } from './hooks/use-gear-control'; import { useHeartRateMonitor } from './hooks/use-heart-rate-monitor'; +import { useRememberedBluetoothDevices } from './hooks/use-remembered-bluetooth-devices'; import { useSession } from './hooks/use-session'; import { useSessionWorkflow } from './hooks/use-session-workflow'; import { useTrainer } from './hooks/use-trainer'; @@ -26,6 +27,7 @@ import { useZwiftClick } from './hooks/use-zwift-click'; import { APP_OVERLAY, type AppOverlay } from './lib/app-overlay'; import { CONTROL_MODE, type ControlMode } from './lib/control-mode'; import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom'; +import { resistanceForVirtualGear } from './lib/gears'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; @@ -62,15 +64,21 @@ function controlModeForClick(paired: boolean): ControlMode { } export function App() { - const trainer = useTrainer(); + const rememberedDevices = useRememberedBluetoothDevices(); + const trainer = useTrainer(rememberedDevices); const [activeOverlay, setActiveOverlay] = useState(() => shouldShowWelcome() ? APP_OVERLAY.WELCOME : undefined ); const devicesOpen = activeOverlay === APP_OVERLAY.DEVICES; const clickShiftRef = useRef<(change: number) => void>(() => undefined); const handleClickShift = useCallback((change: number) => clickShiftRef.current(change), []); - const click = useZwiftClick(handleClickShift, trainer.setNotice, devicesOpen); - const heartRate = useHeartRateMonitor(trainer.setNotice); + const heartRate = useHeartRateMonitor(rememberedDevices, trainer.setNotice); + const click = useZwiftClick( + handleClickShift, + trainer.setNotice, + devicesOpen, + rememberedDevices + ); const liveMetrics = metricsWithHeartRate( trainer.metrics, heartRate.connected, @@ -81,11 +89,17 @@ export function App() { const workoutLibrary = useWorkoutLibrary(); const virtualShiftingReady = trainer.connected && click.connectedCount === MAX_CLICK_CONTROLLERS; + const gearResistanceRef = useRef<(fromGear: number, toGear: number) => void>( + trainer.shiftResistanceForGears + ); + const handleGearChange = useCallback( + (fromGear: number, toGear: number) => gearResistanceRef.current(fromGear, toGear), + [] + ); const gearControl = useGearControl({ active: click.paired, - onResistanceChange: trainer.shiftResistanceBy, + onGearChange: handleGearChange, ready: virtualShiftingReady, - resistance: trainer.resistance, setNotice: trainer.setNotice, }); const session = useSession( @@ -108,16 +122,26 @@ export function App() { const workoutTerrain = dashboardWorkout.workout ? workoutTerrainAtDistance(dashboardWorkout.workout.course, dashboardWorkout.distance) : undefined; + const virtualShiftingActive = click.paired; + let workoutResistance = workoutTerrain?.resistance; + if (workoutTerrain && virtualShiftingActive) { + workoutResistance = resistanceForVirtualGear(workoutTerrain.resistance, gearControl.gear); + } + gearResistanceRef.current = workoutTerrain + ? (_fromGear, toGear) => + trainer.updateProgramShiftResistance( + resistanceForVirtualGear(workoutTerrain.resistance, toGear) + ) + : trainer.shiftResistanceForGears; useWorkoutResistance({ active: !session.ended, connected: trainer.connected, onResistanceChange: trainer.updateProgramResistance, onRestoreResistance: trainer.restoreManualResistance, - terrain: workoutTerrain, + resistance: workoutResistance, }); const workflow = useSessionWorkflow(session, trainer.setNotice, trainer.settleAfterRide); const dashboardKeyboardEnabled = activeOverlay === undefined && !workflow.saveDialogOpen; - const virtualShiftingActive = click.paired && !dashboardWorkout.workout; clickShiftRef.current = shiftHandlerUnlessBlocked( gearControl.shiftGear, !(dashboardKeyboardEnabled && virtualShiftingActive) @@ -305,6 +329,7 @@ export function App() { elevationTotals={dashboardWorkout.elevationTotals} isRiding={session.isRiding} speedUnit={speedUnit} + targetResistance={workoutResistance} terrain={workoutTerrain} workout={dashboardWorkout.workout} /> @@ -320,7 +345,7 @@ export function App() { speedUnit={speedUnit} workout={session.workout} /> - {workoutTerrain ? null : ( + {workoutTerrain && !virtualShiftingActive ? null : ( void }) { diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx index 6626bff..89c228e 100644 --- a/src/components/device-pairing.tsx +++ b/src/components/device-pairing.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { CHROME_BLUETOOTH_FLAGS_URL } from '../constants'; import { automaticBluetoothReconnectConfigured, bluetoothBrowserNotice } from '../lib/browser'; import type { DeviceConnectionView } from '../lib/device-connection'; @@ -30,6 +30,58 @@ interface ClickSlot extends DeviceSlot { reconnecting: boolean; } +const SLOW_RECONNECT_NOTICE_DELAY_MS = 10_000; + +function ConnectingLabel() { + return ( + + Connecting... + + + ); +} + +function SlowReconnectNotice() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const timeout = window.setTimeout(() => setVisible(true), SLOW_RECONNECT_NOTICE_DELAY_MS); + return () => window.clearTimeout(timeout); + }, []); + + if (!visible) { + return null; + } + + return ( +
+ Connecting can take up to 60 seconds. Please be patient. If a device seems stuck, reload + the page or re-pair it. +
+ ); +} + +function DeviceConnectionAction({ + busy, + disconnecting, +}: { + busy: boolean; + disconnecting: boolean; +}) { + if (busy) { + return ; + } + return disconnecting ? 'Disconnect' : 'Reconnect'; +} + function clickControllerOrder(controller: ClickController) { if (controller.label.startsWith('+')) { return 0; @@ -52,8 +104,8 @@ function StatusDot({ let statusClass = 'bg-slate-600'; if (busy) { statusClass = bluePulse - ? 'animate-pulse bg-sky-400 shadow-[0_0_10px_rgba(56,189,248,.7)]' - : 'animate-pulse bg-yellow-300'; + ? 'connection-status-pulse bg-sky-300 ring-2 ring-sky-400/35 shadow-[0_0_16px_rgba(56,189,248,.95)]' + : 'connection-status-pulse bg-yellow-300 ring-2 ring-yellow-300/25 shadow-[0_0_14px_rgba(253,224,71,.75)]'; } else if (connected) { statusClass = 'bg-mint shadow-[0_0_10px_rgba(173,245,189,.55)]'; } @@ -76,12 +128,6 @@ function DeviceActions({ slot }: { slot: DeviceSlot }) { ); } - let connectionAction = 'Reconnect'; - if (actionBusy) { - connectionAction = 'Connecting…'; - } else if (slot.connected) { - connectionAction = 'Disconnect'; - } const disconnecting = slot.connected && !actionBusy; return (
@@ -91,7 +137,7 @@ function DeviceActions({ slot }: { slot: DeviceSlot }) { onClick={disconnecting ? slot.onDisconnect : slot.onReconnect} type="button" > - {connectionAction} +
@@ -215,7 +267,8 @@ export function DevicePairingButton({ } let statusClass = 'bg-slate-600'; if (connecting) { - statusClass = 'animate-pulse bg-sky-400 shadow-[0_0_10px_rgba(56,189,248,.7)]'; + statusClass = + 'connection-status-pulse bg-sky-300 ring-2 ring-sky-400/35 shadow-[0_0_16px_rgba(56,189,248,.95)]'; } else if (allConnected) { statusClass = 'bg-mint shadow-[0_0_8px_rgba(173,245,189,.45)]'; } @@ -230,7 +283,7 @@ export function DevicePairingButton({ {pairedCount ? 'Devices' : 'Pair devices'} {pairedCount ? ( - + {connectedCount}/{pairedCount} ) : null} @@ -263,6 +316,10 @@ export function DevicePairingPanel({ const clickSlot: DeviceSlot = click; const waitingForControllers = click.reconnecting || click.phase === 'connecting'; + const reconnecting = trainer.reconnecting || heartRate.reconnecting || click.reconnecting; + const allPairedDevicesConnected = + [trainer, heartRate].every((slot) => !slot.paired || slot.connected) && + click.connectedCount === click.pairedCount; const orderedClickControllers = [...click.controllers].sort( (left, right) => clickControllerOrder(left) - clickControllerOrder(right) ); @@ -304,6 +361,13 @@ export function DevicePairingPanel({ × + {open && + !browserNotice && + automaticReconnectConfigured && + reconnecting && + !allPairedDevicesConnected ? ( + + ) : null} {browserNotice ? (

- {waitingForControllers ? 'Reconnecting…' : click.status} + {waitingForControllers ? : click.status}

diff --git a/src/components/session-detail.tsx b/src/components/session-detail.tsx index 3efc081..a0c5fd6 100644 --- a/src/components/session-detail.tsx +++ b/src/components/session-detail.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import { EMPTY_ROUTE } from '../constants'; import { CONTROL_MODE } from '../lib/control-mode'; import { aggregateMaximum, formatAggregateAverage, formatWholeNumber } from '../lib/format'; +import { resistanceForVirtualGear } from '../lib/gears'; import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; import { feelingLabel, @@ -104,6 +105,11 @@ export function SessionDetail({ const workoutTerrain = session.workout ? workoutTerrainAtDistance(session.workout.course, session.distance) : undefined; + const finalGear = session.history.findLast((sample) => sample.gear !== undefined)?.gear; + const workoutResistance = + workoutTerrain && finalGear !== undefined + ? resistanceForVirtualGear(workoutTerrain.resistance, finalGear) + : undefined; const controlMetric = usesGear ? { accent: 'mint', @@ -194,7 +200,7 @@ export function SessionDetail({ /> ) : null} -
+
diff --git a/src/components/session-overview.tsx b/src/components/session-overview.tsx index a87120e..2f07547 100644 --- a/src/components/session-overview.tsx +++ b/src/components/session-overview.tsx @@ -24,7 +24,7 @@ export function SessionOverview({ }) { return (
-
+
-
+
diff --git a/src/constants.ts b/src/constants.ts index dfd1a14..96daadd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,6 +17,9 @@ export const CHROME_BLUETOOTH_FLAGS_URL = export const CHROME_BLUETOOTH_PERMISSION_MESSAGE = 'Chrome returned no site-authorized.'; 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 BLUETOOTH_OPERATION_TIMEOUT_MS = 5000; +export const OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS = 1000; export const RECORDING_PAUSE_DELAY_MS = 10_000; export const CONTROL_FLASH_MS = 180; export const MAX_SESSION_HISTORY_SAMPLES = 3600; diff --git a/src/hooks/use-gear-control.ts b/src/hooks/use-gear-control.ts index 9151690..99e5aa3 100644 --- a/src/hooks/use-gear-control.ts +++ b/src/hooks/use-gear-control.ts @@ -3,8 +3,6 @@ import { CONTROL_FLASH_MS } from '../constants'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { GEAR_STORAGE_KEY, - gearForResistance, - resistanceChangeForGears, SHIFTING_CONNECTION_MESSAGE, shiftedGear, storedGear, @@ -14,18 +12,16 @@ import type { ResistanceAdjustmentDirection } from '../types'; export function useGearControl({ active, - onResistanceChange, + onGearChange, ready, - resistance, setNotice, }: { active: boolean; - onResistanceChange: (change: number) => void; + onGearChange: (fromGear: number, toGear: number) => void; ready: boolean; - resistance: number; setNotice: (notice: string) => void; }) { - const [gear, setGear] = useState(() => storedGear(localStorage, gearForResistance(resistance))); + const [gear, setGear] = useState(() => storedGear()); const [shiftFlash, setShiftFlash] = useState(); const gearRef = useRef(gear); const keyboardControlsEnabled = useRef(true); @@ -51,9 +47,9 @@ export function useGearControl({ gearRef.current = next; setGear(next); localStorage.setItem(GEAR_STORAGE_KEY, String(next)); - onResistanceChange(resistanceChangeForGears(previous, next)); + onGearChange(previous, next); }, - [onResistanceChange, ready, setNotice] + [onGearChange, ready, setNotice] ); useEffect(() => { diff --git a/src/hooks/use-heart-rate-monitor.ts b/src/hooks/use-heart-rate-monitor.ts index 6bcd7a3..1147bb5 100644 --- a/src/hooks/use-heart-rate-monitor.ts +++ b/src/hooks/use-heart-rate-monitor.ts @@ -1,16 +1,30 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { BATTERY, HEART_RATE, WEB_BLUETOOTH_UNAVAILABLE_MESSAGE } from '../constants'; import { isBluetoothChooserCancellation } from '../lib/bluetooth'; +import { + createBluetoothReconnectController, + reconnectBluetoothDeviceNow, + scheduleBluetoothDeviceReconnect, +} from '../lib/bluetooth-reconnect'; import { type DeviceConnectionPhase, deviceConnectionView } from '../lib/device-connection'; import { errorMessage } from '../lib/errors'; import { connectHeartRateDevice } from '../lib/heart-rate-device'; -import { createReconnectController } from '../lib/reconnect-controller'; +import { + type RememberedBluetoothDeviceCatalog, + rememberedBluetoothDevice, +} from '../lib/remembered-bluetooth-devices'; +import { usePageHide } from './use-page-hide'; const STORAGE_KEY = 'heart-rate-device-id'; -export function useHeartRateMonitor(setNotice: (notice: string) => void) { +export function useHeartRateMonitor( + rememberedDevices: RememberedBluetoothDeviceCatalog, + setNotice: (notice: string) => void +) { const [device, setDevice] = useState(); - const [phase, setPhase] = useState('unpaired'); + const [phase, setPhase] = useState(() => + localStorage.getItem(STORAGE_KEY) ? 'reconnecting' : 'unpaired' + ); const [heartRate, setHeartRate] = useState(0); const [battery, setBattery] = useState(); const autoReconnect = useRef(true); @@ -21,19 +35,19 @@ export function useHeartRateMonitor(setNotice: (notice: string) => void) { ((selected: BluetoothDevice, reconnecting?: boolean) => Promise) | undefined >(undefined); const reconnectController = useRef( - createReconnectController({ + createBluetoothReconnectController({ attempt: (selected) => connectDeviceRef.current?.(selected, true) ?? Promise.resolve(false), canRetry: () => autoReconnect.current && !forgotten.current, - delayForAttempt: (attempt) => Math.min(5000, 900 * attempt), onWaiting: () => setPhase('reconnecting'), + watchAdvertisements: false, }) ); const handleDisconnect = useCallback((selected: BluetoothDevice) => { connectionCleanup.current(); setHeartRate(0); if (autoReconnect.current && !forgotten.current) { - reconnectController.current.start(selected.id, selected, 900); + scheduleBluetoothDeviceReconnect(reconnectController.current, selected); } else { setPhase('offline'); } @@ -48,7 +62,7 @@ export function useHeartRateMonitor(setNotice: (notice: string) => void) { } setNotice(`Heart rate monitor connection failed: ${errorMessage(error)}`); if (autoReconnect.current && !forgotten.current) { - reconnectController.current.start(selected.id, selected); + scheduleBluetoothDeviceReconnect(reconnectController.current, selected); } }, [setNotice] @@ -62,13 +76,14 @@ export function useHeartRateMonitor(setNotice: (notice: string) => void) { connecting.current = true; setPhase(reconnecting ? 'reconnecting' : 'connecting'); connectionCleanup.current(); + setBattery(undefined); try { - const connection = await connectHeartRateDevice(selected, { + const connection = await connectHeartRateDevice(selected, reconnecting, { + onBattery: setBattery, onDisconnect: () => handleDisconnect(selected), onHeartRate: setHeartRate, }); connectionCleanup.current = connection.cleanup; - setBattery(connection.battery); setDevice(selected); setPhase('connected'); reconnectController.current.reset(selected.id); @@ -112,17 +127,15 @@ export function useHeartRateMonitor(setNotice: (notice: string) => void) { } }, [connectDevice, device, setNotice]); - const reconnect = useCallback(async () => { + const reconnect = useCallback(() => { if (!device) { return; } forgotten.current = false; autoReconnect.current = true; reconnectController.current.reset(device.id); - if (!(await connectDevice(device, true))) { - reconnectController.current.start(device.id, device); - } - }, [connectDevice, device]); + reconnectBluetoothDeviceNow(reconnectController.current, device); + }, [device]); const disconnect = useCallback(() => { autoReconnect.current = false; @@ -154,36 +167,52 @@ export function useHeartRateMonitor(setNotice: (notice: string) => void) { } }, [device]); + usePageHide(() => { + autoReconnect.current = false; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + device?.gatt?.disconnect(); + }); + useEffect(() => { - let cancelled = false; - async function restore() { - if (!navigator.bluetooth?.getDevices) { - return; - } - const savedId = localStorage.getItem(STORAGE_KEY); - if (!savedId) { - return; - } - const remembered = (await navigator.bluetooth.getDevices()).find( - (candidate) => candidate.id === savedId - ); - if (!(remembered && !cancelled)) { - return; + const savedDeviceId = localStorage.getItem(STORAGE_KEY); + if (!savedDeviceId) { + setPhase('unpaired'); + return; + } + if (!(rememberedDevices.supported && rememberedDevices.devices)) { + if (rememberedDevices.error) { + setPhase('offline'); } - setDevice(remembered); - setPhase('reconnecting'); - forgotten.current = false; - autoReconnect.current = true; - reconnectController.current.start(remembered.id, remembered, 1); + return; + } + const remembered = rememberedBluetoothDevice(rememberedDevices.devices, savedDeviceId); + if (!remembered) { + setPhase('unpaired'); + return; } - restore().catch(() => setPhase('offline')); - return () => { - cancelled = true; + setDevice(remembered); + setPhase('reconnecting'); + forgotten.current = false; + autoReconnect.current = true; + reconnectBluetoothDeviceNow(reconnectController.current, remembered); + }, [rememberedDevices.devices, rememberedDevices.error, rememberedDevices.supported]); + + useEffect( + () => () => { autoReconnect.current = false; reconnectController.current.cancelAll(); connectionCleanup.current(); - }; - }, []); + }, + [] + ); + + useEffect( + () => () => { + device?.gatt?.disconnect(); + }, + [device] + ); return { battery, diff --git a/src/hooks/use-page-hide.ts b/src/hooks/use-page-hide.ts new file mode 100644 index 0000000..a661ab9 --- /dev/null +++ b/src/hooks/use-page-hide.ts @@ -0,0 +1,12 @@ +import { useEffect, useRef } from 'react'; + +export function usePageHide(onPageHide: () => void) { + const callback = useRef(onPageHide); + callback.current = onPageHide; + + useEffect(() => { + const handlePageHide = () => callback.current(); + window.addEventListener('pagehide', handlePageHide); + return () => window.removeEventListener('pagehide', handlePageHide); + }, []); +} diff --git a/src/hooks/use-remembered-bluetooth-devices.ts b/src/hooks/use-remembered-bluetooth-devices.ts new file mode 100644 index 0000000..f3f6483 --- /dev/null +++ b/src/hooks/use-remembered-bluetooth-devices.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { + loadRememberedBluetoothDevices, + type RememberedBluetoothDeviceCatalog, + supportsRememberedBluetoothDevices, +} from '../lib/remembered-bluetooth-devices'; + +export function useRememberedBluetoothDevices(): RememberedBluetoothDeviceCatalog { + const { bluetooth } = navigator; + const [catalog, setCatalog] = useState(() => ({ + supported: supportsRememberedBluetoothDevices(bluetooth), + })); + + useEffect(() => { + let cancelled = false; + if (!supportsRememberedBluetoothDevices(bluetooth)) { + return; + } + loadRememberedBluetoothDevices(bluetooth).then( + (devices) => { + if (!cancelled) { + setCatalog({ devices, supported: true }); + } + }, + (error: unknown) => { + if (!cancelled) { + setCatalog({ error, supported: true }); + } + } + ); + return () => { + cancelled = true; + }; + }, []); + + return catalog; +} diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index 35acdbb..2563cd8 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -1,7 +1,6 @@ import { useSelector } from '@tanstack/react-store'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { RECORDING_PAUSE_DELAY_MS } from '../constants'; -import { CONTROL_MODE } from '../lib/control-mode'; import { loadStoredSession, SESSION_STORAGE_KEY } from '../lib/session'; import { MILLISECONDS_PER_SECOND, secondsForMilliseconds } from '../lib/units'; import { @@ -61,13 +60,6 @@ interface SessionController { workout?: SessionWorkout; } -function controlForWorkout( - control: SessionControlState, - workout?: SessionWorkout -): SessionControlState { - return workout ? { ...control, mode: CONTROL_MODE.RESISTANCE } : control; -} - export function useSession( metrics: Metrics, control: SessionControlState, @@ -76,9 +68,8 @@ export function useSession( ): SessionController { const store = useMemo(() => createSessionStore(loadStoredSession()), []); const state = useSelector(store); - const effectiveControl = controlForWorkout(control, state.workout); const latestMetrics = useRef(metrics); - const latestControl = useRef(effectiveControl); + const latestControl = useRef(control); const lastTrainerDistance = useRef(undefined); useEffect(() => { @@ -87,9 +78,9 @@ export function useSession( }, [metrics, store]); useEffect(() => { - latestControl.current = effectiveControl; - store.actions.observeControlMode(effectiveControl.mode); - }, [effectiveControl, store]); + latestControl.current = control; + store.actions.observeControlMode(control.mode); + }, [control, store]); useEffect(() => { persistSessionState(store.get()); diff --git a/src/hooks/use-trainer-connection.ts b/src/hooks/use-trainer-connection.ts index b19f483..7166635 100644 --- a/src/hooks/use-trainer-connection.ts +++ b/src/hooks/use-trainer-connection.ts @@ -6,17 +6,23 @@ import { WEB_BLUETOOTH_UNAVAILABLE_MESSAGE, } from '../constants'; import { - findRememberedKickr, isBluetoothChooserCancellation, recordMetricActivity, resistanceCommand, + selectRememberedKickr, TRAINER_DEVICE_STORAGE_KEY, } from '../lib/bluetooth'; +import { + createBluetoothReconnectController, + reconnectBluetoothDeviceNow, + scheduleBluetoothDeviceReconnect, +} from '../lib/bluetooth-reconnect'; import { errorMessage } from '../lib/errors'; -import { createReconnectController } from '../lib/reconnect-controller'; +import type { RememberedBluetoothDeviceCatalog } from '../lib/remembered-bluetooth-devices'; import { storedResistance } from '../lib/session'; import { connectTrainerDevice } from '../lib/trainer-device'; import type { TrainerStore } from '../stores/trainer-store'; +import { usePageHide } from './use-page-hide'; interface NumberRef { current: number; @@ -29,7 +35,8 @@ function pairingWasCancelled(error: unknown, connectionCancelled: boolean) { export function useTrainerConnection( store: TrainerStore, appliedResistance: NumberRef, - resistanceTarget: NumberRef + resistanceTarget: NumberRef, + rememberedDevices: RememberedBluetoothDeviceCatalog ) { const { setConnectionPhase, setMetrics, setNotice, setResistance, setResistanceRamp } = store.actions; @@ -51,12 +58,11 @@ export function useTrainerConnection( const controlPoint = useRef(undefined); const resistanceRange = useRef(store.get().resistanceRange); const reconnectController = useRef( - createReconnectController({ + createBluetoothReconnectController({ attempt: (selected) => connectDeviceRef.current?.(selected, true) ?? Promise.resolve(false), canRetry: () => autoReconnect.current && !unloading.current && !connectionCancelled.current, - delayForAttempt: (attempt) => Math.min(5000, 700 * attempt), onWaiting: () => setConnectionPhase('reconnecting'), }) ); @@ -109,7 +115,7 @@ export function useTrainerConnection( pendingDevice.current = selected; setConnectionPhase('reconnecting'); setNotice('Trainer disconnected. Reconnecting automatically…'); - reconnectController.current.start(selected.id, selected, 700); + scheduleBluetoothDeviceReconnect(reconnectController.current, selected); } else if (connectionCancelled.current) { setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); setNotice('Connection attempt stopped.'); @@ -182,6 +188,7 @@ export function useTrainerConnection( device.current = selected; store.actions.setDeviceName(selected.name); setConnectionPhase('connected'); + nextConnection.startOptionalMetrics(); reconnectController.current.reset(selected.id); setNotice(`${selected.name ?? 'Trainer'} is connected and ready.`); return true; @@ -218,7 +225,7 @@ export function useTrainerConnection( store.actions.setPairedDeviceName(selected.name); autoReconnect.current = true; if (!(await connectDevice(selected))) { - reconnectController.current.start(selected.id, selected); + scheduleBluetoothDeviceReconnect(reconnectController.current, selected); } } catch (error) { setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); @@ -256,7 +263,7 @@ export function useTrainerConnection( setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); }, [setConnectionPhase, setMetrics, store]); - async function reconnect() { + function reconnect() { if (!pairedDevice.current) { return; } @@ -265,9 +272,7 @@ export function useTrainerConnection( disconnectRequested.current = false; autoReconnect.current = true; reconnectController.current.reset(selected.id); - if (!(await connectDevice(selected, true))) { - reconnectController.current.start(selected.id, selected); - } + reconnectBluetoothDeviceNow(reconnectController.current, selected); } const forget = useCallback(async () => { @@ -301,51 +306,54 @@ export function useTrainerConnection( [writeControl] ); - useEffect(() => { - const handlePageHide = () => { - unloading.current = true; - autoReconnect.current = false; - disconnectRequested.current = true; - reconnectController.current.cancelAll(); - connectionCleanup.current(); - device.current?.gatt?.disconnect(); - }; - window.addEventListener('pagehide', handlePageHide); - return () => window.removeEventListener('pagehide', handlePageHide); - }, []); + usePageHide(() => { + unloading.current = true; + autoReconnect.current = false; + disconnectRequested.current = true; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + device.current?.gatt?.disconnect(); + }); useEffect(() => { - let cancelled = false; - async function restore() { - autoReconnect.current = true; - connectionCancelled.current = false; - disconnectRequested.current = false; - if (!navigator.bluetooth?.getDevices) { - setConnectionPhase('unpaired'); - setNotice(CHROME_BLUETOOTH_PERMISSION_MESSAGE); - return; - } - const remembered = await findRememberedKickr(); - if (cancelled) { - return; - } - if (!remembered) { - setConnectionPhase('unpaired'); - return; - } - pairedDevice.current = remembered; - store.actions.setPairedDeviceName(remembered.name); - setConnectionPhase('reconnecting'); - reconnectController.current.start(remembered.id, remembered, 1); + if (!rememberedDevices.supported) { + setConnectionPhase('unpaired'); + setNotice(CHROME_BLUETOOTH_PERMISSION_MESSAGE); + return; + } + if (rememberedDevices.error) { + setConnectionPhase('offline'); + setNotice(errorMessage(rememberedDevices.error)); + return; } - restore().catch((error: unknown) => setNotice(errorMessage(error))); + if (!rememberedDevices.devices) { + return; + } + const remembered = selectRememberedKickr(rememberedDevices.devices); + if (!remembered) { + setConnectionPhase('unpaired'); + return; + } + autoReconnect.current = true; + connectionCancelled.current = false; + disconnectRequested.current = false; + pairedDevice.current = remembered; + store.actions.setPairedDeviceName(remembered.name); + setConnectionPhase('reconnecting'); + reconnectBluetoothDeviceNow(reconnectController.current, remembered); return () => { - cancelled = true; autoReconnect.current = false; reconnectController.current.cancelAll(); connectionCleanup.current(); }; - }, [setConnectionPhase, setNotice, store.actions.setPairedDeviceName]); + }, [ + rememberedDevices.devices, + rememberedDevices.error, + rememberedDevices.supported, + setConnectionPhase, + setNotice, + store.actions.setPairedDeviceName, + ]); return { cancelConnection, diff --git a/src/hooks/use-trainer.ts b/src/hooks/use-trainer.ts index 8954d12..0da3ec0 100644 --- a/src/hooks/use-trainer.ts +++ b/src/hooks/use-trainer.ts @@ -4,8 +4,10 @@ import { CONTROL_FLASH_MS } from '../constants'; import { deviceConnectionView } from '../lib/device-connection'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { errorMessage } from '../lib/errors'; +import { resistanceAfterGearShift } from '../lib/gears'; import { scheduleNoticeDismissal } from '../lib/notification'; import { clamp } from '../lib/numbers'; +import type { RememberedBluetoothDeviceCatalog } from '../lib/remembered-bluetooth-devices'; import { clampResistance, DEFAULT_RESISTANCE, @@ -17,7 +19,7 @@ import { RESISTANCE_STORAGE_KEY } from '../lib/session'; import { createTrainerStore } from '../stores/trainer-store'; import { useTrainerConnection } from './use-trainer-connection'; -export function useTrainer() { +export function useTrainer(rememberedDevices: RememberedBluetoothDeviceCatalog) { const store = useMemo(() => createTrainerStore(), []); const state = useSelector(store); const { setNotice, setResistance, setResistanceKeyFlash, setResistanceRamp } = store.actions; @@ -29,7 +31,12 @@ export function useTrainer() { const resistanceTarget = useRef(store.get().resistance); const keyboardControlsEnabled = useRef(true); const gearControlsEnabled = useRef(false); - const trainerConnection = useTrainerConnection(store, appliedResistance, resistanceTarget); + const trainerConnection = useTrainerConnection( + store, + appliedResistance, + resistanceTarget, + rememberedDevices + ); const connection = deviceConnectionView(state.connectionPhase); useEffect( @@ -145,14 +152,13 @@ export function useTrainer() { [connection.connected, queueResistance] ); - const shiftResistanceBy = useCallback( - (change: number) => { - const next = clampResistance(resistanceTarget.current + change); + const applyResistanceImmediately = useCallback( + (value: number, remember: boolean) => { + const next = clampResistance(value); window.clearTimeout(resistanceTimer.current); window.clearTimeout(resistanceRampTimer.current); resistanceTarget.current = next; appliedResistance.current = next; - rememberedResistance.current = next; setResistance(next); setResistanceRamp({ current: next, @@ -161,13 +167,29 @@ export function useTrainer() { progress: 1, to: next, }); - localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next)); + if (remember) { + rememberedResistance.current = next; + localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next)); + } trainerConnection .sendResistance(next) .catch((error: unknown) => setNotice(errorMessage(error))); }, [setNotice, setResistance, setResistanceRamp, trainerConnection.sendResistance] ); + const shiftResistanceForGears = useCallback( + (fromGear: number, toGear: number) => { + applyResistanceImmediately( + resistanceAfterGearShift(resistanceTarget.current, fromGear, toGear), + true + ); + }, + [applyResistanceImmediately] + ); + const updateProgramShiftResistance = useCallback( + (value: number) => applyResistanceImmediately(value, false), + [applyResistanceImmediately] + ); useEffect(() => { const handleKeys = (event: KeyboardEvent) => { @@ -245,9 +267,10 @@ export function useTrainer() { setKeyboardControlsEnabled, setNotice, settleAfterRide, - shiftResistanceBy, + shiftResistanceForGears, trainerReportsDistance: trainerConnection.trainerReportsDistance, updateProgramResistance, + updateProgramShiftResistance, updateResistance, }; } diff --git a/src/hooks/use-workout.ts b/src/hooks/use-workout.ts index a0f45c9..dfa7de3 100644 --- a/src/hooks/use-workout.ts +++ b/src/hooks/use-workout.ts @@ -1,20 +1,18 @@ import { useEffect, useRef } from 'react'; -import type { WorkoutTerrain } from '../types'; export function useWorkoutResistance({ active, connected, onResistanceChange, onRestoreResistance, - terrain, + resistance, }: { active: boolean; connected: boolean; onResistanceChange: (resistance: number) => void; onRestoreResistance: () => void; - terrain?: WorkoutTerrain; + resistance?: number; }) { - const resistance = terrain?.resistance; const automatedResistance = useRef(false); useEffect(() => { diff --git a/src/hooks/use-zwift-click.ts b/src/hooks/use-zwift-click.ts index 491c3af..0bcf243 100644 --- a/src/hooks/use-zwift-click.ts +++ b/src/hooks/use-zwift-click.ts @@ -2,13 +2,23 @@ import { useSelector } from '@tanstack/react-store'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { BATTERY, WEB_BLUETOOTH_UNAVAILABLE_MESSAGE } from '../constants'; import { isBluetoothChooserCancellation } from '../lib/bluetooth'; +import { + createBluetoothReconnectController, + reconnectBluetoothDevicesNow, + scheduleBluetoothDeviceReconnect, + scheduleBluetoothDeviceReconnects, +} from '../lib/bluetooth-reconnect'; import { aggregateConnectionPhase, deviceConnectionView } from '../lib/device-connection'; import { errorMessage } from '../lib/errors'; -import { createReconnectController } from '../lib/reconnect-controller'; +import { + type RememberedBluetoothDeviceCatalog, + rememberedBluetoothDevices, +} from '../lib/remembered-bluetooth-devices'; import { CLICK_DEVICE_IDS_STORAGE_KEY, type ClickControllerRoles, type ClickShift, + clickControllerRoleFromManufacturerData, MAX_CLICK_CONTROLLERS, storedClickControllerRoles, storedClickDeviceIds, @@ -19,6 +29,7 @@ import { } from '../lib/zwift-click'; import { connectClickDevice, SupersededClickConnectionError } from '../lib/zwift-click-device'; import { createZwiftClickStore } from '../stores/zwift-click-store'; +import { usePageHide } from './use-page-hide'; import { useZwiftClickInput } from './use-zwift-click-input'; interface ClickConnectionOptions { @@ -57,7 +68,8 @@ function shouldAutoReconnect( export function useZwiftClick( onShift: (change: number) => void, setNotice: (notice: string) => void, - identifyControllers = false + identifyControllers: boolean, + rememberedDeviceCatalog: RememberedBluetoothDeviceCatalog ) { const store = useMemo(() => createZwiftClickStore(), []); const state = useSelector(store); @@ -69,20 +81,24 @@ export function useZwiftClick( const devicesRef = useRef([]); const forgottenIds = useRef(new Set()); const reportedConnectionFailures = useRef(new Set()); + const handleReconnectAdvertisement = useRef< + ((selected: BluetoothDevice, event: BluetoothAdvertisingEvent) => void) | undefined + >(undefined); const connectDeviceRef = useRef< | ((selected: BluetoothDevice, options?: ClickConnectionOptions) => Promise) | undefined >(undefined); const operationalIds = useRef(new Set()); const reconnectController = useRef( - createReconnectController({ + createBluetoothReconnectController({ attempt: (selected) => connectDeviceRef.current?.(selected, { rediscover: true, scheduleRetry: false, }) ?? Promise.resolve(false), canRetry: (selected) => autoReconnect.current && !forgottenIds.current.has(selected.id), - delayForAttempt: (attempt) => Math.min(1000, 400 * 2 ** (attempt - 1)), + onAdvertisement: (selected, event) => + handleReconnectAdvertisement.current?.(selected, event), onWaiting: (selected) => store.actions.setControllerPhase(selected.id, 'reconnecting'), }) ); @@ -102,6 +118,12 @@ export function useZwiftClick( onShift, store, }); + handleReconnectAdvertisement.current = (selected, event) => { + const role = clickControllerRoleFromManufacturerData(event.manufacturerData); + if (role) { + clickInput.registerControllerRole(selected.id, [role]); + } + }; const cleanupConnection = useCallback((deviceId: string) => { connectionCleanups.current.get(deviceId)?.(); @@ -115,7 +137,7 @@ export function useZwiftClick( clickInput.resetControllerInput(selected.id); if (shouldAutoReconnect(autoReconnect.current, forgottenIds.current, selected.id)) { setControllerPhase(selected.id, 'reconnecting'); - reconnectController.current.start(selected.id, selected); + scheduleBluetoothDeviceReconnect(reconnectController.current, selected); } else { setControllerPhase(selected.id, 'offline'); } @@ -129,17 +151,12 @@ export function useZwiftClick( const cleanup = await connectClickDevice(selected, rediscover, { isCurrent: isCurrentAttempt, isOperational: () => operationalIds.current.has(selected.id), - onControllerRole: (role) => clickInput.registerControllerRole(selected.id, [role]), onDisconnect: handleDisconnect, onMessage: (event) => clickInput.handleControllerMessage(selected.id, event), }); connectionCleanups.current.set(selected.id, cleanup); }, - [ - clickInput.handleControllerMessage, - clickInput.registerControllerRole, - handleControllerDisconnect, - ] + [clickInput.handleControllerMessage, handleControllerDisconnect] ); const beginControllerConnectionAttempt = useCallback( @@ -188,7 +205,7 @@ export function useZwiftClick( setNotice(`Zwift Click connection failed: ${errorMessage(error)}`); } if (shouldReconnect && scheduleRetry) { - reconnectController.current.start(selected.id, selected); + scheduleBluetoothDeviceReconnect(reconnectController.current, selected); } }, [cleanupConnection, clickInput.clearDeviceHeldShifts, setControllerPhase, setNotice] @@ -279,13 +296,13 @@ export function useZwiftClick( const reconnect = useCallback(() => { autoReconnect.current = true; - for (const selected of devicesRef.current) { - if (store.get().controllerPhases[selected.id] === 'connected') { - continue; - } + const disconnected = devicesRef.current.filter( + (selected) => store.get().controllerPhases[selected.id] !== 'connected' + ); + for (const selected of disconnected) { reconnectController.current.reset(selected.id); - reconnectController.current.start(selected.id, selected, 1); } + reconnectBluetoothDevicesNow(reconnectController.current, disconnected); }, [store]); const disconnect = useCallback(() => { @@ -338,45 +355,17 @@ export function useZwiftClick( } }, [forgetDevice]); - useEffect(() => { - let cancelled = false; - async function restore() { - if (!navigator.bluetooth?.getDevices) { - return; - } - const ids = storedClickDeviceIds(); - if (!ids.length) { - return; - } - const permitted = await navigator.bluetooth.getDevices(); - const remembered = ids - .map((id) => permitted.find((candidate) => candidate.id === id)) - .filter((candidate): candidate is BluetoothDevice => Boolean(candidate)) - .slice(0, MAX_CLICK_CONTROLLERS); - if (cancelled) { - return; - } - const rememberedIds = new Set(remembered.map(({ id }) => id)); - const rememberedRoles = Object.fromEntries( - Object.entries(storedClickControllerRoles()).filter(([deviceId]) => - rememberedIds.has(deviceId) - ) - ) as ClickControllerRoles; - clickInput.restoreControllerRoles(rememberedRoles); - devicesRef.current = remembered; - store.actions.setDeviceIds(remembered.map(({ id }) => id)); - store.actions.setControllerPhases( - Object.fromEntries(remembered.map((selected) => [selected.id, 'reconnecting'])) - ); - for (const selected of remembered) { - forgottenIds.current.delete(selected.id); - } - autoReconnect.current = true; - for (const selected of remembered) { - reconnectController.current.start(selected.id, selected, 1); - } + usePageHide(() => { + autoReconnect.current = false; + reconnectController.current.cancelAll(); + for (const selected of devicesRef.current) { + cleanupConnection(selected.id); + selected.gatt?.disconnect(); } - restore().catch(() => { + }); + + useEffect(() => { + if (rememberedDeviceCatalog.error) { store.actions.setControllerPhases( Object.fromEntries( Object.keys(store.get().controllerPhases).map((deviceId) => [ @@ -385,13 +374,39 @@ export function useZwiftClick( ]) ) ); - }); - return () => { - cancelled = true; - autoReconnect.current = false; - reconnectController.current.cancelAll(); - }; - }, [clickInput.restoreControllerRoles, store]); + return; + } + if (!rememberedDeviceCatalog.devices) { + return; + } + const remembered = rememberedBluetoothDevices( + rememberedDeviceCatalog.devices, + storedClickDeviceIds(), + MAX_CLICK_CONTROLLERS + ); + const rememberedIds = new Set(remembered.map(({ id }) => id)); + const rememberedRoles = Object.fromEntries( + Object.entries(storedClickControllerRoles()).filter(([deviceId]) => + rememberedIds.has(deviceId) + ) + ) as ClickControllerRoles; + clickInput.restoreControllerRoles(rememberedRoles); + devicesRef.current = remembered; + store.actions.setDeviceIds(remembered.map(({ id }) => id)); + store.actions.setControllerPhases( + Object.fromEntries(remembered.map((selected) => [selected.id, 'reconnecting'])) + ); + for (const selected of remembered) { + forgottenIds.current.delete(selected.id); + } + autoReconnect.current = true; + scheduleBluetoothDeviceReconnects(reconnectController.current, remembered); + }, [ + clickInput.restoreControllerRoles, + rememberedDeviceCatalog.devices, + rememberedDeviceCatalog.error, + store, + ]); const connectionPhases = state.deviceIds.map( (deviceId) => state.controllerPhases[deviceId] ?? 'offline' diff --git a/src/lib/bluetooth-gatt-coordinator.ts b/src/lib/bluetooth-gatt-coordinator.ts new file mode 100644 index 0000000..f25c074 --- /dev/null +++ b/src/lib/bluetooth-gatt-coordinator.ts @@ -0,0 +1,54 @@ +import { withPromiseTimeout } from './promise-timeout'; + +export interface BluetoothGattCoordinator { + connect: ( + device: BluetoothDevice, + timeoutMs: number, + timeoutMessage: string + ) => Promise; +} + +export function createBluetoothGattCoordinator(): BluetoothGattCoordinator { + const pending = new Map>(); + + return { + connect: (device, timeoutMs, timeoutMessage) => { + const existing = pending.get(device.id); + if (existing) { + return existing; + } + const connect = async () => { + const { gatt } = device; + if (!gatt) { + throw new Error('This device does not expose a GATT server.'); + } + if (gatt.connected) { + return gatt; + } + try { + return await withPromiseTimeout( + gatt.connect(), + timeoutMs, + () => new Error(timeoutMessage) + ); + } catch (error) { + gatt.disconnect(); + throw error; + } + }; + // Chrome can establish independent devices concurrently. Only collapse duplicate + // requests for the same physical device so one slow sensor never blocks another. + const connection = connect(); + pending.set(device.id, connection); + const clearPending = () => { + if (pending.get(device.id) === connection) { + pending.delete(device.id); + } + }; + connection.then(clearPending, clearPending); + return connection; + }, + }; +} + +export const bluetoothGattCoordinator = createBluetoothGattCoordinator(); diff --git a/src/lib/bluetooth-notifications.ts b/src/lib/bluetooth-notifications.ts index 5fe6998..d3fa05d 100644 --- a/src/lib/bluetooth-notifications.ts +++ b/src/lib/bluetooth-notifications.ts @@ -1,3 +1,6 @@ +import { BLUETOOTH_OPERATION_TIMEOUT_MS } from '../constants'; +import { withBluetoothOperationTimeout } from './bluetooth-operation'; + export type BluetoothNotificationCleanup = () => void; export interface BluetoothNotificationSubscription { @@ -20,11 +23,16 @@ export function createBluetoothNotificationSubscription( export async function startBluetoothNotifications( characteristic: BluetoothRemoteGATTCharacteristic, - listener: (event: Event) => void + listener: (event: Event) => void, + timeoutMs = BLUETOOTH_OPERATION_TIMEOUT_MS ): Promise { const subscription = createBluetoothNotificationSubscription(characteristic, listener); try { - await subscription.start(); + await withBluetoothOperationTimeout( + subscription.start(), + 'Bluetooth notification setup', + timeoutMs + ); } catch (error) { subscription.cleanup(); throw error; diff --git a/src/lib/bluetooth-operation.ts b/src/lib/bluetooth-operation.ts new file mode 100644 index 0000000..26c3c8e --- /dev/null +++ b/src/lib/bluetooth-operation.ts @@ -0,0 +1,10 @@ +import { BLUETOOTH_OPERATION_TIMEOUT_MS } from '../constants'; +import { withPromiseTimeout } from './promise-timeout'; + +export function withBluetoothOperationTimeout( + operation: Promise, + description: string, + timeoutMs = BLUETOOTH_OPERATION_TIMEOUT_MS +): Promise { + return withPromiseTimeout(operation, timeoutMs, () => new Error(`${description} timed out.`)); +} diff --git a/src/lib/bluetooth-reconnect.ts b/src/lib/bluetooth-reconnect.ts new file mode 100644 index 0000000..e1b5b13 --- /dev/null +++ b/src/lib/bluetooth-reconnect.ts @@ -0,0 +1,135 @@ +import { type BluetoothAdvertisementWatch, watchBluetoothAdvertisements } from './bluetooth'; +import { + createReconnectController, + type ReconnectController, + type ReconnectControllerOptions, +} from './reconnect-controller'; + +const FIRST_RECONNECT_DELAY_MS = 250; +const MAX_RECONNECT_DELAY_MS = 2000; +const IMMEDIATE_RECONNECT_DELAY_MS = 1; + +interface BluetoothReconnectControllerOptions + extends Omit, 'delayForAttempt'> { + onAdvertisement?: (target: T, event: BluetoothAdvertisingEvent) => void; + watchAdvertisements?: boolean; +} + +export function bluetoothReconnectDelay(attempt: number): number { + return Math.min(MAX_RECONNECT_DELAY_MS, FIRST_RECONNECT_DELAY_MS * 2 ** (attempt - 1)); +} + +export function createBluetoothReconnectController({ + onAdvertisement, + watchAdvertisements = true, + ...options +}: BluetoothReconnectControllerOptions): ReconnectController { + const advertisementWatches = new Map(); + const stopWatching = (key: string) => { + advertisementWatches.get(key)?.stop(); + advertisementWatches.delete(key); + }; + let controller: ReconnectController; + const ensureWatching = (key: string, target: T) => { + if (!watchAdvertisements) { + return; + } + const existing = advertisementWatches.get(key); + if (existing) { + return existing; + } + let activeWatch: BluetoothAdvertisementWatch | undefined; + const watch = watchBluetoothAdvertisements( + target, + (event) => { + onAdvertisement?.(target, event); + controller.expedite(key, target, IMMEDIATE_RECONNECT_DELAY_MS); + }, + () => { + if (advertisementWatches.get(key) === activeWatch) { + advertisementWatches.delete(key); + } + } + ); + if (!watch.supported) { + return; + } + activeWatch = watch; + advertisementWatches.set(key, watch); + return watch; + }; + const reconnectController = createReconnectController({ + ...options, + attempt: async (target) => { + await ensureWatching(target.id, target)?.ready; + const connected = await options.attempt(target); + if (connected) { + stopWatching(target.id); + } else { + ensureWatching(target.id, target); + } + return connected; + }, + delayForAttempt: bluetoothReconnectDelay, + }); + controller = { + ...reconnectController, + cancel: (key, resetAttempts) => { + if (resetAttempts) { + stopWatching(key); + } + reconnectController.cancel(key, resetAttempts); + }, + cancelAll: () => { + for (const key of advertisementWatches.keys()) { + stopWatching(key); + } + reconnectController.cancelAll(); + }, + expedite: (key, target, delay) => { + ensureWatching(key, target); + reconnectController.expedite(key, target, delay); + }, + reset: (key) => { + stopWatching(key); + reconnectController.reset(key); + }, + start: (key, target, initialDelay) => { + ensureWatching(key, target); + reconnectController.start(key, target, initialDelay); + }, + }; + return controller; +} + +export function reconnectBluetoothDeviceNow( + controller: ReconnectController, + device: BluetoothDevice +) { + controller.expedite(device.id, device, IMMEDIATE_RECONNECT_DELAY_MS); +} + +export function reconnectBluetoothDevicesNow( + controller: ReconnectController, + devices: readonly BluetoothDevice[] +) { + for (const device of devices) { + reconnectBluetoothDeviceNow(controller, device); + } +} + +export function scheduleBluetoothDeviceReconnect( + controller: ReconnectController, + device: BluetoothDevice +) { + controller.start(device.id, device); +} + +export function scheduleBluetoothDeviceReconnects( + controller: ReconnectController, + devices: readonly BluetoothDevice[] +) { + for (const device of devices) { + scheduleBluetoothDeviceReconnect(controller, device); + } +} diff --git a/src/lib/bluetooth.ts b/src/lib/bluetooth.ts index 9f9e5d9..233a2af 100644 --- a/src/lib/bluetooth.ts +++ b/src/lib/bluetooth.ts @@ -1,4 +1,7 @@ +import { BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS } from '../constants'; import type { Metrics, Range } from '../types'; +import { bluetoothGattCoordinator } from './bluetooth-gatt-coordinator'; +import { loadRememberedBluetoothDevices } from './remembered-bluetooth-devices'; import { MAX_RESISTANCE } from './resistance'; import { kilometersForMeters, SECONDS_PER_MINUTE } from './units'; @@ -9,6 +12,17 @@ export interface CrankReading { time: number; } +interface GattConnectionTiming { + directTimeoutMs: number; + reconnectProbeTimeoutMs: number; +} + +const ADVERTISEMENT_DISCOVERY_WARMUP_MS = 250; +const DEFAULT_GATT_CONNECTION_TIMING: GattConnectionTiming = { + directTimeoutMs: BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS, + reconnectProbeTimeoutMs: BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS, +}; + export function isBluetoothChooserCancellation(error: unknown): boolean { return error instanceof DOMException && error.name === 'NotFoundError'; } @@ -122,67 +136,87 @@ export async function findRememberedKickr( bluetooth: Bluetooth = navigator.bluetooth, storage: Pick = localStorage ): Promise { - if (!bluetooth?.getDevices) { - return; - } - const permitted = await bluetooth.getDevices(); + const permitted = await loadRememberedBluetoothDevices(bluetooth); + return selectRememberedKickr(permitted, storage); +} + +export function selectRememberedKickr( + permitted: readonly BluetoothDevice[], + storage: Pick = localStorage +): BluetoothDevice | undefined { const savedDeviceId = storage.getItem(TRAINER_DEVICE_STORAGE_KEY); return ( permitted.find((candidate) => candidate.id === savedDeviceId) ?? - permitted.find((candidate) => candidate.name?.toUpperCase().startsWith('KICKR')) ?? - (permitted.length === 1 ? permitted[0] : undefined) + permitted.find((candidate) => candidate.name?.toUpperCase().startsWith('KICKR')) ); } -export async function waitForFreshAdvertisement( - device: BluetoothDevice, - onAdvertisement?: (event: BluetoothAdvertisingEvent) => void, - timeoutMs = 3000 -): Promise { - if (!device.watchAdvertisements) { - return; - } - const controller = new AbortController(); - await new Promise((resolve) => { - let finished = false; - let timeout: number | undefined; - const finish = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(timeout); - device.removeEventListener('advertisementreceived', handleAdvertisement); - controller.abort(); - resolve(); - }; - const handleAdvertisement = (event: Event) => { - try { - onAdvertisement?.(event as BluetoothAdvertisingEvent); - } finally { - finish(); - } - }; - device.addEventListener('advertisementreceived', handleAdvertisement, { once: true }); - timeout = window.setTimeout(finish, timeoutMs); - device.watchAdvertisements({ signal: controller.signal }).catch(finish); +export interface BluetoothAdvertisementWatch { + ready: Promise; + stop: () => void; + supported: boolean; +} + +function boundedAdvertisementWarmup(started: Promise): Promise { + let timeout: ReturnType | undefined; + const warmup = new Promise((resolve) => { + timeout = setTimeout(resolve, ADVERTISEMENT_DISCOVERY_WARMUP_MS); }); + return Promise.race([started, warmup]).finally(() => clearTimeout(timeout)); } -export async function connectGatt( +export function watchBluetoothAdvertisements( device: BluetoothDevice, - rediscover: boolean -): Promise { - if (!device.gatt) { - throw new Error('This device does not expose a GATT server.'); - } - try { - return await device.gatt.connect(); - } catch (directConnectionError) { - if (!rediscover) { - throw directConnectionError; + onAdvertisement: (event: BluetoothAdvertisingEvent) => void, + onUnavailable?: () => void +): BluetoothAdvertisementWatch { + if (!device.watchAdvertisements) { + onUnavailable?.(); + return { ready: Promise.resolve(), stop: () => undefined, supported: false }; + } + const abortController = new AbortController(); + let active = true; + const handleAdvertisement = (event: Event) => + onAdvertisement(event as BluetoothAdvertisingEvent); + const startedWatch = !device.watchingAdvertisements; + const stop = () => { + if (!active) { + return; + } + active = false; + device.removeEventListener('advertisementreceived', handleAdvertisement); + if (startedWatch) { + abortController.abort(); + } + }; + device.addEventListener('advertisementreceived', handleAdvertisement); + let ready = Promise.resolve(); + if (startedWatch) { + try { + const started = device + .watchAdvertisements({ signal: abortController.signal }) + .catch(() => { + stop(); + onUnavailable?.(); + }); + ready = boundedAdvertisementWarmup(started); + } catch { + stop(); + onUnavailable?.(); + return { ready, stop, supported: false }; } - await waitForFreshAdvertisement(device); - return await device.gatt.connect(); } + return { ready, stop, supported: true }; +} + +export function connectGatt( + device: BluetoothDevice, + rediscover: boolean, + timing: GattConnectionTiming = DEFAULT_GATT_CONNECTION_TIMING +): Promise { + return bluetoothGattCoordinator.connect( + device, + rediscover ? timing.reconnectProbeTimeoutMs : timing.directTimeoutMs, + 'Bluetooth device connection timed out.' + ); } diff --git a/src/lib/gears.ts b/src/lib/gears.ts index 590c6bf..85a45ba 100644 --- a/src/lib/gears.ts +++ b/src/lib/gears.ts @@ -1,21 +1,20 @@ import { clamp } from './numbers'; +import { clampResistance } from './resistance'; export const MIN_GEAR = 1; export const MAX_GEAR = 24; export const DEFAULT_GEAR = 12; -export const RESISTANCE_POINTS_PER_GEAR = 3; export const GEAR_STORAGE_KEY = 'trainer-virtual-gear'; export const SHIFTING_CONNECTION_MESSAGE = 'Connect the trainer and controllers before shifting gears.'; +const GEAR_STEPS_PER_RESISTANCE_DOUBLING = 12; +const RESISTANCE_PRECISION = 10; + export function clampGear(gear: number): number { return clamp(Math.round(gear), MIN_GEAR, MAX_GEAR); } -export function gearForResistance(resistance: number): number { - return clampGear(resistance / RESISTANCE_POINTS_PER_GEAR + 1); -} - export function storedGear( storage: Pick = localStorage, fallback = DEFAULT_GEAR @@ -28,6 +27,22 @@ export function shiftedGear(current: number, change: number): number { return clampGear(current + change); } -export function resistanceChangeForGears(from: number, to: number): number { - return (clampGear(to) - clampGear(from)) * RESISTANCE_POINTS_PER_GEAR; +export function virtualGearRatio(gear: number): number { + return 2 ** ((clampGear(gear) - DEFAULT_GEAR) / GEAR_STEPS_PER_RESISTANCE_DOUBLING); +} + +function roundedResistance(resistance: number): number { + return Math.round(clampResistance(resistance) * RESISTANCE_PRECISION) / RESISTANCE_PRECISION; +} + +export function resistanceForVirtualGear(baseResistance: number, gear: number): number { + return roundedResistance(baseResistance * virtualGearRatio(gear)); +} + +export function resistanceAfterGearShift( + resistance: number, + fromGear: number, + toGear: number +): number { + return roundedResistance(resistance * (virtualGearRatio(toGear) / virtualGearRatio(fromGear))); } diff --git a/src/lib/heart-rate-device.ts b/src/lib/heart-rate-device.ts index 2a39f68..4499848 100644 --- a/src/lib/heart-rate-device.ts +++ b/src/lib/heart-rate-device.ts @@ -1,30 +1,47 @@ -import { BATTERY, HEART_RATE } from '../constants'; +import { BATTERY, HEART_RATE, OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS } from '../constants'; +import { connectGatt } from './bluetooth'; import { startBluetoothNotifications } from './bluetooth-notifications'; +import { withBluetoothOperationTimeout } from './bluetooth-operation'; import { parseHeartRateMeasurement } from './heart-rate'; +import { withPromiseTimeout } from './promise-timeout'; const HEART_RATE_MEASUREMENT = 0x2a_37; const BATTERY_LEVEL = 0x2a_19; interface HeartRateDeviceCallbacks { + onBattery: (battery: number) => void; onDisconnect: () => void; onHeartRate: (heartRate: number) => void; } export interface HeartRateDeviceConnection { - battery?: number; cleanup: () => void; } +async function readBatteryLevel(server: BluetoothRemoteGATTServer): Promise { + const batteryValue = await ( + await (await server.getPrimaryService(BATTERY)).getCharacteristic(BATTERY_LEVEL) + ).readValue(); + return batteryValue.getUint8(0); +} + export async function connectHeartRateDevice( device: BluetoothDevice, - { onDisconnect, onHeartRate }: HeartRateDeviceCallbacks + rediscover: boolean, + { onBattery, onDisconnect, onHeartRate }: HeartRateDeviceCallbacks, + operationTimeoutMs?: number ): Promise { - const server = await device.gatt?.connect(); - if (!server) { - throw new Error('This heart rate monitor does not expose Bluetooth services.'); - } - const service = await server.getPrimaryService(HEART_RATE); - const measurement = await service.getCharacteristic(HEART_RATE_MEASUREMENT); + const server = await connectGatt(device, rediscover); + const service = await withBluetoothOperationTimeout( + server.getPrimaryService(HEART_RATE), + 'Heart rate service discovery', + operationTimeoutMs + ); + const measurement = await withBluetoothOperationTimeout( + service.getCharacteristic(HEART_RATE_MEASUREMENT), + 'Heart rate measurement discovery', + operationTimeoutMs + ); const handleMeasurement = (event: Event) => { const { value } = event.target as BluetoothRemoteGATTCharacteristic; if (!value) { @@ -37,21 +54,26 @@ export async function connectHeartRateDevice( }; const removeMeasurementListener = await startBluetoothNotifications( measurement, - handleMeasurement + handleMeasurement, + operationTimeoutMs + ); + let active = true; + withPromiseTimeout( + readBatteryLevel(server), + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, + () => new Error('Battery level unavailable.') + ).then( + (battery) => { + if (active) { + onBattery(battery); + } + }, + () => undefined ); - let battery: number | undefined; - try { - const batteryValue = await ( - await (await server.getPrimaryService(BATTERY)).getCharacteristic(BATTERY_LEVEL) - ).readValue(); - battery = batteryValue.getUint8(0); - } catch { - battery = undefined; - } device.addEventListener('gattserverdisconnected', onDisconnect, { once: true }); return { - battery, cleanup: () => { + active = false; removeMeasurementListener(); device.removeEventListener('gattserverdisconnected', onDisconnect); }, diff --git a/src/lib/promise-timeout.ts b/src/lib/promise-timeout.ts new file mode 100644 index 0000000..7051462 --- /dev/null +++ b/src/lib/promise-timeout.ts @@ -0,0 +1,17 @@ +export async function withPromiseTimeout( + promise: Promise, + timeoutMs: number, + timeoutError: () => Error +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(timeoutError()), timeoutMs); + }), + ]); + } finally { + clearTimeout(timeout); + } +} diff --git a/src/lib/reconnect-controller.ts b/src/lib/reconnect-controller.ts index 00db6f1..8dfda57 100644 --- a/src/lib/reconnect-controller.ts +++ b/src/lib/reconnect-controller.ts @@ -1,4 +1,4 @@ -interface ReconnectControllerOptions { +export interface ReconnectControllerOptions { attempt: (target: T) => Promise; canRetry: (target: T) => boolean; clearTimer?: typeof clearTimeout; @@ -9,6 +9,8 @@ interface ReconnectControllerOptions { interface RetryEntry { attempt: number; + expediteDelay?: number; + inFlight: boolean; target: T; timer?: ReturnType; } @@ -16,6 +18,7 @@ interface RetryEntry { export interface ReconnectController { cancel: (key: string, resetAttempts?: boolean) => void; cancelAll: () => void; + expedite: (key: string, target: T, delay?: number) => void; isPending: (key: string) => boolean; reset: (key: string) => void; start: (key: string, target: T, initialDelay?: number) => void; @@ -55,7 +58,9 @@ export function createReconnectController({ entries.delete(key); return; } + entry.inFlight = true; const connected = await attempt(entry.target).catch(() => false); + entry.inFlight = false; if (entries.get(key) !== entry) { return; } @@ -64,7 +69,9 @@ export function createReconnectController({ return; } entry.attempt += 1; - schedule(key, entry, delayForAttempt(entry.attempt)); + const nextDelay = entry.expediteDelay ?? delayForAttempt(entry.attempt); + entry.expediteDelay = undefined; + schedule(key, entry, nextDelay); }, delay); }; @@ -75,14 +82,33 @@ export function createReconnectController({ cancel(key, true); } }, - isPending: (key) => Boolean(entries.get(key)?.timer), + expedite: (key, target, delay = 0) => { + const current = entries.get(key); + if (current?.inFlight) { + current.expediteDelay = delay; + current.target = target; + return; + } + if (current?.timer) { + clearTimer(current.timer); + } + const entry = current ?? { attempt: 1, inFlight: false, target }; + entry.target = target; + entry.timer = undefined; + entries.set(key, entry); + schedule(key, entry, delay); + }, + isPending: (key) => { + const entry = entries.get(key); + return Boolean(entry?.timer || entry?.inFlight); + }, reset: (key) => cancel(key, true), start: (key, target, initialDelay = 0) => { const current = entries.get(key); - if (current?.timer) { + if (current?.timer || current?.inFlight) { return; } - const entry = current ?? { attempt: 1, target }; + const entry = current ?? { attempt: 1, inFlight: false, target }; entry.target = target; entries.set(key, entry); schedule(key, entry, initialDelay || delayForAttempt(entry.attempt)); diff --git a/src/lib/remembered-bluetooth-devices.ts b/src/lib/remembered-bluetooth-devices.ts new file mode 100644 index 0000000..dea418f --- /dev/null +++ b/src/lib/remembered-bluetooth-devices.ts @@ -0,0 +1,38 @@ +export interface RememberedBluetoothDeviceCatalog { + devices?: readonly BluetoothDevice[]; + error?: unknown; + supported: boolean; +} + +export function supportsRememberedBluetoothDevices( + bluetooth: Bluetooth | undefined = navigator.bluetooth +): boolean { + return Boolean(bluetooth?.getDevices); +} + +export function loadRememberedBluetoothDevices( + bluetooth: Bluetooth | undefined = navigator.bluetooth +): Promise { + if (!bluetooth?.getDevices) { + return Promise.resolve([]); + } + return Promise.resolve().then(() => bluetooth.getDevices?.() ?? []); +} + +export function rememberedBluetoothDevice( + devices: readonly BluetoothDevice[], + deviceId: string | null +): BluetoothDevice | undefined { + return deviceId ? devices.find(({ id }) => id === deviceId) : undefined; +} + +export function rememberedBluetoothDevices( + devices: readonly BluetoothDevice[], + deviceIds: readonly string[], + limit = deviceIds.length +): BluetoothDevice[] { + return deviceIds + .map((deviceId) => rememberedBluetoothDevice(devices, deviceId)) + .filter((device): device is BluetoothDevice => Boolean(device)) + .slice(0, limit); +} diff --git a/src/lib/trainer-device.ts b/src/lib/trainer-device.ts index 11a4167..5e5f460 100644 --- a/src/lib/trainer-device.ts +++ b/src/lib/trainer-device.ts @@ -5,8 +5,8 @@ import { CYCLING_POWER_MEASUREMENT, CYCLING_SPEED_AND_CADENCE, FITNESS_MACHINE, - FITNESS_MACHINE_STATUS, INDOOR_BIKE_DATA, + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, SUPPORTED_RESISTANCE_LEVEL_RANGE, } from '../constants'; import type { Metrics, Range } from '../types'; @@ -16,7 +16,13 @@ import { parseCrankCadence, parseIndoorBikeData, } from './bluetooth'; -import { combineBluetoothCleanups, startBluetoothNotifications } from './bluetooth-notifications'; +import { + combineBluetoothCleanups, + createBluetoothNotificationSubscription, + startBluetoothNotifications, +} from './bluetooth-notifications'; +import { withBluetoothOperationTimeout } from './bluetooth-operation'; +import { withPromiseTimeout } from './promise-timeout'; interface TrainerDeviceCallbacks { onControlRejected: () => void; @@ -28,6 +34,11 @@ export interface TrainerDeviceConnection { cleanup: () => void; controlPoint: BluetoothRemoteGATTCharacteristic; resistanceRange: Range; + startOptionalMetrics: () => void; +} + +async function readResistanceRange(service: BluetoothRemoteGATTService) { + return (await service.getCharacteristic(SUPPORTED_RESISTANCE_LEVEL_RANGE)).readValue(); } async function optionalPowerSubscription( @@ -35,15 +46,26 @@ async function optionalPowerSubscription( onMetrics: TrainerDeviceCallbacks['onMetrics'] ) { try { - const measurement = await (await server.getPrimaryService(CYCLING_POWER)).getCharacteristic( - CYCLING_POWER_MEASUREMENT + const service = await withPromiseTimeout( + server.getPrimaryService(CYCLING_POWER), + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, + () => new Error('Cycling power service unavailable.') + ); + const measurement = await withPromiseTimeout( + service.getCharacteristic(CYCLING_POWER_MEASUREMENT), + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, + () => new Error('Cycling power measurement unavailable.') + ); + return await startBluetoothNotifications( + measurement, + (event) => { + const value = characteristicValue(event); + if (value) { + onMetrics({ power: value.getInt16(2, true) }, false); + } + }, + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS ); - return await startBluetoothNotifications(measurement, (event) => { - const value = characteristicValue(event); - if (value) { - onMetrics({ power: value.getInt16(2, true) }, false); - } - }); } catch { // Indoor Bike Data is the normal power source. } @@ -55,20 +77,31 @@ async function optionalCadenceSubscription( ) { let previousCrank: { revolutions: number; time: number } | undefined; try { - const measurement = await ( - await server.getPrimaryService(CYCLING_SPEED_AND_CADENCE) - ).getCharacteristic(CSC_MEASUREMENT); - return await startBluetoothNotifications(measurement, (event) => { - const value = characteristicValue(event); - if (!value) { - return; - } - const parsed = parseCrankCadence(value, previousCrank); - previousCrank = parsed.current ?? previousCrank; - if (parsed.cadence !== undefined) { - onMetrics({ cadence: parsed.cadence }, false); - } - }); + const service = await withPromiseTimeout( + server.getPrimaryService(CYCLING_SPEED_AND_CADENCE), + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, + () => new Error('Cycling cadence service unavailable.') + ); + const measurement = await withPromiseTimeout( + service.getCharacteristic(CSC_MEASUREMENT), + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, + () => new Error('Cycling cadence measurement unavailable.') + ); + return await startBluetoothNotifications( + measurement, + (event) => { + const value = characteristicValue(event); + if (!value) { + return; + } + const parsed = parseCrankCadence(value, previousCrank); + previousCrank = parsed.current ?? previousCrank; + if (parsed.cadence !== undefined) { + onMetrics({ cadence: parsed.cadence }, false); + } + }, + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS + ); } catch { // CSC cadence is optional. } @@ -81,9 +114,21 @@ export async function connectTrainerDevice( { onControlRejected, onDisconnect, onMetrics }: TrainerDeviceCallbacks ): Promise { const server = await connectGatt(device, rediscover); - const service = await server.getPrimaryService(FITNESS_MACHINE); - const bikeData = await service.getCharacteristic(INDOOR_BIKE_DATA); - const removeBikeData = await startBluetoothNotifications(bikeData, (event) => { + const service = await withBluetoothOperationTimeout( + server.getPrimaryService(FITNESS_MACHINE), + 'Fitness machine service discovery' + ); + // ATT requests share one channel on a device. Keep this setup sequential even + // while connections to other physical devices proceed in parallel. + const bikeData = await withBluetoothOperationTimeout( + service.getCharacteristic(INDOOR_BIKE_DATA), + 'Indoor bike data discovery' + ); + const controlPoint = await withBluetoothOperationTimeout( + service.getCharacteristic(CONTROL_POINT), + 'Fitness machine control discovery' + ); + const bikeDataNotifications = createBluetoothNotificationSubscription(bikeData, (event) => { const value = characteristicValue(event); if (!value) { return; @@ -91,24 +136,31 @@ export async function connectTrainerDevice( const parsed = parseIndoorBikeData(value); onMetrics(parsed.metrics, parsed.reportsDistance); }); - try { - const controlPoint = await service.getCharacteristic(CONTROL_POINT); - const removeControlPoint = await startBluetoothNotifications(controlPoint, (event) => { + const controlPointNotifications = createBluetoothNotificationSubscription( + controlPoint, + (event) => { const value = characteristicValue(event); if (value?.getUint8(0) === 0x80 && value.getUint8(2) !== 0x01) { onControlRejected(); } - }); - try { - await (await service.getCharacteristic(FITNESS_MACHINE_STATUS)).startNotifications(); - } catch { - // Optional characteristic. } + ); + try { + await withBluetoothOperationTimeout( + bikeDataNotifications.start(), + 'Indoor bike data notification setup' + ); + await withBluetoothOperationTimeout( + controlPointNotifications.start(), + 'Fitness machine control notification setup' + ); let resistanceRange = fallbackRange; try { - const rangeValue = await ( - await service.getCharacteristic(SUPPORTED_RESISTANCE_LEVEL_RANGE) - ).readValue(); + const rangeValue = await withPromiseTimeout( + readResistanceRange(service), + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, + () => new Error('Resistance range unavailable.') + ); resistanceRange = { max: rangeValue.getInt16(2, true) / 10, min: rangeValue.getInt16(0, true) / 10, @@ -119,9 +171,10 @@ export async function connectTrainerDevice( device.addEventListener('gattserverdisconnected', onDisconnect, { once: true }); const optionalCleanups: Array<() => void> = []; let cleanedUp = false; + let optionalMetricsStarted = false; const cleanupRequiredServices = combineBluetoothCleanups( - removeBikeData, - removeControlPoint, + bikeDataNotifications.cleanup, + controlPointNotifications.cleanup, () => device.removeEventListener('gattserverdisconnected', onDisconnect) ); const cleanup = () => { @@ -133,28 +186,38 @@ export async function connectTrainerDevice( combineBluetoothCleanups(...optionalCleanups)(); optionalCleanups.length = 0; }; - Promise.all([ - optionalPowerSubscription(server, onMetrics), - optionalCadenceSubscription(server, onMetrics), - ]).then((cleanups) => { - if (cleanedUp) { - combineBluetoothCleanups(...cleanups)(); + const startOptionalMetrics = () => { + if (optionalMetricsStarted) { return; } - for (const optionalCleanup of cleanups) { - if (optionalCleanup) { - optionalCleanups.push(optionalCleanup); + optionalMetricsStarted = true; + (async () => { + const cleanups = [ + await optionalPowerSubscription(server, onMetrics), + await optionalCadenceSubscription(server, onMetrics), + ]; + if (cleanedUp) { + combineBluetoothCleanups(...cleanups)(); + return; } - } - }); + for (const optionalCleanup of cleanups) { + if (optionalCleanup) { + optionalCleanups.push(optionalCleanup); + } + } + })().catch(() => undefined); + }; return { cleanup, controlPoint, resistanceRange, + startOptionalMetrics, }; } catch (error) { - combineBluetoothCleanups(removeBikeData, () => - device.removeEventListener('gattserverdisconnected', onDisconnect) + combineBluetoothCleanups( + bikeDataNotifications.cleanup, + controlPointNotifications.cleanup, + () => device.removeEventListener('gattserverdisconnected', onDisconnect) )(); throw error; } diff --git a/src/lib/zwift-click-device.ts b/src/lib/zwift-click-device.ts index b51f1aa..b7ab2e6 100644 --- a/src/lib/zwift-click-device.ts +++ b/src/lib/zwift-click-device.ts @@ -1,9 +1,7 @@ import { createBluetoothNotificationSubscription } from './bluetooth-notifications'; import { - type ClickShift, clickV2StartCommand, connectClickGatt, - waitForUsableClickNotification, withClickConnectionTimeout, ZWIFT_ASYNC_CHARACTERISTIC, ZWIFT_CLICK_SERVICE, @@ -33,7 +31,6 @@ async function clickService(server: BluetoothRemoteGATTServer) { interface ClickDeviceCallbacks { isCurrent: () => boolean; isOperational: () => boolean; - onControllerRole: (role: ClickShift) => void; onDisconnect: () => void; onMessage: (event: Event) => void; } @@ -41,29 +38,27 @@ interface ClickDeviceCallbacks { export async function connectClickDevice( device: BluetoothDevice, rediscover: boolean, - { isCurrent, isOperational, onControllerRole, onDisconnect, onMessage }: ClickDeviceCallbacks + { isCurrent, isOperational, onDisconnect, onMessage }: ClickDeviceCallbacks ): Promise<() => void> { - const server = await connectClickGatt(device, rediscover, onControllerRole); + const server = await connectClickGatt(device, rediscover); ensureCurrentConnection(isCurrent); const service = await withClickConnectionTimeout( clickService(server), CLICK_SETUP_STEP_TIMEOUT_MS ); ensureCurrentConnection(isCurrent); - const [asyncCharacteristic, syncTxCharacteristic, syncRxCharacteristic] = await Promise.all([ - withClickConnectionTimeout( - service.getCharacteristic(ZWIFT_ASYNC_CHARACTERISTIC), - CLICK_SETUP_STEP_TIMEOUT_MS - ), - withClickConnectionTimeout( - service.getCharacteristic(ZWIFT_SYNC_TX_CHARACTERISTIC), - CLICK_SETUP_STEP_TIMEOUT_MS - ), - withClickConnectionTimeout( - service.getCharacteristic(ZWIFT_SYNC_RX_CHARACTERISTIC), - CLICK_SETUP_STEP_TIMEOUT_MS - ), - ]); + const asyncCharacteristic = await withClickConnectionTimeout( + service.getCharacteristic(ZWIFT_ASYNC_CHARACTERISTIC), + CLICK_SETUP_STEP_TIMEOUT_MS + ); + const syncTxCharacteristic = await withClickConnectionTimeout( + service.getCharacteristic(ZWIFT_SYNC_TX_CHARACTERISTIC), + CLICK_SETUP_STEP_TIMEOUT_MS + ); + const syncRxCharacteristic = await withClickConnectionTimeout( + service.getCharacteristic(ZWIFT_SYNC_RX_CHARACTERISTIC), + CLICK_SETUP_STEP_TIMEOUT_MS + ); ensureCurrentConnection(isCurrent); const asyncNotifications = createBluetoothNotificationSubscription( asyncCharacteristic, @@ -80,13 +75,22 @@ export async function connectClickDevice( }; device.addEventListener('gattserverdisconnected', onDisconnect, { once: true }); try { - await waitForUsableClickNotification( - [ - withClickConnectionTimeout(asyncNotifications.start(), CLICK_SETUP_STEP_TIMEOUT_MS), - withClickConnectionTimeout(syncNotifications.start(), CLICK_SETUP_STEP_TIMEOUT_MS), - ], - isOperational - ); + let notificationStarted = false; + let notificationError: unknown; + for (const notifications of [asyncNotifications, syncNotifications]) { + try { + await withClickConnectionTimeout( + notifications.start(), + CLICK_SETUP_STEP_TIMEOUT_MS + ); + notificationStarted = true; + } catch (error) { + notificationError = error; + } + } + if (!(notificationStarted || isOperational())) { + throw notificationError; + } ensureCurrentConnection(isCurrent); try { await withClickConnectionTimeout( diff --git a/src/lib/zwift-click.ts b/src/lib/zwift-click.ts index 5a8265a..bea1aca 100644 --- a/src/lib/zwift-click.ts +++ b/src/lib/zwift-click.ts @@ -1,4 +1,6 @@ -import { waitForFreshAdvertisement } from './bluetooth'; +import { BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS } from '../constants'; +import { bluetoothGattCoordinator } from './bluetooth-gatt-coordinator'; +import { withPromiseTimeout } from './promise-timeout'; import { isRecord, isString } from './type-guards'; export const ZWIFT_CLICK_NAME = 'Zwift Click'; @@ -16,10 +18,7 @@ const CONTROLLER_NOTIFICATION = 0x23; const MINUS_BUTTON_MASK = 0x01_00; const PLUS_BUTTON_MASK = 0x10_00; const ALL_BUTTONS_RELEASED = 0xff_ff_ff_ff; -const CLICK_CONNECTION_TIMEOUT_MS = 8000; -const CLICK_RECONNECT_PROBE_TIMEOUT_MS = 2000; -const CLICK_REDISCOVERY_TIMEOUT_MS = 1600; -const CLICK_REDISCOVERED_CONNECTION_TIMEOUT_MS = 3500; +const CLICK_CONNECTION_TIMEOUT_MS = BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS; const CLICK_V2_RIGHT_SIDE = 0x0a; const CLICK_V2_LEFT_SIDE = 0x0b; @@ -62,59 +61,30 @@ export async function waitForUsableClickNotification( } } -export async function withClickConnectionTimeout( +export function withClickConnectionTimeout( connection: Promise, timeoutMs = CLICK_CONNECTION_TIMEOUT_MS ): Promise { - let timeout: ReturnType | undefined; - try { - return await Promise.race([ - connection, - new Promise((_, reject) => { - timeout = setTimeout( - () => reject(new Error('Controller did not respond. Wake it and try again.')), - timeoutMs - ); - }), - ]); - } finally { - clearTimeout(timeout); - } + return withPromiseTimeout( + connection, + timeoutMs, + () => new Error('Controller did not respond. Wake it and try again.') + ); } -export async function connectClickGatt( +export function connectClickGatt( device: BluetoothDevice, - rediscover: boolean, - onControllerRole?: (role: ClickShift) => void + _rediscover: boolean ): Promise { const { gatt } = device; if (!gatt) { - throw new Error('This controller does not expose Bluetooth services.'); - } - const connect = (timeoutMs: number) => withClickConnectionTimeout(gatt.connect(), timeoutMs); - const observeController = () => - waitForFreshAdvertisement( - device, - (event) => { - const role = clickControllerRoleFromManufacturerData(event.manufacturerData); - if (role) { - onControllerRole?.(role); - } - }, - CLICK_REDISCOVERY_TIMEOUT_MS - ); - // Observe the side identity concurrently so it does not slow an awake controller's - // direct connection. The same observation doubles as wake discovery if that probe fails. - const advertisement = onControllerRole ? observeController() : undefined; - try { - return await connect( - rediscover ? CLICK_RECONNECT_PROBE_TIMEOUT_MS : CLICK_CONNECTION_TIMEOUT_MS - ); - } catch { - gatt.disconnect(); - await (advertisement ?? observeController()); - return connect(CLICK_REDISCOVERED_CONNECTION_TIMEOUT_MS); + return Promise.reject(new Error('This controller does not expose Bluetooth services.')); } + return bluetoothGattCoordinator.connect( + device, + CLICK_CONNECTION_TIMEOUT_MS, + 'Controller did not respond. Wake it and try again.' + ); } export function shouldAcceptClickShift( diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts index 7eef6af..2e05b04 100644 --- a/src/stores/session-store.ts +++ b/src/stores/session-store.ts @@ -66,7 +66,6 @@ function selectActiveWorkout( } return { ...current, - controlMode: course ? CONTROL_MODE.RESISTANCE : current.controlMode, workout: course ? { course } : undefined, }; } @@ -245,7 +244,7 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) { return { ...emptySession, aggregates: emptySession.aggregates, - controlMode: workout ? CONTROL_MODE.RESISTANCE : controlMode, + controlMode, isRiding: false, manuallyPaused: false, maximums: emptyMetrics, diff --git a/src/style.css b/src/style.css index 169764f..14309d0 100644 --- a/src/style.css +++ b/src/style.css @@ -110,3 +110,86 @@ transform: translateY(0); } } + +@keyframes connecting-dot-one { + 0%, + 83% { + opacity: 1; + } + 84%, + 100% { + opacity: 0.12; + } +} + +@keyframes connecting-dot-two { + 0%, + 24% { + opacity: 0.12; + } + 25%, + 83% { + opacity: 1; + } + 84%, + 100% { + opacity: 0.12; + } +} + +@keyframes connecting-dot-three { + 0%, + 49% { + opacity: 0.12; + } + 50%, + 83% { + opacity: 1; + } + 84%, + 100% { + opacity: 0.12; + } +} + +@keyframes connection-status-pulse { + 0%, + 100% { + opacity: 0.8; + transform: scale(0.9); + } + 50% { + opacity: 1; + transform: scale(1.2); + } +} + +.connecting-dot { + display: inline-block; + width: 0.3em; + font-weight: 900; + color: var(--color-sky-300); + text-align: center; + opacity: 0.12; + animation: connecting-dot-one 1.4s linear infinite; +} + +.connecting-dot:nth-of-type(2) { + animation-name: connecting-dot-two; +} + +.connecting-dot:nth-of-type(3) { + animation-name: connecting-dot-three; +} + +.connection-status-pulse { + animation: connection-status-pulse 1.1s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .connection-status-pulse { + opacity: 1; + transform: none; + animation: none; + } +} diff --git a/tests/bluetooth-gatt-coordinator.test.ts b/tests/bluetooth-gatt-coordinator.test.ts new file mode 100644 index 0000000..fc34eb8 --- /dev/null +++ b/tests/bluetooth-gatt-coordinator.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'bun:test'; +import { createBluetoothGattCoordinator } from '../src/lib/bluetooth-gatt-coordinator'; + +function bluetoothDevice( + id: string, + connect: () => Promise, + onDisconnect: () => void = () => undefined +): BluetoothDevice { + return { + gatt: { + connect, + connected: false, + disconnect: onDisconnect, + }, + id, + } as unknown as BluetoothDevice; +} + +describe('Bluetooth GATT coordinator', () => { + test('establishes different device connections in parallel', async () => { + const coordinator = createBluetoothGattCoordinator(); + const operations: string[] = []; + let finishFirst: (() => void) | undefined; + const firstServer = {} as BluetoothRemoteGATTServer; + const secondServer = {} as BluetoothRemoteGATTServer; + const first = bluetoothDevice( + 'trainer', + () => + new Promise((resolve) => { + operations.push('trainer'); + finishFirst = () => resolve(firstServer); + }) + ); + const second = bluetoothDevice('heart-rate', () => { + operations.push('heart-rate'); + return Promise.resolve(secondServer); + }); + + const firstConnection = coordinator.connect(first, 1000, 'trainer timeout'); + const secondConnection = coordinator.connect(second, 1000, 'heart-rate timeout'); + await Promise.resolve(); + expect(operations).toEqual(['trainer', 'heart-rate']); + + finishFirst?.(); + expect(await firstConnection).toBe(firstServer); + expect(await secondConnection).toBe(secondServer); + }); + + test('deduplicates simultaneous requests for the same device', async () => { + const coordinator = createBluetoothGattCoordinator(); + let attempts = 0; + const server = {} as BluetoothRemoteGATTServer; + const device = bluetoothDevice('click-plus', () => { + attempts += 1; + return Promise.resolve(server); + }); + + const first = coordinator.connect(device, 1000, 'timeout'); + const duplicate = coordinator.connect(device, 1000, 'timeout'); + expect(first).toBe(duplicate); + expect(await duplicate).toBe(server); + expect(attempts).toBe(1); + }); + + test('disconnects a failed device without blocking another handshake', async () => { + const coordinator = createBluetoothGattCoordinator(); + const operations: string[] = []; + const failed = bluetoothDevice( + 'click-minus', + () => { + operations.push('connect-click-minus'); + return Promise.reject(new Error('unavailable')); + }, + () => operations.push('disconnect-click-minus') + ); + const server = {} as BluetoothRemoteGATTServer; + const ready = bluetoothDevice('trainer', () => { + operations.push('connect-trainer'); + return Promise.resolve(server); + }); + + const failedConnection = coordinator.connect(failed, 1000, 'timeout'); + const readyConnection = coordinator.connect(ready, 1000, 'timeout'); + expect(await readyConnection).toBe(server); + await expect(failedConnection).rejects.toThrow('unavailable'); + expect(operations).toContain('disconnect-click-minus'); + expect(operations.slice(0, 2)).toEqual(['connect-click-minus', 'connect-trainer']); + }); +}); diff --git a/tests/bluetooth.test.ts b/tests/bluetooth.test.ts index 7dea269..97c3866 100644 --- a/tests/bluetooth.test.ts +++ b/tests/bluetooth.test.ts @@ -9,6 +9,11 @@ import { recordPedaling, resistanceCommand, } from '../src/lib/bluetooth'; +import { + loadRememberedBluetoothDevices, + rememberedBluetoothDevice, + rememberedBluetoothDevices, +} from '../src/lib/remembered-bluetooth-devices'; describe('Bluetooth data utilities', () => { test('tracks meaningful pedaling activity', () => { @@ -77,7 +82,7 @@ describe('Bluetooth data utilities', () => { expect(resistanceCommand(50, { max: 30, min: -10 })).toEqual([4, 100, 0]); }); - test('prefers a saved device, then a named KICKR, then a sole device', async () => { + test('prefers a saved device and otherwise reconnects only a named KICKR', async () => { const devices = [ { id: 'one', name: 'Other' }, { id: 'two', name: 'KICKR CORE' }, @@ -91,10 +96,32 @@ describe('Bluetooth data utilities', () => { await findRememberedKickr({ getDevices: async () => [devices[0]] } as Bluetooth, { getItem: () => null, }) - ).toBe(devices[0]); + ).toBeUndefined(); expect(await findRememberedKickr({} as Bluetooth, { getItem: () => null })).toBeUndefined(); }); + test('loads and selects all saved device types from one permitted-device catalog', async () => { + const permitted = [ + { id: 'trainer' }, + { id: 'heart-rate' }, + { id: 'click-minus' }, + { id: 'click-plus' }, + ] as BluetoothDevice[]; + let loads = 0; + const devices = await loadRememberedBluetoothDevices({ + getDevices: () => { + loads += 1; + return Promise.resolve(permitted); + }, + } as Bluetooth); + + expect(loads).toBe(1); + expect(rememberedBluetoothDevice(devices, 'heart-rate')).toBe(permitted[1]); + expect( + rememberedBluetoothDevices(devices, ['click-plus', 'missing', 'click-minus'], 2) + ).toEqual([permitted[3], permitted[2]]); + }); + test('connects GATT', async () => { const server = {} as BluetoothRemoteGATTServer; const device = { @@ -103,41 +130,40 @@ describe('Bluetooth data utilities', () => { expect(await connectGatt(device, false)).toBe(server); }); - test('rediscovers and retries a failed GATT connection', async () => { - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); - Object.defineProperty(globalThis, 'window', { - configurable: true, - value: { - clearTimeout, - setTimeout, + test('uses one bounded GATT probe per reconnect cycle', async () => { + let attempts = 0; + const device = { + gatt: { + connect: () => { + attempts += 1; + return Promise.reject(new Error('temporarily unavailable')); + }, + disconnect: () => undefined, }, - }); - const server = {} as BluetoothRemoteGATTServer; + } as unknown as BluetoothDevice; + + await expect(connectGatt(device, true)).rejects.toThrow('temporarily unavailable'); + expect(attempts).toBe(1); + }); + + test('times out stalled GATT reconnects so background retries can continue', async () => { let attempts = 0; const device = { - addEventListener: () => undefined, gatt: { connect: () => { attempts += 1; - if (attempts === 1) { - return Promise.reject(new Error('temporarily unavailable')); - } - return Promise.resolve(server); + return new Promise(() => undefined); }, + disconnect: () => undefined, }, - removeEventListener: () => undefined, - watchAdvertisements: () => Promise.reject(new Error('advertisement already fresh')), } as unknown as BluetoothDevice; - try { - expect(await connectGatt(device, true)).toBe(server); - expect(attempts).toBe(2); - } finally { - if (originalWindow) { - Object.defineProperty(globalThis, 'window', originalWindow); - } else { - Reflect.deleteProperty(globalThis, 'window'); - } - } + await expect( + connectGatt(device, true, { + directTimeoutMs: 1, + reconnectProbeTimeoutMs: 1, + }) + ).rejects.toThrow('Bluetooth device connection timed out.'); + expect(attempts).toBe(1); }); test('rejects devices without a GATT server', async () => { diff --git a/tests/components.test.tsx b/tests/components.test.tsx index aa2dbe0..55eb748 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -257,7 +257,7 @@ describe('view components', () => { /> ); expect(connectingButton).toContain('aria-busy="true"'); - expect(connectingButton).toContain('animate-pulse bg-sky-400'); + expect(connectingButton).toContain('connection-status-pulse bg-sky-300'); expect(connectingButton).not.toContain('bg-sky-400/10'); const connectedButton = render( undefined} pairedCount={3} /> @@ -328,13 +328,15 @@ describe('view components', () => { expect(panel).toContain('Smart trainer'); expect(panel).toContain('Heart rate'); expect(panel).toContain('Zwift Click V2'); - expect(panel).toContain('Reconnecting…'); + expect(panel).not.toContain('Reconnecting…'); expect(panel).not.toContain('Waiting for controllers…'); expect(panel).not.toContain('Retry'); - expect(panel).toContain('Connecting…'); + expect(panel).toContain('Connecting...'); expect(panel).not.toContain('>Reconnect'); - expect(panel.match(/animate-pulse/g)).toHaveLength(2); - expect(panel.match(/animate-pulse bg-sky-400/g)).toHaveLength(1); + expect(panel.match(/Connecting\.\.\.<\/span>/g)).toHaveLength(2); + expect(panel.match(/connecting-dot/g)).toHaveLength(6); + expect(panel.match(/connection-status-pulse/g)).toHaveLength(2); + expect(panel).toContain('shadow-[0_0_16px_rgba(56,189,248,.95)]'); expect(panel).toContain('Automatic reconnect in Chrome'); expect(panel).toContain('Chrome needs persistent Bluetooth permissions'); expect(panel).toContain('chrome://flags/#enable-web-bluetooth-new-permissions-backend'); @@ -344,7 +346,7 @@ describe('view components', () => { expect(panel).not.toContain('github.com/lookfirst/RideControl#automatic-reconnect'); expect(panel).toContain('+ Controller'); expect(panel.indexOf('+ Controller')).toBeLessThan(panel.indexOf('− Controller')); - expect(panel).toContain('animate-pulse'); + expect(panel).toContain('connection-status-pulse'); expect(panel).toContain('bg-mint/10'); expect(panel).not.toContain('shadow-[inset_0_0_18px'); expect(panel).not.toContain('divide-y'); @@ -532,11 +534,13 @@ describe('view components', () => { expect(lockedPanel.match(/disabled=""/g)).toHaveLength(6); const terrain = workoutTerrainAtDistance(course, course.distance * 2 + 2); + const shiftedWorkoutResistance = 42.4; const progress = render( @@ -559,7 +563,8 @@ describe('view components', () => { expect(progress).toContain('Grade'); expect(progress).toContain(formatGrade(terrain.grade)); expect(progress).toContain('Resistance'); - expect(progress).toContain(`${terrain.resistance}%`); + expect(progress).toContain(`${Math.round(shiftedWorkoutResistance)}%`); + expect(progress).not.toContain(`${terrain.resistance}%`); expect(progress.match(/sm:text-4xl/g)).toHaveLength(3); expect(progress.match(/sm:text-2xl/g)).toHaveLength(3); expect(progress).toContain('sm:text-lg'); @@ -640,7 +645,7 @@ describe('view components', () => { 'href="https://github.com/lookfirst/RideControl/pulls?q=is%3Apr+is%3Aclosed"' ); expect(html).toContain('