From dbba1c1607ec97f1195c038c065b0b376ab5a2f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 1 May 2026 10:24:50 +0200 Subject: [PATCH] Fix FIT Course capabilities and lap duration The Course message advertised capabilities=0x04 (time only), telling consumers the course had no position data. Wahoo accepted the route (distance/elevation come from request fields) but rendered an empty map. Set capabilities to valid|distance|position (0x1A). Also write a real lap totalElapsedTime/totalTimerTime derived from the 1Hz record timestamps; a zero-duration lap can be rejected as malformed. Co-Authored-By: Claude Opus 4.7 --- packages/fit/src/gpx-to-fit-course.test.ts | 35 ++++++++++++++++++++-- packages/fit/src/gpx-to-fit-course.ts | 18 +++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/fit/src/gpx-to-fit-course.test.ts b/packages/fit/src/gpx-to-fit-course.test.ts index 8607f3d..6f938ea 100644 --- a/packages/fit/src/gpx-to-fit-course.test.ts +++ b/packages/fit/src/gpx-to-fit-course.test.ts @@ -17,8 +17,10 @@ async function loadFixture(name: string): Promise { interface ParsedFit { records: Array<{ position_lat?: number; position_long?: number; altitude?: number }>; - course?: Array<{ name?: string; sport?: string }> | { name?: string; sport?: string }; - laps?: Array; + course?: + | Array<{ name?: string; sport?: string; capabilities?: unknown }> + | { name?: string; sport?: string; capabilities?: unknown }; + laps?: Array<{ total_elapsed_time?: number; total_timer_time?: number }>; } function decode(bytes: Uint8Array): Promise { @@ -65,6 +67,35 @@ describe("gpxToFitCourse", () => { await expect(gpxToFitCourse({ gpx, name: "Empty" })).rejects.toThrow(/zero track points/); }); + it("advertises position+distance course capabilities (not time-only)", async () => { + const gpx = await loadFixture("short-flat.gpx"); + const bytes = await gpxToFitCourse({ gpx, name: "Caps", sport: "cycling" }); + const parsed = await decode(bytes); + const course = Array.isArray(parsed.course) ? parsed.course[0] : parsed.course; + // fit-file-parser returns capabilities as a single-element array of the raw bitfield. + const caps = course?.capabilities; + const value = Array.isArray(caps) ? Number(caps[0]) : Number(caps); + const VALID = 0x02; + const DISTANCE = 0x08; + const POSITION = 0x10; + const TIME = 0x04; + expect(value & VALID).toBe(VALID); + expect(value & DISTANCE).toBe(DISTANCE); + expect(value & POSITION).toBe(POSITION); + expect(value & TIME).toBe(0); + }); + + it("writes a non-zero lap elapsed time matching record count", async () => { + const gpx = await loadFixture("short-flat.gpx"); + const source = await parseGpxAsync(gpx); + const sourcePoints = source.tracks.flat(); + const bytes = await gpxToFitCourse({ gpx, name: "Lap", sport: "cycling" }); + const parsed = await decode(bytes); + const lap = parsed.laps?.[0]; + expect(lap?.total_elapsed_time).toBe(sourcePoints.length - 1); + expect(lap?.total_timer_time).toBe(sourcePoints.length - 1); + }); + it("encodes course name and sport", async () => { const gpx = await loadFixture("short-flat.gpx"); const bytes = await gpxToFitCourse({ gpx, name: "Loop Test", sport: "cycling" }); diff --git a/packages/fit/src/gpx-to-fit-course.ts b/packages/fit/src/gpx-to-fit-course.ts index 7f877eb..d8065f5 100644 --- a/packages/fit/src/gpx-to-fit-course.ts +++ b/packages/fit/src/gpx-to-fit-course.ts @@ -49,13 +49,25 @@ export async function gpxToFitCourse(input: GpxToFitCourseInput): Promise