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 <noreply@anthropic.com>
This commit is contained in:
parent
2865ec748f
commit
dbba1c1607
2 changed files with 48 additions and 5 deletions
|
|
@ -17,8 +17,10 @@ async function loadFixture(name: string): Promise<string> {
|
||||||
|
|
||||||
interface ParsedFit {
|
interface ParsedFit {
|
||||||
records: Array<{ position_lat?: number; position_long?: number; altitude?: number }>;
|
records: Array<{ position_lat?: number; position_long?: number; altitude?: number }>;
|
||||||
course?: Array<{ name?: string; sport?: string }> | { name?: string; sport?: string };
|
course?:
|
||||||
laps?: Array<unknown>;
|
| 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<ParsedFit> {
|
function decode(bytes: Uint8Array): Promise<ParsedFit> {
|
||||||
|
|
@ -65,6 +67,35 @@ describe("gpxToFitCourse", () => {
|
||||||
await expect(gpxToFitCourse({ gpx, name: "Empty" })).rejects.toThrow(/zero track points/);
|
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 () => {
|
it("encodes course name and sport", async () => {
|
||||||
const gpx = await loadFixture("short-flat.gpx");
|
const gpx = await loadFixture("short-flat.gpx");
|
||||||
const bytes = await gpxToFitCourse({ gpx, name: "Loop Test", sport: "cycling" });
|
const bytes = await gpxToFitCourse({ gpx, name: "Loop Test", sport: "cycling" });
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,25 @@ export async function gpxToFitCourse(input: GpxToFitCourseInput): Promise<Uint8A
|
||||||
hardwareVersion: 0,
|
hardwareVersion: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// courseCapabilities bitfield (FIT Profile):
|
||||||
|
// 0x02 valid | 0x08 distance | 0x10 position
|
||||||
|
// Previously this was 0x04 ("time" only), which falsely told consumers the
|
||||||
|
// course had no position data — Wahoo accepted the route but rendered an
|
||||||
|
// empty map.
|
||||||
|
const COURSE_CAPABILITIES_VALID_DISTANCE_POSITION = 0x1a;
|
||||||
|
|
||||||
encoder.writeMesg({
|
encoder.writeMesg({
|
||||||
mesgNum: Profile.MesgNum.COURSE,
|
mesgNum: Profile.MesgNum.COURSE,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
sport: SPORT_ENUM[input.sport ?? "cycling"],
|
sport: SPORT_ENUM[input.sport ?? "cycling"],
|
||||||
capabilities: 0x00000004,
|
capabilities: COURSE_CAPABILITIES_VALID_DISTANCE_POSITION,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Records below are written at 1Hz starting at startTime, so elapsed time
|
||||||
|
// matches (points.length - 1) seconds. A zero-duration lap can be rejected
|
||||||
|
// as malformed by stricter consumers.
|
||||||
|
const totalElapsedSeconds = points.length - 1;
|
||||||
|
|
||||||
encoder.writeMesg({
|
encoder.writeMesg({
|
||||||
mesgNum: Profile.MesgNum.LAP,
|
mesgNum: Profile.MesgNum.LAP,
|
||||||
timestamp: startTime,
|
timestamp: startTime,
|
||||||
|
|
@ -64,8 +76,8 @@ export async function gpxToFitCourse(input: GpxToFitCourseInput): Promise<Uint8A
|
||||||
startPositionLong: degToSemicircles(first.lon),
|
startPositionLong: degToSemicircles(first.lon),
|
||||||
endPositionLat: degToSemicircles(last.lat),
|
endPositionLat: degToSemicircles(last.lat),
|
||||||
endPositionLong: degToSemicircles(last.lon),
|
endPositionLong: degToSemicircles(last.lon),
|
||||||
totalElapsedTime: 0,
|
totalElapsedTime: totalElapsedSeconds,
|
||||||
totalTimerTime: 0,
|
totalTimerTime: totalElapsedSeconds,
|
||||||
totalDistance: data.distance,
|
totalDistance: data.distance,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue