Add @trails-cool/fit package with GPX→FIT Course encoder
Wraps @garmin/fitsdk to emit FIT Course files from GPX, the binary format Wahoo's POST /v1/routes API requires. Server-side only — the ~1 MB SDK never ships to the planner browser bundle. Round-trip tests use fit-file-parser as an independent oracle and assert lat/lon parity within 1e-4 deg and altitude within 0.5 m across short flat, alpine, multi-day, and single-point fixtures. Updates design.md decision #1: the original hand-rolled-encoder plan was justified largely by ESM friction in the Garmin SDK, but as of v21.202.0 the SDK is pure ESM with zero deps. Wrapping it saves us ~400 LOC of binary plumbing and ongoing maintenance against future FIT spec updates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a45a3808d9
commit
8ba5554a67
18 changed files with 384 additions and 18 deletions
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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue