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:
Ullrich Schäfer 2026-04-30 22:31:37 +02:00
parent a45a3808d9
commit 8ba5554a67
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
18 changed files with 384 additions and 18 deletions

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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;
};
}

View 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");
});
});

View 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));
}

View file

@ -0,0 +1,2 @@
export { gpxToFitCourse } from "./gpx-to-fit-course.ts";
export type { FitCourseSport, GpxToFitCourseInput } from "./gpx-to-fit-course.ts";

View 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;
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";