diff --git a/README.md b/README.md index e127833..42b8bde 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,15 @@ pnpm app This builds `openCycle.app` into `~/Applications`. Open it and allow Bluetooth once when macOS asks. It starts the server, opens the browser and puts an openCycle item in the menu bar with Open, Show logs and Quit. +The first time, a short setup walks you through it: Bluetooth, your rider (FTP and weight in kg or lb), then your trainer and strap. Each device shows live watts, cadence or heart rate before you move on. If macOS hasn't given openCycle Bluetooth yet, the app stays up and shows a banner with a link to the right System Settings page. + A few things worth knowing: - Quitting finishes any ride in progress first, so the FIT file is saved. - Logs are in `~/Library/Logs/openCycle/server.log`. If the server crashes, the app shows the last lines. - The app runs the code from this folder. After you pull changes, run `pnpm app` again. If you move the folder, rebuild. - Each rebuild re-signs the app, so macOS might ask for Bluetooth again. Say yes and it sticks. -- To run a one-off command with the app's Bluetooth permission, like the Garmin login: `open -a openCycle --args --exec "pnpm garmin:login --rider "`. +- To run a one-off command with the app's Bluetooth permission: `open -a openCycle --args --exec ""`. `pnpm app` options: `--sim` (simulator, no Bluetooth), `--out `, `--data-dir `, `--no-build`. @@ -139,13 +141,9 @@ ln -s "$(pwd)/skills/workout-author" ~/.claude/skills/workout-author **Uploading rides.** Finished rides upload to Garmin Connect on their own when the rider's profile has auto-upload on and a saved login exists. If an upload fails, History shows the reason and a retry button. Garmin changes their login flow without warning, and when they do, uploads fail until this part gets fixed. It's kept separate so nothing else breaks with it. -Log in once per rider: - -```bash -pnpm garmin:login --rider -``` +Log in once per rider: press **Connect Garmin** on the rider's profile (or on the last setup step). A browser opens. Sign in and do the MFA by hand. The first time, openCycle downloads the browser it uses for this, which takes a minute. The login is saved to `~/.opencycle/garmin/.json`, never the database, and profiles don't store passwords. Connecting again replaces it. -A browser opens. Sign in and do the MFA by hand. The login is saved to `~/.opencycle/garmin/.json`, never the database, and profiles don't store passwords. Running it again replaces it. +The same login also works from a terminal: `pnpm garmin:login --rider `. ## Credits diff --git a/apps/server/scripts/garmin-login.ts b/apps/server/scripts/garmin-login.ts index 67f07e4..1b3ea94 100644 --- a/apps/server/scripts/garmin-login.ts +++ b/apps/server/scripts/garmin-login.ts @@ -1,10 +1,8 @@ -import { mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { chromium } from 'playwright'; - -import { GARMIN } from '../src/garmin/connect.js'; +import { garminLoginStatePath } from '../src/garmin/connect.js'; +import { runGarminLogin } from '../src/garmin/login.js'; const USAGE = `Usage: pnpm --filter @opencycle/server exec tsx scripts/garmin-login.ts --rider @@ -40,27 +38,17 @@ function parseArgs(argv: string[]): CliArgs { async function main(): Promise { const { riderId } = parseArgs(process.argv.slice(2)); const dataDir = process.env.OPENCYCLE_DATA_DIR ?? join(homedir(), '.opencycle'); - const garminDir = join(dataDir, 'garmin'); - const statePath = join(garminDir, `${riderId}.json`); - // Persistent profile keeps the session cookies warm across logins. - const profileDir = join(garminDir, `${riderId}.profile`); - mkdirSync(garminDir, { recursive: true }); + const statePath = garminLoginStatePath(dataDir, riderId); console.log(`Signing in to Garmin Connect for rider "${riderId}".`); console.log('A browser window opens — complete the login and any MFA there.'); console.log('The script waits until the Garmin Connect dashboard loads, then saves login state to:'); console.log(` ${statePath}`); - const context = await chromium.launchPersistentContext(profileDir, { headless: false }); - try { - const page = context.pages()[0] ?? (await context.newPage()); - await page.goto(GARMIN.signInUrl, { waitUntil: 'domcontentloaded' }); - await page.waitForURL(GARMIN.dashboardUrlGlob, { timeout: 15 * 60 * 1000 }); - await context.storageState({ path: statePath }); - console.log(`Signed in. Login state saved to: ${statePath}`); - } finally { - await context.close(); - } + await runGarminLogin(dataDir, riderId, (state) => { + if (state === 'installing') console.log('Chromium is not installed. Installing it now.'); + }); + console.log(`Signed in. Login state saved to: ${statePath}`); } main().catch((err) => { diff --git a/apps/server/src/api/api.test.ts b/apps/server/src/api/api.test.ts index 710cde0..e106d6a 100644 --- a/apps/server/src/api/api.test.ts +++ b/apps/server/src/api/api.test.ts @@ -440,13 +440,22 @@ describe('WS /ws endpoint', () => { return messages; } - it('sends no messages when no session is running', async () => { + it('broadcasts live device samples when no session is running', async () => { const world = await buildWorld(); const ws = await world.app.injectWS('/ws'); try { const messages = collect(world, ws); - await sleep(200); - expect(messages).toEqual([]); + await waitFor( + () => messages.filter((message) => message.type === 'deviceSample').length >= 2, + 3000, + ); + const samples = messages.filter((message) => message.type === 'deviceSample'); + expect(samples).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'trainer', powerW: expect.any(Number), cadenceRpm: expect.any(Number) }), + expect.objectContaining({ kind: 'hrm', hrBpm: expect.any(Number) }), + ]), + ); } finally { ws.close(); await destroyWorld(world); diff --git a/apps/server/src/api/garminRoutes.test.ts b/apps/server/src/api/garminRoutes.test.ts index 43b064c..c741560 100644 --- a/apps/server/src/api/garminRoutes.test.ts +++ b/apps/server/src/api/garminRoutes.test.ts @@ -111,6 +111,8 @@ async function buildWorld(session: unknown = null): Promise { queue: queue as unknown as UploadQueue, connector, engine: fakeEngine(session), + hasLogin: () => false, + runLogin: () => Promise.resolve(), }); await app.ready(); const world: World = { app, db, queue, connector }; diff --git a/apps/server/src/api/garminRoutes.ts b/apps/server/src/api/garminRoutes.ts index 6eb740c..a847258 100644 --- a/apps/server/src/api/garminRoutes.ts +++ b/apps/server/src/api/garminRoutes.ts @@ -1,7 +1,9 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; +import type { GarminLoginStatus } from '@opencycle/shared'; import type { GarminConnector } from '../garmin/connect.js'; +import type { GarminLoginProgress } from '../garmin/login.js'; import { importGarminZip } from '../garmin/zipImport.js'; import type { SessionEngine } from '../session/engine.js'; import type { Db } from '../storage/db.js'; @@ -16,6 +18,8 @@ export interface GarminRouteDeps { queue: UploadQueue; connector: Pick; engine: SessionEngine; + hasLogin(riderId: string): boolean; + runLogin(riderId: string, onProgress: (state: GarminLoginProgress) => void): Promise; } /** How many days back the history pull scans on each request. */ @@ -27,22 +31,36 @@ function parseIdParam(params: unknown): string { return result.data.id; } +function parseRiderIdParam(params: unknown): string { + const result = RiderIdParamSchema.safeParse(params); + if (!result.success) throw new ApiError(400, 'Invalid rider id'); + return result.data.riderId; +} + +function requireProfile(db: Db, riderId: string): void { + if (db.prepare('SELECT id FROM profiles WHERE id = ?').get(riderId) === undefined) { + throw new ApiError(404, `Unknown profile ${riderId}`); + } +} + +interface StoredLoginStatus { + state: GarminLoginStatus['state']; + error?: string; +} + interface UploadStatusRow { uploadStatus: string | null; uploadError: string | null; } /** - * Garmin upload endpoints. POST enqueues a finalized ride's FIT for upload to - * Garmin Connect (async: 202 immediately, the queue does the work); GET - * reports the ride's upload_status/upload_error. POST /api/garmin/pull/:riderId - * starts a background history pull (30 days back, up to 1000 activities per - * pull — the activitylist-service cap) whose downloaded activity archives are - * imported under that rider; failures are logged and non-fatal — the 202 is - * the whole response. + * Garmin login, upload, and history endpoints. Login and history work run in + * the background after a 202 response. Login status stays in memory while the + * saved Playwright state stays on disk; upload status stays with each ride. */ export function registerGarminRoutes(app: FastifyInstance, deps: GarminRouteDeps): void { - const { db, queue, connector, engine } = deps; + const { db, queue, connector, engine, hasLogin, runLogin } = deps; + const loginStatuses = new Map(); app.post('/api/rides/:id/upload', async (req, reply) => { const id = parseIdParam(req.params); @@ -64,13 +82,49 @@ export function registerGarminRoutes(app: FastifyInstance, deps: GarminRouteDeps return row; }); - app.post('/api/garmin/pull/:riderId', async (req, reply) => { - const parsed = RiderIdParamSchema.safeParse(req.params); - if (!parsed.success) throw new ApiError(400, 'Invalid rider id'); - const { riderId } = parsed.data; - if (db.prepare('SELECT id FROM profiles WHERE id = ?').get(riderId) === undefined) { - throw new ApiError(404, `Unknown profile ${riderId}`); + app.get('/api/garmin/login/:riderId', async (req) => { + const riderId = parseRiderIdParam(req.params); + requireProfile(db, riderId); + const status = loginStatuses.get(riderId); + return { + hasLogin: hasLogin(riderId), + state: status?.state ?? 'idle', + ...(status?.error !== undefined ? { error: status.error } : {}), + }; + }); + + app.post('/api/garmin/login/:riderId', async (req, reply) => { + const riderId = parseRiderIdParam(req.params); + requireProfile(db, riderId); + const current = loginStatuses.get(riderId)?.state; + if (current === 'installing' || current === 'running') { + throw new ApiError(409, `Garmin login already in progress for ${riderId}`); + } + + let state: GarminLoginProgress = 'running'; + loginStatuses.set(riderId, { state }); + let pending: Promise; + try { + pending = runLogin(riderId, (nextState) => { + state = nextState; + loginStatuses.set(riderId, { state }); + }); + } catch (err) { + pending = Promise.reject(err); } + void pending.then( + () => loginStatuses.set(riderId, { state: 'idle' }), + (err: unknown) => { + const error = err instanceof Error ? err.message : String(err); + loginStatuses.set(riderId, { state: 'failed', error }); + }, + ); + reply.code(202).send({ state }); + }); + + app.post('/api/garmin/pull/:riderId', async (req, reply) => { + const riderId = parseRiderIdParam(req.params); + requireProfile(db, riderId); if (engine.session !== null) { throw new ApiError(409, 'history pull blocked while a session is active'); } diff --git a/apps/server/src/api/routes.ts b/apps/server/src/api/routes.ts index ef7eff0..156216b 100644 --- a/apps/server/src/api/routes.ts +++ b/apps/server/src/api/routes.ts @@ -214,6 +214,7 @@ function listProfiles(db: Db): RiderProfile[] { function registerDeviceRoutes(app: FastifyInstance, ctx: ApiContext): void { app.get('/api/devices', async () => ctx.registry.list()); + app.get('/api/ble', async () => ({ state: ctx.hub.adapterState() })); app.post('/api/devices/:id/assign', async (req, reply) => { const { id } = parseOr400(IdParamSchema, req.params); diff --git a/apps/server/src/api/ws.ts b/apps/server/src/api/ws.ts index 962172b..0826baf 100644 --- a/apps/server/src/api/ws.ts +++ b/apps/server/src/api/ws.ts @@ -2,6 +2,8 @@ import type { FastifyInstance } from 'fastify'; import type { WebSocket } from '@fastify/websocket'; import { WsClientMessageSchema, + type AdapterState, + type DeviceSample, type SessionEvent, type SessionSnapshot, type TelemetrySample, @@ -9,7 +11,13 @@ import { type WsServerMessage, } from '@opencycle/shared'; -import { HrmDriver, TrainerDriver, type TrainerStatus } from '../devices/driver.js'; +import { + HrmDriver, + TrainerDriver, + type HrmSample, + type TrainerSample, + type TrainerStatus, +} from '../devices/driver.js'; import { sessionDestination } from '../game/voyage.js'; import type { ApiContext } from './routes.js'; import { resolveRiderConfigs } from './routes.js'; @@ -78,8 +86,8 @@ export function registerWs(app: FastifyInstance, ctx: ApiContext): void { for (const socket of sockets) send(socket, message); }; - // deviceStatus: mirror every driver's status event to all sockets, for - // already-known drivers and for devices discovered later. + // Mirror every driver's status and raw samples, for already-known drivers + // and for devices discovered later. const statusFrame = (deviceId: string, status: TrainerStatus): WsServerMessage => ({ type: 'deviceStatus', deviceId, @@ -88,23 +96,67 @@ export function registerWs(app: FastifyInstance, ctx: ApiContext): void { ...(status.message ? { message: status.message } : {}), }); const statusSubs: Array<{ driver: TrainerDriver | HrmDriver; handler: (status: TrainerStatus) => void }> = []; + const trainerSampleSubs: Array<{ driver: TrainerDriver; handler: (sample: TrainerSample) => void }> = []; + const hrmSampleSubs: Array<{ driver: HrmDriver; handler: (sample: HrmSample) => void }> = []; + const lastSampleBroadcast = new Map(); + const broadcastSample = (sample: DeviceSample): void => { + const now = Date.now(); + const previous = lastSampleBroadcast.get(sample.deviceId); + if (previous !== undefined && now - previous < 1000) return; + lastSampleBroadcast.set(sample.deviceId, now); + broadcast(sample); + }; const subscribeStatus = (driver: TrainerDriver | HrmDriver): void => { const handler = (status: TrainerStatus): void => broadcast(statusFrame(driver.id, status)); driver.on('status', handler); statusSubs.push({ driver, handler }); }; - const onTrainerDiscovered = (trainer: TrainerDriver): void => subscribeStatus(trainer); - const onHrmDiscovered = (hrm: HrmDriver): void => subscribeStatus(hrm); - for (const trainer of ctx.hub.drivers().trainers) subscribeStatus(trainer); - for (const hrm of ctx.hub.drivers().hrms) subscribeStatus(hrm); + const subscribeTrainer = (driver: TrainerDriver): void => { + subscribeStatus(driver); + const handler = (sample: TrainerSample): void => { + if (sockets.size === 0) return; + broadcastSample({ + type: 'deviceSample', + deviceId: driver.id, + kind: 'trainer', + ts: sample.ts, + powerW: sample.powerW, + cadenceRpm: sample.cadenceRpm, + }); + }; + driver.on('sample', handler); + trainerSampleSubs.push({ driver, handler }); + }; + const subscribeHrm = (driver: HrmDriver): void => { + subscribeStatus(driver); + const handler = (sample: HrmSample): void => { + if (sockets.size === 0) return; + broadcastSample({ + type: 'deviceSample', + deviceId: driver.id, + kind: 'hrm', + ts: sample.ts, + hrBpm: sample.bpm, + }); + }; + driver.on('hr', handler); + hrmSampleSubs.push({ driver, handler }); + }; + const onTrainerDiscovered = (trainer: TrainerDriver): void => subscribeTrainer(trainer); + const onHrmDiscovered = (hrm: HrmDriver): void => subscribeHrm(hrm); + const onAdapterState = (state: AdapterState): void => broadcast({ type: 'adapterState', state }); + for (const trainer of ctx.hub.drivers().trainers) subscribeTrainer(trainer); + for (const hrm of ctx.hub.drivers().hrms) subscribeHrm(hrm); ctx.hub.on('trainer', onTrainerDiscovered); ctx.hub.on('hrm', onHrmDiscovered); + ctx.hub.on('adapter', onAdapterState); app.get('/ws', { websocket: true }, (socket) => { sockets.add(socket); // Always send an initial sessionState (null included): clients use this // frame as the hydration signal before making routing decisions. send(socket, { type: 'sessionState', session: ctx.engine.session }); + send(socket, { type: 'adapterState', state: ctx.hub.adapterState() }); // Status is event-only: replay each device's last one so late pages are current. for (const [deviceId, status] of ctx.hub.lastStatuses()) send(socket, statusFrame(deviceId, status)); @@ -152,6 +204,9 @@ export function registerWs(app: FastifyInstance, ctx: ApiContext): void { app.addHook('onClose', () => { ctx.hub.off('trainer', onTrainerDiscovered); ctx.hub.off('hrm', onHrmDiscovered); + ctx.hub.off('adapter', onAdapterState); for (const { driver, handler } of statusSubs) driver.off('status', handler); + for (const { driver, handler } of trainerSampleSubs) driver.off('sample', handler); + for (const { driver, handler } of hrmSampleSubs) driver.off('hr', handler); }); } diff --git a/apps/server/src/devices/ble/manager.test.ts b/apps/server/src/devices/ble/manager.test.ts index ef19be1..714a6af 100644 --- a/apps/server/src/devices/ble/manager.test.ts +++ b/apps/server/src/devices/ble/manager.test.ts @@ -122,8 +122,10 @@ class FakeNoble extends EventEmitter { state = 'poweredOn'; scanning: string[] | null = null; allowDuplicates: boolean | null = null; + scanStarts = 0; async startScanningAsync(serviceUUIDs: string[], allowDuplicates: boolean): Promise { + this.scanStarts += 1; this.scanning = serviceUUIDs; this.allowDuplicates = allowDuplicates; } @@ -568,17 +570,26 @@ describe('BleManager', () => { expect(states).toEqual(['scanning', 'idle']); }); - it('waits for the adapter to power on before scanning', async () => { + it('reports an unauthorized adapter and scans once when it later powers on', async () => { const noble = new FakeNoble(); - noble.state = 'poweredOff'; + noble.state = 'unauthorized'; const manager = new BleManager({ noble: noble as unknown as Noble }); - const scanPromise = manager.startScan(); - await tick(); - expect(noble.scanning).toBeNull(); + const adapters: string[] = []; + manager.on('adapter', (state) => adapters.push(state)); + + await expect(manager.startScan()).resolves.toBeUndefined(); + expect(manager.adapterState()).toBe('unauthorized'); + expect(noble.scanStarts).toBe(0); + noble.state = 'poweredOn'; noble.emit('stateChange', 'poweredOn'); - await scanPromise; + await tick(); + noble.emit('stateChange', 'poweredOn'); + await tick(); + + expect(adapters).toEqual(['poweredOn', 'poweredOn']); expect(noble.scanning).toEqual(['1826', '180d']); + expect(noble.scanStarts).toBe(1); }); it('classifies devices and emits each driver once per peripheral id', async () => { diff --git a/apps/server/src/devices/ble/manager.ts b/apps/server/src/devices/ble/manager.ts index 0fd0932..3954e47 100644 --- a/apps/server/src/devices/ble/manager.ts +++ b/apps/server/src/devices/ble/manager.ts @@ -3,80 +3,108 @@ // (CoreBluetooth); a fake noble can be injected for tests. import { EventEmitter } from 'node:events'; -import noble, { type Noble, type Peripheral } from '@stoprocent/noble'; +import noble, { type AdapterState as NobleAdapterState, type Noble, type Peripheral } from '@stoprocent/noble'; import { HrmDriver, TrainerDriver } from '../driver.js'; import { FTMS_SERVICE_UUID } from './ftms.js'; import { HRS_SERVICE_UUID } from './hrs.js'; import { BleHrmDriver, BleTrainerDriver, isUuid } from './trainer.js'; const SCAN_SERVICE_UUIDS = [FTMS_SERVICE_UUID, HRS_SERVICE_UUID]; -const POWER_ON_TIMEOUT_MS = 30_000; export interface BleManagerEvents { trainer: [TrainerDriver]; hrm: [HrmDriver]; + adapter: [NobleAdapterState]; state: [string]; } export class BleManager extends EventEmitter { private readonly noble: Noble; private readonly drivers = new Map(); + private adapter: NobleAdapterState; private scanning = false; + private scanRequested = false; + private scanStart: Promise | undefined; + private restartAfterStart = false; constructor(opts?: { noble?: Noble }) { super(); this.noble = opts?.noble ?? noble; + this.adapter = this.noble.state; this.noble.on('discover', this.handleDiscover); + this.noble.on('stateChange', this.handleStateChange); } - /** Release the noble listener and stop scanning; idempotent. */ + adapterState(): NobleAdapterState { + return this.adapter; + } + + /** Release the noble listeners and stop scanning; idempotent. */ async dispose(): Promise { this.noble.off('discover', this.handleDiscover); - if (this.scanning) { - this.scanning = false; - this.emit('state', 'idle'); - } + this.noble.off('stateChange', this.handleStateChange); try { - await this.noble.stopScanningAsync(); + await this.stopScan(); } catch { // Best effort: a stopScan failure must not block shutdown. } } - /** Scan for FTMS trainers and HRM straps (no duplicate advertisements). */ + /** Request FTMS + HRS scanning now or when the adapter becomes available. */ async startScan(): Promise { - if (this.scanning) return; - await this.waitForPoweredOn(); - await this.noble.startScanningAsync(SCAN_SERVICE_UUIDS, false); - this.scanning = true; - this.emit('state', 'scanning'); + this.scanRequested = true; + if (this.adapter !== 'poweredOn') return; + await this.ensureScanning(); } async stopScan(): Promise { + this.scanRequested = false; + if (this.scanStart !== undefined) await this.scanStart.catch(() => {}); if (!this.scanning) return; await this.noble.stopScanningAsync(); this.scanning = false; this.emit('state', 'idle'); } - private async waitForPoweredOn(): Promise { - if (this.noble.state === 'poweredOn') return; - await new Promise((resolve, reject) => { - let timer: ReturnType; - const onState = (state: string): void => { - if (state !== 'poweredOn') return; - clearTimeout(timer); - this.noble.off('stateChange', onState); - resolve(); - }; - timer = setTimeout(() => { - this.noble.off('stateChange', onState); - reject(new Error('BLE adapter did not power on within 30 s')); - }, POWER_ON_TIMEOUT_MS); - this.noble.on('stateChange', onState); + private ensureScanning(): Promise { + if (this.scanning) return Promise.resolve(); + if (this.scanStart !== undefined) return this.scanStart; + const pending: Promise = this.startScanning().finally(() => { + if (this.scanStart === pending) this.scanStart = undefined; + const restart = this.restartAfterStart; + this.restartAfterStart = false; + if (restart && this.scanRequested && this.adapter === 'poweredOn') { + void this.ensureScanning().catch(() => {}); + } }); + this.scanStart = pending; + return pending; + } + + private async startScanning(): Promise { + await this.noble.startScanningAsync(SCAN_SERVICE_UUIDS, false); + if (this.restartAfterStart || !this.scanRequested || this.adapter !== 'poweredOn') { + await this.noble.stopScanningAsync().catch(() => {}); + return; + } + this.scanning = true; + this.emit('state', 'scanning'); } + private readonly handleStateChange = (state: NobleAdapterState): void => { + this.adapter = state; + this.emit('adapter', state); + if (state === 'poweredOn') { + if (this.scanRequested) void this.ensureScanning().catch(() => {}); + return; + } + if (this.scanStart !== undefined) this.restartAfterStart = true; + if (this.scanning) { + this.scanning = false; + this.emit('state', 'idle'); + } + }; + /** Emit at most one driver instance per discovered peripheral id. */ private readonly handleDiscover = (peripheral: Peripheral): void => { if (this.drivers.has(peripheral.id)) return; diff --git a/apps/server/src/devices/hub.ts b/apps/server/src/devices/hub.ts index 8f4ed04..01a42e9 100644 --- a/apps/server/src/devices/hub.ts +++ b/apps/server/src/devices/hub.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events'; +import type { AdapterState } from '@opencycle/shared'; import { BleManager } from './ble/manager.js'; import { createSimDevices } from './sim/index.js'; import type { DeviceRegistry } from './registry.js'; @@ -8,13 +9,19 @@ import type { HrmDriver, TrainerDriver, TrainerStatus } from './driver.js'; * Facade over all device sources (sim + BLE). Emits a driver instance once per * discovered device and persists every discovery to the registry. */ -export class DeviceHub extends EventEmitter<{ trainer: [TrainerDriver]; hrm: [HrmDriver] }> { +export class DeviceHub extends EventEmitter<{ + trainer: [TrainerDriver]; + hrm: [HrmDriver]; + adapter: [AdapterState]; + bleError: [unknown]; +}> { private readonly registry: DeviceRegistry; private readonly sim: string | undefined; private readonly ble: boolean; private readonly trainerDrivers = new Set(); private readonly hrmDrivers = new Set(); private bleManager: BleManager | undefined; + private adapter: AdapterState; // Status is event-only and drivers emit before later listeners attach, so the // hub records it from registration on for late subscribers (new sockets). private readonly statuses = new Map(); @@ -24,6 +31,7 @@ export class DeviceHub extends EventEmitter<{ trainer: [TrainerDriver]; hrm: [Hr this.registry = opts.registry; this.sim = opts.sim; this.ble = opts.ble !== false; + this.adapter = this.ble ? 'unknown' : 'disabled'; } async start(): Promise { @@ -33,10 +41,20 @@ export class DeviceHub extends EventEmitter<{ trainer: [TrainerDriver]; hrm: [Hr for (const hrm of sim.hrms) this.registerHrm(hrm); } if (this.ble) { - this.bleManager = new BleManager(); - this.bleManager.on('trainer', (trainer) => this.registerTrainer(trainer)); - this.bleManager.on('hrm', (hrm) => this.registerHrm(hrm)); - await this.bleManager.startScan(); + try { + const manager = new BleManager(); + this.bleManager = manager; + manager.on('trainer', (trainer) => this.registerTrainer(trainer)); + manager.on('hrm', (hrm) => this.registerHrm(hrm)); + manager.on('adapter', (state) => { + this.adapter = state; + this.emit('adapter', state); + }); + this.adapter = manager.adapterState(); + void manager.startScan().catch((err: unknown) => this.emit('bleError', err)); + } catch { + // BLE availability must not prevent the HTTP server or sim devices. + } } } @@ -68,6 +86,10 @@ export class DeviceHub extends EventEmitter<{ trainer: [TrainerDriver]; hrm: [Hr return this.statuses; } + adapterState(): AdapterState { + return this.adapter; + } + find(id: string): TrainerDriver | HrmDriver | undefined { for (const trainer of this.trainerDrivers) { if (trainer.id === id) return trainer; diff --git a/apps/server/src/garmin/connect.ts b/apps/server/src/garmin/connect.ts index 736aa43..3010d92 100644 --- a/apps/server/src/garmin/connect.ts +++ b/apps/server/src/garmin/connect.ts @@ -29,6 +29,10 @@ export const GARMIN = { `https://connect.garmin.com/modern/proxy/download-service/files/activity/${activityId}`, } as const; +export function garminLoginStatePath(dataDir: string, riderId: string): string { + return join(dataDir, 'garmin', `${riderId}.json`); +} + export interface LoggerLike { info(message: string, ...args: unknown[]): void; warn(message: string, ...args: unknown[]): void; @@ -59,7 +63,7 @@ export class GarminConnector { ) {} loginStatePath(riderId: string): string { - return join(this.dataDir, 'garmin', `${riderId}.json`); + return garminLoginStatePath(this.dataDir, riderId); } hasLogin(riderId: string): boolean { diff --git a/apps/server/src/garmin/login.ts b/apps/server/src/garmin/login.ts new file mode 100644 index 0000000..c6f62d2 --- /dev/null +++ b/apps/server/src/garmin/login.ts @@ -0,0 +1,52 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { existsSync, mkdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +import { chromium } from 'playwright'; + +import { GARMIN, garminLoginStatePath } from './connect.js'; + +export type GarminLoginProgress = 'installing' | 'running'; + +const require = createRequire(import.meta.url); +const PLAYWRIGHT_CLI = join(dirname(require.resolve('playwright/package.json')), 'cli.js'); + +async function installChromium(): Promise { + const child = spawn(process.execPath, [PLAYWRIGHT_CLI, 'install', 'chromium'], { stdio: 'inherit' }); + const [code, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null]; + if (code === 0) return; + const outcome = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}`; + throw new Error(`Playwright Chromium install failed (${outcome})`); +} + +export async function runGarminLogin( + dataDir: string, + riderId: string, + onProgress: (state: GarminLoginProgress) => void, +): Promise { + const garminDir = join(dataDir, 'garmin'); + const statePath = garminLoginStatePath(dataDir, riderId); + const profileDir = join(garminDir, `${riderId}.profile`); + mkdirSync(garminDir, { recursive: true }); + + if (!existsSync(chromium.executablePath())) { + onProgress('installing'); + await installChromium(); + if (!existsSync(chromium.executablePath())) { + throw new Error('Playwright Chromium is still missing after installation'); + } + } + + onProgress('running'); + const context = await chromium.launchPersistentContext(profileDir, { headless: false }); + try { + const page = context.pages()[0] ?? (await context.newPage()); + await page.goto(GARMIN.signInUrl, { waitUntil: 'domcontentloaded' }); + await page.waitForURL(GARMIN.dashboardUrlGlob, { timeout: 15 * 60 * 1000 }); + await context.storageState({ path: statePath }); + } finally { + await context.close(); + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 46c83b8..7e27366 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -16,6 +16,7 @@ import { registerWs } from './api/ws.js'; import { attachDiscoveries } from './game/discoveries.js'; import { attachVoyage } from './game/voyage.js'; import { GarminConnector } from './garmin/connect.js'; +import { runGarminLogin } from './garmin/login.js'; import { watchImportFolder } from './garmin/dropFolder.js'; import { attachAutoUpload, UploadQueue } from './garmin/uploadQueue.js'; import { importGarminZip } from './garmin/zipImport.js'; @@ -43,6 +44,7 @@ const ble = process.env.OPENCYCLE_BLE === '1'; const hub = new DeviceHub({ registry, sim, ble }); app.decorate('deviceHub', hub); +hub.on('bleError', (err) => app.log.warn({ err }, 'bluetooth scan failed to start')); hub.on('trainer', (trainer) => { app.log.info({ id: trainer.id, name: trainer.name }, 'trainer discovered'); // The session engine never connects drivers; the app owns device @@ -73,7 +75,14 @@ const garmin = new GarminConnector(dataDir, app.log); const uploadQueue = new UploadQueue(db, garmin, (message) => app.log.info({ message }, 'garmin queue')); attachAutoUpload(engine, db, uploadQueue); registerActivitiesRoutes(app, { db, engine, importZip: (buf, riderId) => importGarminZip(db, buf, riderId) }); -registerGarminRoutes(app, { db, queue: uploadQueue, connector: garmin, engine }); +registerGarminRoutes(app, { + db, + queue: uploadQueue, + connector: garmin, + engine, + hasLogin: (riderId) => garmin.hasLogin(riderId), + runLogin: (riderId, onProgress) => runGarminLogin(dataDir, riderId, onProgress), +}); registerTrainingRoutes(app, { db }); registerPlansRoutes(app, { db, workoutLibrary }); const stopImportWatcher = watchImportFolder(db, dataDir, (message) => app.log.info({ message }, 'import folder')); @@ -132,8 +141,8 @@ process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); try { - // Start the device hub first so a bad OPENCYCLE_SIM spec or a BLE - // power-on timeout fails fast instead of leaving a half-up server window. + // Invalid sim specs still fail fast. BLE availability is non-fatal inside + // the hub, so denied permission cannot prevent the server from listening. await hub.start(); await app.listen({ host, port }); } catch (err) { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 87dcadd..bcb05b4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,8 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { BrowserRouter, NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'; +import BluetoothBanner from './components/BluetoothBanner.js'; +import { listProfiles } from './lib/api.js'; import { connectWs } from './lib/ws.js'; import DevicesPage from './pages/DevicesPage.js'; import { HistoryPage } from './pages/HistoryPage.js'; @@ -9,6 +11,7 @@ import { RideDetailPage } from './pages/RideDetailPage.js'; import RidePage from './pages/RidePage.js'; import SessionBuilderPage from './pages/SessionBuilderPage.js'; import TrainingPage from './pages/TrainingPage.js'; +import WelcomePage from './pages/WelcomePage.js'; import VoyagePage from './pages/VoyagePage.js'; import { useAppStore, type AppState } from './store.js'; @@ -63,27 +66,45 @@ function HomeRoute() { const session = useAppStore((s) => s.session); const wsStatus = useAppStore((s) => s.wsStatus); const hydrated = useAppStore((s) => s.hydrated); + const [profileState, setProfileState] = useState<'loading' | 'empty' | 'ready'>('loading'); + + useEffect(() => { + if (session !== null || wsStatus !== 'open' || !hydrated) return; + let cancelled = false; + setProfileState('loading'); + void listProfiles() + .then((profiles) => { + if (!cancelled) setProfileState(profiles.length === 0 ? 'empty' : 'ready'); + }) + .catch(() => { + if (!cancelled) setProfileState('ready'); + }); + return () => { + cancelled = true; + }; + }, [hydrated, session, wsStatus]); if (session !== null) return ; - // No session AND (socket down OR the initial sessionState frame has not - // arrived yet): hold the dashboard's place instead of bouncing to - // /session/new — the session may still be running server-side and the - // server always sends a sessionState frame (null included) on connect. - if (wsStatus !== 'open' || !hydrated) { + // The server always replays sessionState on connect. Wait for it before + // deciding whether the rider belongs in onboarding or the session builder. + if (wsStatus !== 'open' || !hydrated || profileState === 'loading') { return (
-

Reconnecting…

-

Your session stays on screen once the server is back.

+

+ {wsStatus === 'open' && hydrated ? 'Loading…' : 'Reconnecting…'} +

+ {wsStatus !== 'open' && ( +

Your session stays on screen once the server is back.

+ )}
); } - // Socket open and the server confirms no session: the builder is the home. - return ; + return ; } export default function App() { @@ -95,9 +116,11 @@ export default function App() {
+
} /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/BluetoothBanner.tsx b/apps/web/src/components/BluetoothBanner.tsx new file mode 100644 index 0000000..93ff3d4 --- /dev/null +++ b/apps/web/src/components/BluetoothBanner.tsx @@ -0,0 +1,47 @@ +import { useLocation } from 'react-router-dom'; + +import type { AdapterState } from '@opencycle/shared'; + +import { useAppStore } from '../store.js'; + +const BLUETOOTH_SETTINGS_URL = + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth'; + +type BluetoothProblem = Extract; + +export function BluetoothFix({ state }: { state: BluetoothProblem }) { + return ( + <> + + {state === 'unauthorized' + ? 'macOS needs to let openCycle use Bluetooth. In System Settings, go to Privacy & Security > Bluetooth, then turn on openCycle.' + : 'Turn Bluetooth on to connect your trainer and heart rate strap.'} + {' '} + + Open Bluetooth settings + + + ); +} + +export default function BluetoothBanner() { + const adapterState = useAppStore((state) => state.adapterState); + const session = useAppStore((state) => state.session); + const location = useLocation(); + const onRidePage = location.pathname === '/' && session !== null; + + if (onRidePage || (adapterState !== 'unauthorized' && adapterState !== 'poweredOff')) { + return null; + } + + return ( + + ); +} diff --git a/apps/web/src/components/GarminConnect.tsx b/apps/web/src/components/GarminConnect.tsx new file mode 100644 index 0000000..cf7da18 --- /dev/null +++ b/apps/web/src/components/GarminConnect.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; + +import type { GarminLoginStatus } from '@opencycle/shared'; + +import { getGarminLogin, startGarminLogin } from '../lib/api.js'; + +interface GarminConnectProps { + riderId: string; +} + +export default function GarminConnect({ riderId }: GarminConnectProps) { + const [status, setStatus] = useState(null); + const [checking, setChecking] = useState(true); + const [starting, setStarting] = useState(false); + const [requestError, setRequestError] = useState(null); + const polling = status?.state === 'installing' || status?.state === 'running'; + + useEffect(() => { + let cancelled = false; + + async function refresh() { + try { + const next = await getGarminLogin(riderId); + if (!cancelled) { + setStatus(next); + setRequestError(null); + } + } catch (error) { + if (!cancelled) setRequestError(error instanceof Error ? error.message : String(error)); + } finally { + if (!cancelled) setChecking(false); + } + } + + void refresh(); + if (!polling) { + return () => { + cancelled = true; + }; + } + + const timer = window.setInterval(() => void refresh(), 2000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [polling, riderId]); + + async function beginLogin() { + setStarting(true); + setRequestError(null); + try { + const result = await startGarminLogin(riderId); + setStatus({ hasLogin: false, state: result.state }); + } catch (error) { + const conflict = + typeof error === 'object' && error !== null && 'status' in error && error.status === 409; + if (conflict) { + try { + setStatus(await getGarminLogin(riderId)); + } catch (refreshError) { + setRequestError(refreshError instanceof Error ? refreshError.message : String(refreshError)); + } + } else { + setRequestError(error instanceof Error ? error.message : String(error)); + } + } finally { + setStarting(false); + setChecking(false); + } + } + + if (polling) { + return ( +

+

+ ); + } + + // Failure wins over a saved login: a failed re-login must stay visible. + const error = status?.state === 'failed' ? (status.error ?? 'Garmin login failed.') : requestError; + if (error !== null) { + return ( +
+

+ {error} +

+ +
+ ); + } + + if (status?.hasLogin === true) { + // Garmin sessions expire; Reconnect replaces the saved login. + return ( +

+

+ ); + } + + if (checking) return

Checking Garmin…

; + + return ( + + ); +} diff --git a/apps/web/src/components/ProfileForm.tsx b/apps/web/src/components/ProfileForm.tsx index d1c7516..3111714 100644 --- a/apps/web/src/components/ProfileForm.tsx +++ b/apps/web/src/components/ProfileForm.tsx @@ -28,6 +28,10 @@ interface ProfileFormProps { /** Called with the saved profile after the mutation succeeds. */ onSaved: (profile: RiderProfile) => void; onCancel?: () => void; + /** Limit the form to the fields needed during first-run setup. */ + riderOnly?: boolean; + submitLabel?: string; + cancelLabel?: string; } interface FormValues { @@ -87,7 +91,15 @@ function validate(values: FormValues): ValidationResult { return { ok: false, error: `${FIELD_LABELS[path] ?? (path === '' ? 'Profile' : path)}: ${issue.message}` }; } -export default function ProfileForm({ initial, devices, onSaved, onCancel }: ProfileFormProps) { +export default function ProfileForm({ + initial, + devices, + onSaved, + onCancel, + riderOnly = false, + submitLabel, + cancelLabel = 'Cancel', +}: ProfileFormProps) { const [values, setValues] = useState(() => initialValues(initial, devices)); const [createdProfile, setCreatedProfile] = useState(null); const [error, setError] = useState(null); @@ -182,6 +194,9 @@ export default function ProfileForm({ initial, devices, onSaved, onCancel }: Pro onChange={(e) => setField('ftpW', e.target.value)} placeholder="250" /> +

+ Not sure? Start at 2.5 W per kg; you can change it later. +

Weight @@ -208,76 +223,80 @@ export default function ProfileForm({ initial, devices, onSaved, onCancel }: Pro
- - - - - + {!riderOnly && ( + <> + + + + + + + )}
{error !== null &&

{error}

} @@ -288,7 +307,9 @@ export default function ProfileForm({ initial, devices, onSaved, onCancel }: Pro disabled={submitting} className="rounded-[8px] bg-on px-4 py-2 font-medium text-void hover:bg-on/85 disabled:opacity-50" > - {submitting ? 'Saving…' : initial === undefined ? 'Create profile' : 'Save changes'} + {submitting + ? 'Saving…' + : (submitLabel ?? (initial === undefined ? 'Create profile' : 'Save changes'))} {onCancel !== undefined && ( )} diff --git a/apps/web/src/game/director.test.ts b/apps/web/src/game/director.test.ts index b3693ac..f128b7e 100644 --- a/apps/web/src/game/director.test.ts +++ b/apps/web/src/game/director.test.ts @@ -18,6 +18,8 @@ function makeState(over: Partial = {}): AppState { events: [], deviceStatus: {}, deviceStatusMessage: {}, + adapterState: 'unknown', + deviceSamples: {}, lastError: null, hydrated: false, spaceGame: true, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 749655c..1ced1d0 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,5 +1,8 @@ +import { AdapterStateSchema, GarminLoginStatusSchema } from '@opencycle/shared'; import type { + AdapterState, DeviceInfo, + GarminLoginStatus, RiderProfile, RiderStart, SessionSnapshot, @@ -61,6 +64,11 @@ async function request(path: string, init?: RequestInit): Promise { return (await res.json()) as T; } +export async function getBleState(): Promise<{ state: AdapterState }> { + const body = await request<{ state?: unknown }>('/api/ble'); + return { state: AdapterStateSchema.parse(body.state) }; +} + export function listProfiles(): Promise { return request('/api/profiles'); } @@ -169,6 +177,24 @@ export function listActivities(riderId?: string): Promise { return request(`/api/activities${query}`); } +export async function startGarminLogin( + riderId: string, +): Promise<{ state: 'installing' | 'running' }> { + const body = await request<{ state?: unknown }>( + `/api/garmin/login/${encodeURIComponent(riderId)}`, + { method: 'POST' }, + ); + if (body.state !== 'installing' && body.state !== 'running') { + throw new Error('Invalid Garmin login response'); + } + return { state: body.state }; +} + +export async function getGarminLogin(riderId: string): Promise { + const body = await request(`/api/garmin/login/${encodeURIComponent(riderId)}`); + return GarminLoginStatusSchema.parse(body); +} + /** Result of a Garmin ZIP/FIT import as served by POST /api/garmin/import-zip. */ export interface ImportResult { imported: number; diff --git a/apps/web/src/pages/ProfilesPage.tsx b/apps/web/src/pages/ProfilesPage.tsx index 5a8ffe9..e93788c 100644 --- a/apps/web/src/pages/ProfilesPage.tsx +++ b/apps/web/src/pages/ProfilesPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { RiderProfile } from '@opencycle/shared'; +import GarminConnect from '../components/GarminConnect.js'; import ProfileForm from '../components/ProfileForm.js'; import { deleteProfile, @@ -238,7 +239,8 @@ export default function ProfilesPage() { )} -
+
+ {estimates[p.id] === undefined ? ( +
+ + ); +} + +function RiderStep({ + profile, + onSaved, + onBack, +}: { + profile: RiderProfile | null; + onSaved: (profile: RiderProfile) => void; + onBack: () => void; +}) { + return ( +
+

+ Set up your rider +

+

These details set your training targets.

+ +
+ ); +} + +function DeviceStatusBadge({ status }: { status: string | undefined }) { + const connected = status === 'connected' || status === 'controlAcquired'; + const failed = status === 'error' || status === 'disconnected' || status === 'controlLost'; + const className = connected + ? 'border-on/40 bg-on/10 text-on' + : failed + ? 'border-danger/40 bg-danger/10 text-danger' + : status === 'connecting' + ? 'border-under/40 bg-under/10 text-under' + : 'border-line text-dim'; + const label = connected + ? 'Connected' + : failed + ? status === 'error' + ? 'Error' + : 'Disconnected' + : status === 'connecting' + ? 'Connecting' + : 'Found'; + return {label}; +} + +function DeviceChoices({ + devices, + selectedId, + busyId, + onSelect, +}: { + devices: ListedDevice[]; + selectedId: string | null; + busyId: string | null; + onSelect: (device: ListedDevice) => void; +}) { + const deviceStatus = useAppStore((state) => state.deviceStatus); + const deviceStatusMessage = useAppStore((state) => state.deviceStatusMessage); + + if (devices.length === 0) { + return ( +

+ Scanning for devices… +

+ ); + } + + return ( +
+ {devices.map((device) => ( + + ))} +
+ ); +} + +function RenameDevice({ label, onLabel }: { label: string; onLabel: (label: string) => void }) { + return ( +
+ + onLabel(event.target.value)} + /> +
+ ); +} + +function LiveReading({ kind, sample }: { kind: DeviceKind; sample: DeviceSample | undefined }) { + if (kind === 'trainer') { + const cadence = sample?.cadenceRpm ?? null; + return ( +
+
+

Live power

+

+ {sample?.powerW === undefined ? '—' : Math.round(sample.powerW)} + W +

+ {cadence !== null && cadence > 0 && ( +

Got it — {Math.round(cadence)} rpm

+ )} +
+ +
+ ); + } + + const heartRate = sample?.hrBpm; + return ( +
+

Live heart rate

+

+ {heartRate === undefined ? '—' : Math.round(heartRate)} + bpm +

+ {heartRate !== undefined && heartRate > 0 && ( +

Signal received — {Math.round(heartRate)} bpm

+ )} +
+ ); +} + +function DeviceStepActions({ + ready, + skippable, + onBack, + onNext, + onSkip, +}: { + ready: boolean; + skippable: boolean; + onBack: () => void; + onNext: () => void; + onSkip?: () => void; +}) { + return ( +
+ +
+ {skippable && !ready && ( + + )} + +
+
+ ); +} + +function DeviceSetupStep({ + kind, + profile, + devices, + selectedId, + discoveryError, + onSelected, + onBack, + onNext, + onSkip, +}: DeviceStepProps) { + const [busyId, setBusyId] = useState(null); + const deviceNoun = kind === 'trainer' ? 'trainer' : 'heart rate strap'; + // Coming Back to a step must keep the name already saved on the device. + const [label, setLabel] = useState( + () => devices.find((device) => device.id === selectedId)?.name ?? `${profile.name}'s ${deviceNoun}`, + ); + const [selectedAt, setSelectedAt] = useState(() => (selectedId === null ? 0 : Date.now())); + const [error, setError] = useState(null); + const sample = useAppStore((state) => + selectedId === null ? undefined : state.deviceSamples[selectedId], + ); + const liveSample = sample !== undefined && sample.ts >= selectedAt ? sample : undefined; + const status = useAppStore((state) => + selectedId === null ? undefined : state.deviceStatus[selectedId], + ); + const message = useAppStore((state) => + selectedId === null ? undefined : state.deviceStatusMessage[selectedId], + ); + const ready = + kind === 'trainer' ? (liveSample?.cadenceRpm ?? 0) > 0 : (liveSample?.hrBpm ?? 0) > 0; + const pairingHint = + kind === 'hrm' && + (status === 'error' || message?.toLowerCase().includes('pairing') === true); + + async function selectDevice(device: ListedDevice) { + setBusyId(device.id); + setError(null); + try { + if (selectedId !== null && selectedId !== device.id) { + await assignDevice(selectedId, null); + } + await assignDevice(device.id, profile.id); + onSelected(device.id); + setSelectedAt(Date.now()); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusyId(null); + } + } + + // The prefilled name is applied on Next so nobody has to find a separate save button. + async function saveNameAndContinue() { + if (selectedId !== null && label.trim() !== '') { + setBusyId(selectedId); + setError(null); + try { + await labelDevice(selectedId, label.trim()); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + setBusyId(null); + return; + } + setBusyId(null); + } + onNext(); + } + + const trainer = kind === 'trainer'; + return ( +
+

+ {trainer ? 'Connect your trainer' : 'Connect heart rate'} +

+

+ {trainer + ? 'Pedal a few strokes to wake your trainer.' + : 'Put on your heart rate strap and wake it.'} +

+ {(error ?? discoveryError) !== null && ( +

+ {error ?? discoveryError} +

+ )} + void selectDevice(device)} + /> + {selectedId !== null && ( + <> + + + + )} + {pairingHint && ( +

+ Hold the strap button for 4 seconds to pair +

+ )} + void saveNameAndContinue()} + onSkip={onSkip} + /> +
+ ); +} + +function DoneStep({ + profile, + trainer, + hrm, + onBack, + onStart, +}: { + profile: RiderProfile; + trainer: ListedDevice | undefined; + hrm: ListedDevice | undefined; + onBack: () => void; + onStart: () => void; +}) { + return ( +
+

+ Ready to ride +

+

Your rider and devices are ready for a first session.

+
+
+
Rider
+
{profile.name}
+
+
+
Trainer
+
{trainer?.name || 'Connected trainer'}
+
+
+
Heart rate
+
{hrm?.name || 'Skipped'}
+
+
+
+

+ Garmin Connect +

+

+ Optional. Sign in now to sync ride history and uploads. +

+ +
+
+ + +
+
+ ); +} + +function useDiscoveredDevices(enabled: boolean) { + const [devices, setDevices] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + async function refresh() { + try { + const next = await listDevices(); + if (!cancelled) { + setDevices(next); + setError(null); + } + } catch (caught) { + if (!cancelled) setError(caught instanceof Error ? caught.message : String(caught)); + } + } + void refresh(); + const timer = window.setInterval(() => void refresh(), 2000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [enabled]); + + return { devices, error }; +} + +export default function WelcomePage() { + const navigate = useNavigate(); + const adapterState = useAppStore((state) => state.adapterState); + const [step, setStep] = useState(0); + const [profile, setProfile] = useState(null); + const [trainerId, setTrainerId] = useState(null); + const [hrmId, setHrmId] = useState(null); + const { devices, error } = useDiscoveredDevices(profile !== null); + + useEffect(() => { + let cancelled = false; + void getBleState() + .then(({ state }) => { + if (!cancelled) { + useAppStore.getState().applyServer({ type: 'adapterState', state }); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + const trainers = devices.filter((device) => device.kind === 'trainer'); + const hrms = devices.filter((device) => device.kind === 'hrm'); + const trainer = devices.find((device) => device.id === trainerId); + const hrm = devices.find((device) => device.id === hrmId); + + function handleRiderSaved(saved: RiderProfile) { + setProfile(saved); + setStep(2); + } + + return ( +
+ +
+ {step === 0 && setStep(1)} />} + {step === 1 && ( + setStep(0)} + onSaved={handleRiderSaved} + /> + )} + {step === 2 && profile !== null && ( + setStep(1)} + onNext={() => setStep(3)} + /> + )} + {step === 3 && profile !== null && ( + setStep(2)} + onNext={() => setStep(4)} + onSkip={() => setStep(4)} + /> + )} + {step === 4 && profile !== null && ( + setStep(3)} + onStart={() => navigate('/session/new')} + /> + )} +
+
+ ); +} diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 2ee3e58..33ebd5b 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,5 +1,7 @@ import { create } from 'zustand'; import type { + AdapterState, + DeviceSample, SessionEvent, SessionSnapshot, TelemetrySample, @@ -43,6 +45,10 @@ export interface AppState { deviceStatus: Record; /** deviceId -> last driver status message. */ deviceStatusMessage: Record; + /** Current Bluetooth adapter state reported by the server. */ + adapterState: AdapterState; + /** deviceId -> latest live sample, independent of session state. */ + deviceSamples: Record; /** Last server `error` frame message, additive to the frozen contract. */ lastError: string | null; /** @@ -72,6 +78,8 @@ export const useAppStore = create()((set) => ({ events: [], deviceStatus: {}, deviceStatusMessage: {}, + adapterState: 'unknown', + deviceSamples: {}, lastError: null, hydrated: false, spaceGame: loadSpaceGame(), @@ -136,6 +144,16 @@ export const useAppStore = create()((set) => ({ }); break; } + case 'adapterState': { + set({ adapterState: msg.state }); + break; + } + case 'deviceSample': { + set((state) => ({ + deviceSamples: { ...state.deviceSamples, [msg.deviceId]: msg }, + })); + break; + } case 'deviceStatus': { set((state) => { const deviceStatusMessage = { ...state.deviceStatusMessage }; diff --git a/packages/shared/src/api.test.ts b/packages/shared/src/api.test.ts index eff4aa2..2048dbd 100644 --- a/packages/shared/src/api.test.ts +++ b/packages/shared/src/api.test.ts @@ -77,6 +77,10 @@ describe('WsServerMessageSchema', () => { ['rejects a telemetry batch with a missing riderId', { type: 'telemetry', samples: [{ ...sample, riderId: undefined }] }, false], ['rejects a telemetry batch that is not an array', { type: 'telemetry', samples: sample }, false], + ['accepts adapterState', { type: 'adapterState', state: 'unauthorized' }, true], + ['accepts a trainer deviceSample', { type: 'deviceSample', deviceId: 't1', kind: 'trainer', ts: 1_700_000_000_000, powerW: 200, cadenceRpm: 90 }, true], + ['accepts an HRM deviceSample', { type: 'deviceSample', deviceId: 'h1', kind: 'hrm', ts: 1_700_000_000_000, hrBpm: 150 }, true], + ['accepts stepCompleted', { type: 'sessionEvent', event: { kind: 'stepCompleted', riderId: 'r1', stepIndex: 0, ts: 1000 } }, true], ['accepts workoutCompleted', { type: 'sessionEvent', event: { kind: 'workoutCompleted', riderId: 'r1', ts: 1000 } }, true], ['accepts bothInZone', { type: 'sessionEvent', event: { kind: 'bothInZone', ts: 1000, streakS: 30 } }, true], diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 2314e06..f887364 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -78,9 +78,37 @@ export const SessionSnapshotSchema = z.object({ export type SessionDestination = NonNullable['destination']>; +export const AdapterStateSchema = z.enum([ + 'poweredOn', + 'poweredOff', + 'unauthorized', + 'unsupported', + 'unknown', + 'resetting', + 'disabled', +]); + +export const DeviceSampleSchema = z.object({ + type: z.literal('deviceSample'), + deviceId: z.string().min(1), + kind: DeviceKindSchema, + ts: z.number().int().positive(), + powerW: z.number().nonnegative().optional(), + cadenceRpm: z.number().nonnegative().optional(), + hrBpm: z.number().min(20).max(250).optional(), +}); + +export const GarminLoginStatusSchema = z.object({ + hasLogin: z.boolean(), + state: z.enum(['idle', 'installing', 'running', 'failed']), + error: z.string().min(1).optional(), +}); + /** Server → client WS payloads. */ export const WsServerMessageSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('telemetry'), samples: z.array(TelemetrySampleSchema) }), + z.object({ type: z.literal('adapterState'), state: AdapterStateSchema }), + DeviceSampleSchema, z.object({ type: z.literal('sessionEvent'), event: SessionEventSchema }), z.object({ type: z.literal('deviceStatus'), @@ -111,3 +139,6 @@ export type RiderStart = z.infer; export type SessionSnapshot = z.infer; export type WsServerMessage = z.infer; export type WsClientMessage = z.infer; +export type AdapterState = z.infer; +export type DeviceSample = z.infer; +export type GarminLoginStatus = z.infer;