Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>"`.
- To run a one-off command with the app's Bluetooth permission: `open -a openCycle --args --exec "<command>"`.

`pnpm app` options: `--sim` (simulator, no Bluetooth), `--out <dir>`, `--data-dir <path>`, `--no-build`.

Expand Down Expand Up @@ -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 <profile-id>
```
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/<riderId>.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/<riderId>.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 <profile-id>`.

## Credits

Expand Down
26 changes: 7 additions & 19 deletions apps/server/scripts/garmin-login.ts
Original file line number Diff line number Diff line change
@@ -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 <id>

Expand Down Expand Up @@ -40,27 +38,17 @@ function parseArgs(argv: string[]): CliArgs {
async function main(): Promise<void> {
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) => {
Expand Down
15 changes: 12 additions & 3 deletions apps/server/src/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/api/garminRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ async function buildWorld(session: unknown = null): Promise<World> {
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 };
Expand Down
82 changes: 68 additions & 14 deletions apps/server/src/api/garminRoutes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +18,8 @@ export interface GarminRouteDeps {
queue: UploadQueue;
connector: Pick<GarminConnector, 'pullRecentActivities'>;
engine: SessionEngine;
hasLogin(riderId: string): boolean;
runLogin(riderId: string, onProgress: (state: GarminLoginProgress) => void): Promise<void>;
}

/** How many days back the history pull scans on each request. */
Expand All @@ -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<string, StoredLoginStatus>();

app.post('/api/rides/:id/upload', async (req, reply) => {
const id = parseIdParam(req.params);
Expand All @@ -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<void>;
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');
}
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
69 changes: 62 additions & 7 deletions apps/server/src/api/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
import {
WsClientMessageSchema,
type AdapterState,
type DeviceSample,
type SessionEvent,
type SessionSnapshot,
type TelemetrySample,
type WsClientMessage,
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';
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, number>();
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));

Expand Down Expand Up @@ -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);
});
}
Loading
Loading