Merge pull request #336 from trails-cool/stigi/wahoo-push-apply
Add @trails-cool/fit GPX→FIT Course encoder
This commit is contained in:
commit
19f4c45c74
18 changed files with 384 additions and 18 deletions
|
|
@ -21,6 +21,7 @@
|
|||
"@trails-cool/api": "workspace:*",
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/jobs": "workspace:*",
|
||||
"@trails-cool/fit": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
"@trails-cool/map": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -23,17 +23,22 @@ There is no FIT *encoder* in the repo today — we only decode. FIT Course encod
|
|||
|
||||
## Decisions
|
||||
|
||||
### 1. Use a hand-rolled FIT Course encoder, not the official Garmin SDK
|
||||
### 1. Use Garmin's official `@garmin/fitsdk` package server-side
|
||||
|
||||
**Decision**: Add a small (~300 LOC) hand-rolled FIT encoder to `packages/fit/` that emits exactly the message types Wahoo needs for a Course: `file_id`, `file_creator`, `course`, `lap`, `event` (start/stop), and `record`. No dependency on Garmin's SDK.
|
||||
**Decision**: Depend on `@garmin/fitsdk` (the renamed, current package — formerly published as `@garmin/fitsdk-javascript`) and wrap it in `packages/fit/` with a single `gpxToFitCourse(...)` export. Encoding runs server-side in the Journal action route only — the SDK does not ship to the planner browser bundle.
|
||||
|
||||
**Why**:
|
||||
- `@garmin/fitsdk-javascript` is the official option but ships ~1 MB of profile metadata and a CommonJS-only build that fights our ESM/Vite setup. Pulling it into the journal bundle is wasteful when we use ~5 message types.
|
||||
- `fit-file-writer` (npm) is the closest community option, but unmaintained since 2021 and ships TypeScript types that disagree with the runtime API. We'd be patching it on day one.
|
||||
- The FIT binary format is a documented, stable protocol (header + variable-length records + CRC). Encoding the seven messages we need is small enough to own.
|
||||
- `fit-file-parser` (used today on the import path) gives us a free correctness oracle: every encoded file gets round-tripped through the parser in tests and asserted to match the source GPX track points.
|
||||
**Why** (revised after re-evaluating the SDK as of 2026-04):
|
||||
- The SDK is now **pure ESM** (`"type": "module"`, zero deps). The earlier ESM/Vite friction is gone.
|
||||
- Actively maintained by Garmin, published quarterly in lockstep with the FIT profile (latest 21.202.0, Apr 2026).
|
||||
- First-class Course encoder API (`new Encoder()` + `writeMesg(...)` + `close()` → `Uint8Array`) with an official cookbook at <https://developer.garmin.com/fit/cookbook/encoding-course-files/>.
|
||||
- The ~1 MB unpacked size is real but irrelevant: encoding lives behind `/api/sync/push/wahoo/:routeId` (Node 20). Nothing in the planner imports `@trails-cool/fit`.
|
||||
- Avoids ~400 LOC of binary plumbing (file header, definition messages, data messages, CRC-16) that we'd otherwise own and maintain against future FIT spec updates.
|
||||
|
||||
**Alternatives considered**: `@garmin/fitsdk-javascript` (rejected: bundle size, ESM friction), `fit-file-writer` (rejected: unmaintained), shelling out to the Python `fit-tool` (rejected: adds a runtime dependency on the deploy host that doesn't already exist).
|
||||
**Round-trip oracle**: `fit-file-parser` (already a dep on the import path) decodes every encoded fixture in tests and asserts track-point parity with the source GPX. Same correctness story as before — just with less code in our repo.
|
||||
|
||||
**Cost**: TypeScript types aren't shipped, so `packages/fit/` includes a small ambient `fitsdk.d.ts` (~30 lines covering `Encoder`, `Stream`, `Profile.MesgNum`).
|
||||
|
||||
**Alternatives considered**: hand-rolled encoder (rejected: 400 LOC of binary format we'd own forever, plus ongoing maintenance against FIT spec updates — net loss now that the SDK's ESM/maintenance objections are resolved); `fit-file-writer` (rejected: appears unpublished from npm registry); shelling out to Python `fit-tool` (rejected: new runtime dependency on the deploy host).
|
||||
|
||||
### 2. New `packages/fit/` package, GPX-shaped public API
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ The existing Wahoo integration is read-only: we pull completed workouts back int
|
|||
- `apps/journal/app/routes/` — new `/api/sync/push/wahoo/:routeId` action; "Send to Wahoo" button on the route detail page.
|
||||
- `packages/db/src/schema/journal.ts` — new `sync_pushes` table + `granted_scopes` column on `sync_connections`, plus the generated Drizzle migration in `packages/db/migrations/`.
|
||||
- **New package** `packages/fit/` — GPX→FIT Course encoder, co-located tests with FIT-decoder round-trip fixtures.
|
||||
- **Dependencies**: One new dep — a JS FIT encoder. Candidates to evaluate in design.md: `@garmin/fitsdk-javascript` (official, large), `fit-file-writer`, or a thin hand-rolled encoder against the FIT SDK profile (Course message + Record + Lap).
|
||||
- **Dependencies**: One new dep — `@garmin/fitsdk` (Garmin's official ESM-native FIT SDK; see design.md §1 for evaluation). `fit-file-parser` is already a dep on the import path and is reused as the round-trip oracle in tests.
|
||||
- **External API**: Net-new outbound calls to `api.wahooligan.com/v1/routes`. No webhook changes.
|
||||
- **Privacy manifest**: Update `docs/privacy.md` (or wherever the manifest lives) to declare that route geometry + name + description are sent to Wahoo when the user opts in by clicking "Send to Wahoo".
|
||||
- **Out of scope for this change**: bulk push ("send all my routes"), automatic push on route save, push to non-Wahoo providers, and pushing route updates after the initial send (PUT /v1/routes/:id can come later — first send wins).
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
## 1. FIT Course encoder package
|
||||
|
||||
- [ ] 1.1 Scaffold `packages/fit/` (package.json, tsconfig, vitest config) following the pattern of `packages/gpx/`
|
||||
- [ ] 1.2 Add the package to `pnpm-workspace.yaml` and reference it from `apps/journal/package.json`
|
||||
- [ ] 1.3 Implement low-level FIT primitives: file header, definition messages, data messages, CRC-16. Cover only the field types needed (`enum`, `uint8`, `uint16`, `uint32`, `sint32`, `string`, `float32`)
|
||||
- [ ] 1.4 Implement message encoders for `file_id` (type=course), `file_creator`, `course` (name, sport, capabilities), `lap` (start/end position, distance, elapsed time = 0), `event` (timer start at first record, timer stop at last), and `record` (position_lat, position_long, altitude, distance)
|
||||
- [ ] 1.5 Implement `gpxToFitCourse({ gpx, name, description?, sport? })`: parse GPX via `@trails-cool/gpx`, compute distance and ascent, emit the message stream, return `Uint8Array`
|
||||
- [ ] 1.6 Add fixtures in `packages/fit/__fixtures__/` covering: short flat route, alpine route with elevation, multi-day route, route with single track point (should still encode), zero-track-point route (should throw)
|
||||
- [ ] 1.7 Write round-trip tests: encode each fixture, decode with `fit-file-parser`, assert track-point count and lat/lon/elevation parity within tolerance (1e-5 deg, 0.5 m)
|
||||
- [ ] 1.8 Add the package to `pnpm test` and `pnpm typecheck` runs (should be automatic via Turborepo)
|
||||
- [x] 1.1 Scaffold `packages/fit/` (package.json, tsconfig, vitest config) following the pattern of `packages/gpx/`
|
||||
- [x] 1.2 Add the package to `pnpm-workspace.yaml` and reference it from `apps/journal/package.json`
|
||||
- [x] 1.3 Add `@garmin/fitsdk` dependency to `packages/fit/package.json` and a small ambient `src/fitsdk.d.ts` declaring the `Encoder`, `Stream`, and `Profile.MesgNum` shapes we use (the SDK ships no types)
|
||||
- [x] 1.4 Wrap the SDK Encoder to emit the Course message stream Wahoo needs: `file_id` (type=course), `file_creator`, `course` (name, sport, capabilities), `lap` (start/end position, distance, elapsed time = 0), `event` (timer start at first record, timer stop at last), and `record` (position_lat, position_long, altitude, distance)
|
||||
- [x] 1.5 Implement `gpxToFitCourse({ gpx, name, description?, sport? })`: parse GPX via `@trails-cool/gpx`, compute distance and ascent, drive the SDK encoder, return `Uint8Array`
|
||||
- [x] 1.6 Add fixtures in `packages/fit/__fixtures__/` covering: short flat route, alpine route with elevation, multi-day route, route with single track point (should still encode), zero-track-point route (should throw)
|
||||
- [x] 1.7 Write round-trip tests: encode each fixture, decode with `fit-file-parser`, assert track-point count and lat/lon/elevation parity within tolerance (1e-5 deg, 0.5 m)
|
||||
- [x] 1.8 Add the package to `pnpm test` and `pnpm typecheck` runs (should be automatic via Turborepo)
|
||||
|
||||
## 2. Database schema for push tracking
|
||||
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
|
||||
## 9. Tests
|
||||
|
||||
- [ ] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7
|
||||
- [x] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7
|
||||
- [ ] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit
|
||||
- [ ] 9.3 Unit tests for the scope-mismatch redirect flow
|
||||
- [ ] 9.4 E2E test in `e2e/`: log in as a user with a (mocked) Wahoo connection, open a route detail page, click "Send to Wahoo", assert the success toast and the "Sent to Wahoo on …" status appear
|
||||
|
|
|
|||
16
packages/fit/__fixtures__/alpine.gpx
Normal file
16
packages/fit/__fixtures__/alpine.gpx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata><name>Alpine climb</name></metadata>
|
||||
<trk><trkseg>
|
||||
<trkpt lat="46.5000" lon="11.3000"><ele>800.0</ele></trkpt>
|
||||
<trkpt lat="46.5050" lon="11.3050"><ele>950.5</ele></trkpt>
|
||||
<trkpt lat="46.5100" lon="11.3100"><ele>1100.2</ele></trkpt>
|
||||
<trkpt lat="46.5150" lon="11.3150"><ele>1280.7</ele></trkpt>
|
||||
<trkpt lat="46.5200" lon="11.3200"><ele>1450.0</ele></trkpt>
|
||||
<trkpt lat="46.5250" lon="11.3250"><ele>1620.3</ele></trkpt>
|
||||
<trkpt lat="46.5300" lon="11.3300"><ele>1800.0</ele></trkpt>
|
||||
<trkpt lat="46.5350" lon="11.3350"><ele>1750.0</ele></trkpt>
|
||||
<trkpt lat="46.5400" lon="11.3400"><ele>1600.0</ele></trkpt>
|
||||
<trkpt lat="46.5450" lon="11.3450"><ele>1400.0</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>
|
||||
5
packages/fit/__fixtures__/empty.gpx
Normal file
5
packages/fit/__fixtures__/empty.gpx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata><name>Empty</name></metadata>
|
||||
<trk><trkseg></trkseg></trk>
|
||||
</gpx>
|
||||
21
packages/fit/__fixtures__/multi-day.gpx
Normal file
21
packages/fit/__fixtures__/multi-day.gpx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata><name>Multi-day tour</name></metadata>
|
||||
<trk><trkseg>
|
||||
<trkpt lat="47.0000" lon="10.0000"><ele>500</ele></trkpt>
|
||||
<trkpt lat="47.0500" lon="10.0500"><ele>650</ele></trkpt>
|
||||
<trkpt lat="47.1000" lon="10.1000"><ele>800</ele></trkpt>
|
||||
<trkpt lat="47.1500" lon="10.1500"><ele>720</ele></trkpt>
|
||||
<trkpt lat="47.2000" lon="10.2000"><ele>600</ele></trkpt>
|
||||
<trkpt lat="47.2500" lon="10.2500"><ele>550</ele></trkpt>
|
||||
<trkpt lat="47.3000" lon="10.3000"><ele>700</ele></trkpt>
|
||||
<trkpt lat="47.3500" lon="10.3500"><ele>900</ele></trkpt>
|
||||
<trkpt lat="47.4000" lon="10.4000"><ele>1100</ele></trkpt>
|
||||
<trkpt lat="47.4500" lon="10.4500"><ele>1050</ele></trkpt>
|
||||
<trkpt lat="47.5000" lon="10.5000"><ele>900</ele></trkpt>
|
||||
<trkpt lat="47.5500" lon="10.5500"><ele>750</ele></trkpt>
|
||||
<trkpt lat="47.6000" lon="10.6000"><ele>600</ele></trkpt>
|
||||
<trkpt lat="47.6500" lon="10.6500"><ele>500</ele></trkpt>
|
||||
<trkpt lat="47.7000" lon="10.7000"><ele>450</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>
|
||||
11
packages/fit/__fixtures__/short-flat.gpx
Normal file
11
packages/fit/__fixtures__/short-flat.gpx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata><name>Short flat loop</name></metadata>
|
||||
<trk><name>Short flat loop</name><trkseg>
|
||||
<trkpt lat="52.5200" lon="13.4050"><ele>34.0</ele></trkpt>
|
||||
<trkpt lat="52.5210" lon="13.4060"><ele>34.5</ele></trkpt>
|
||||
<trkpt lat="52.5220" lon="13.4070"><ele>35.0</ele></trkpt>
|
||||
<trkpt lat="52.5230" lon="13.4080"><ele>34.5</ele></trkpt>
|
||||
<trkpt lat="52.5240" lon="13.4090"><ele>34.0</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>
|
||||
7
packages/fit/__fixtures__/single-point.gpx
Normal file
7
packages/fit/__fixtures__/single-point.gpx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="trails.cool" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata><name>Single point</name></metadata>
|
||||
<trk><trkseg>
|
||||
<trkpt lat="48.8566" lon="2.3522"><ele>35</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>
|
||||
23
packages/fit/package.json
Normal file
23
packages/fit/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@trails-cool/fit",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@garmin/fitsdk": "^21.202.0",
|
||||
"@trails-cool/gpx": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"fit-file-parser": "^2.3.3"
|
||||
}
|
||||
}
|
||||
39
packages/fit/src/fitsdk.d.ts
vendored
Normal file
39
packages/fit/src/fitsdk.d.ts
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
declare module "@garmin/fitsdk" {
|
||||
export class Encoder {
|
||||
constructor(options?: { fieldDescriptions?: unknown });
|
||||
writeMesg(mesg: { mesgNum: number; [field: string]: unknown }): this;
|
||||
onMesg(mesgNum: number, mesg: Record<string, unknown>): this;
|
||||
close(): Uint8Array;
|
||||
}
|
||||
|
||||
export class Stream {
|
||||
static fromArrayBuffer(buf: ArrayBuffer): Stream;
|
||||
static fromBuffer(buf: Uint8Array | Buffer): Stream;
|
||||
}
|
||||
|
||||
export class Decoder {
|
||||
constructor(stream: Stream);
|
||||
read(opts?: Record<string, unknown>): {
|
||||
messages: Record<string, Array<Record<string, unknown>>>;
|
||||
errors: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
export const Profile: {
|
||||
version: { major: number; minor: number };
|
||||
MesgNum: {
|
||||
FILE_ID: number;
|
||||
FILE_CREATOR: number;
|
||||
COURSE: number;
|
||||
LAP: number;
|
||||
RECORD: number;
|
||||
EVENT: number;
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const Utils: {
|
||||
convertDateToDateTime(date: Date): number;
|
||||
convertDateTimeToDate(dt: number): Date;
|
||||
};
|
||||
}
|
||||
76
packages/fit/src/gpx-to-fit-course.test.ts
Normal file
76
packages/fit/src/gpx-to-fit-course.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import FitParser from "fit-file-parser";
|
||||
|
||||
import { parseGpxAsync } from "@trails-cool/gpx";
|
||||
|
||||
import { gpxToFitCourse } from "./gpx-to-fit-course.ts";
|
||||
|
||||
const FIXTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../__fixtures__");
|
||||
|
||||
async function loadFixture(name: string): Promise<string> {
|
||||
return readFile(resolve(FIXTURES_DIR, name), "utf8");
|
||||
}
|
||||
|
||||
interface ParsedFit {
|
||||
records: Array<{ position_lat?: number; position_long?: number; altitude?: number }>;
|
||||
course?: Array<{ name?: string; sport?: string }> | { name?: string; sport?: string };
|
||||
laps?: Array<unknown>;
|
||||
}
|
||||
|
||||
function decode(bytes: Uint8Array): Promise<ParsedFit> {
|
||||
return new Promise((res, rej) => {
|
||||
const parser = new FitParser({ force: true, mode: "list", lengthUnit: "m", speedUnit: "m/s" });
|
||||
parser.parse(Buffer.from(bytes), (err, data) => {
|
||||
if (err) rej(new Error(err));
|
||||
else res(data as unknown as ParsedFit);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const FIXTURES = ["short-flat.gpx", "alpine.gpx", "multi-day.gpx", "single-point.gpx"] as const;
|
||||
|
||||
describe("gpxToFitCourse", () => {
|
||||
for (const fixture of FIXTURES) {
|
||||
it(`encodes ${fixture} round-trip via fit-file-parser`, async () => {
|
||||
const gpx = await loadFixture(fixture);
|
||||
const source = await parseGpxAsync(gpx);
|
||||
const sourcePoints = source.tracks.flat();
|
||||
|
||||
const bytes = await gpxToFitCourse({ gpx, name: `Test ${fixture}` });
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(bytes.byteLength).toBeGreaterThan(20);
|
||||
|
||||
const parsed = await decode(bytes);
|
||||
const records = parsed.records ?? [];
|
||||
expect(records.length).toBe(sourcePoints.length);
|
||||
|
||||
for (let i = 0; i < sourcePoints.length; i++) {
|
||||
const src = sourcePoints[i]!;
|
||||
const got = records[i]!;
|
||||
expect(got.position_lat).toBeCloseTo(src.lat, 4);
|
||||
expect(got.position_long).toBeCloseTo(src.lon, 4);
|
||||
if (src.ele !== undefined && got.altitude !== undefined) {
|
||||
expect(Math.abs(got.altitude - src.ele)).toBeLessThan(0.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("throws on a GPX with zero track points", async () => {
|
||||
const gpx = await loadFixture("empty.gpx");
|
||||
await expect(gpxToFitCourse({ gpx, name: "Empty" })).rejects.toThrow(/zero track points/);
|
||||
});
|
||||
|
||||
it("encodes course name and sport", async () => {
|
||||
const gpx = await loadFixture("short-flat.gpx");
|
||||
const bytes = await gpxToFitCourse({ gpx, name: "Loop Test", sport: "cycling" });
|
||||
const parsed = await decode(bytes);
|
||||
const course = Array.isArray(parsed.course) ? parsed.course[0] : parsed.course;
|
||||
expect(course?.name).toBe("Loop Test");
|
||||
expect(course?.sport).toBe("cycling");
|
||||
});
|
||||
});
|
||||
117
packages/fit/src/gpx-to-fit-course.ts
Normal file
117
packages/fit/src/gpx-to-fit-course.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { Encoder, Profile } from "@garmin/fitsdk";
|
||||
import { parseGpxAsync } from "@trails-cool/gpx";
|
||||
import type { TrackPoint } from "@trails-cool/gpx";
|
||||
|
||||
import { degToSemicircles } from "./semicircles.ts";
|
||||
|
||||
export type FitCourseSport = "cycling" | "running" | "hiking";
|
||||
|
||||
export interface GpxToFitCourseInput {
|
||||
gpx: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
sport?: FitCourseSport;
|
||||
}
|
||||
|
||||
const SPORT_ENUM: Record<FitCourseSport, string> = {
|
||||
cycling: "cycling",
|
||||
running: "running",
|
||||
hiking: "hiking",
|
||||
};
|
||||
|
||||
export async function gpxToFitCourse(input: GpxToFitCourseInput): Promise<Uint8Array> {
|
||||
const data = await parseGpxAsync(input.gpx);
|
||||
const points: TrackPoint[] = data.tracks.flat();
|
||||
|
||||
if (points.length === 0) {
|
||||
throw new Error("Cannot encode FIT Course from a GPX with zero track points");
|
||||
}
|
||||
|
||||
const first = points[0]!;
|
||||
const last = points[points.length - 1]!;
|
||||
const startTime = new Date("2020-01-01T00:00:00Z");
|
||||
|
||||
const encoder = new Encoder();
|
||||
|
||||
encoder.writeMesg({
|
||||
mesgNum: Profile.MesgNum.FILE_ID,
|
||||
type: "course",
|
||||
manufacturer: "development",
|
||||
product: 0,
|
||||
timeCreated: startTime,
|
||||
serialNumber: 0,
|
||||
});
|
||||
|
||||
encoder.writeMesg({
|
||||
mesgNum: Profile.MesgNum.FILE_CREATOR,
|
||||
softwareVersion: 1,
|
||||
hardwareVersion: 0,
|
||||
});
|
||||
|
||||
encoder.writeMesg({
|
||||
mesgNum: Profile.MesgNum.COURSE,
|
||||
name: input.name,
|
||||
sport: SPORT_ENUM[input.sport ?? "cycling"],
|
||||
capabilities: 0x00000004,
|
||||
});
|
||||
|
||||
encoder.writeMesg({
|
||||
mesgNum: Profile.MesgNum.LAP,
|
||||
timestamp: startTime,
|
||||
startTime,
|
||||
startPositionLat: degToSemicircles(first.lat),
|
||||
startPositionLong: degToSemicircles(first.lon),
|
||||
endPositionLat: degToSemicircles(last.lat),
|
||||
endPositionLong: degToSemicircles(last.lon),
|
||||
totalElapsedTime: 0,
|
||||
totalTimerTime: 0,
|
||||
totalDistance: data.distance,
|
||||
});
|
||||
|
||||
encoder.writeMesg({
|
||||
mesgNum: Profile.MesgNum.EVENT,
|
||||
timestamp: startTime,
|
||||
event: "timer",
|
||||
eventType: "start",
|
||||
});
|
||||
|
||||
let cumulativeDistance = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i]!;
|
||||
if (i > 0) {
|
||||
const prev = points[i - 1]!;
|
||||
cumulativeDistance += haversine(prev.lat, prev.lon, p.lat, p.lon);
|
||||
}
|
||||
const mesg: { mesgNum: number; [field: string]: unknown } = {
|
||||
mesgNum: Profile.MesgNum.RECORD,
|
||||
timestamp: new Date(startTime.getTime() + i * 1000),
|
||||
positionLat: degToSemicircles(p.lat),
|
||||
positionLong: degToSemicircles(p.lon),
|
||||
distance: cumulativeDistance,
|
||||
};
|
||||
if (p.ele !== undefined) {
|
||||
mesg.altitude = p.ele;
|
||||
}
|
||||
encoder.writeMesg(mesg);
|
||||
}
|
||||
|
||||
encoder.writeMesg({
|
||||
mesgNum: Profile.MesgNum.EVENT,
|
||||
timestamp: new Date(startTime.getTime() + (points.length - 1) * 1000),
|
||||
event: "timer",
|
||||
eventType: "stopAll",
|
||||
});
|
||||
|
||||
return encoder.close();
|
||||
}
|
||||
|
||||
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000;
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
2
packages/fit/src/index.ts
Normal file
2
packages/fit/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { gpxToFitCourse } from "./gpx-to-fit-course.ts";
|
||||
export type { FitCourseSport, GpxToFitCourseInput } from "./gpx-to-fit-course.ts";
|
||||
9
packages/fit/src/semicircles.ts
Normal file
9
packages/fit/src/semicircles.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const SEMICIRCLES_PER_DEGREE = 2 ** 31 / 180;
|
||||
|
||||
export function degToSemicircles(deg: number): number {
|
||||
return Math.round(deg * SEMICIRCLES_PER_DEGREE);
|
||||
}
|
||||
|
||||
export function semicirclesToDeg(semi: number): number {
|
||||
return semi / SEMICIRCLES_PER_DEGREE;
|
||||
}
|
||||
9
packages/fit/tsconfig.json
Normal file
9
packages/fit/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
packages/fit/vitest.config.ts
Normal file
1
packages/fit/vitest.config.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../../vitest.shared.ts";
|
||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
|
|
@ -218,6 +218,9 @@ importers:
|
|||
'@trails-cool/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db
|
||||
'@trails-cool/fit':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/fit
|
||||
'@trails-cool/gpx':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/gpx
|
||||
|
|
@ -592,6 +595,22 @@ importers:
|
|||
specifier: 'catalog:'
|
||||
version: 22.19.17
|
||||
|
||||
packages/fit:
|
||||
dependencies:
|
||||
'@garmin/fitsdk':
|
||||
specifier: ^21.202.0
|
||||
version: 21.202.0
|
||||
'@trails-cool/gpx':
|
||||
specifier: workspace:*
|
||||
version: link:../gpx
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 22.19.17
|
||||
fit-file-parser:
|
||||
specifier: ^2.3.3
|
||||
version: 2.3.3
|
||||
|
||||
packages/gpx:
|
||||
dependencies:
|
||||
'@trails-cool/types':
|
||||
|
|
@ -1928,6 +1947,9 @@ packages:
|
|||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
|
||||
'@garmin/fitsdk@21.202.0':
|
||||
resolution: {integrity: sha512-WeveKUCKZSehRdM6JY/n0embblH18z5sY0KkJye5Z9nJ8Qv2S6EEmoSuBrW2ew45OtTGph0bu4/jxlO6VRP+Qw==}
|
||||
|
||||
'@geoman-io/leaflet-geoman-free@2.19.3':
|
||||
resolution: {integrity: sha512-HjbEpfAEUs0NyI1Dhvz3SMVG6m0pAN/1Eo0tRKsz9cpaROTrFtmJGY22swEir1Uj/8IeGF1NJId38C5Fu+nZGQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -8942,6 +8964,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@garmin/fitsdk@21.202.0': {}
|
||||
|
||||
'@geoman-io/leaflet-geoman-free@2.19.3(leaflet@1.9.4)':
|
||||
dependencies:
|
||||
'@turf/boolean-contains': 7.3.4
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue