Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/obc-web-convert/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ crate-type = ["cdylib", "rlib"]

[dependencies]
# The conversion and export algorithms themselves — the same `no_std` route decoder and finished
# ride-v4 reader shared with firmware. The nRF54L records final ride bytes directly; this crate is a
# ride-v5 reader shared with firmware. The nRF54L records final ride bytes directly; this crate is a
# byte-buffer adapter plus an error vocabulary.
obc-route = { path = "../../firmware/obc-route" }
# The byte-I/O seam (`ByteSource`/`ByteSink`/`Error`) and the recorded-track record width — the
Expand Down
8 changes: 4 additions & 4 deletions apps/obc-web-convert/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub enum ErrorCode {
GpxNoTrackPoints,
/// The decimated route still exceeds [`MAX_STORED_POINTS`].
GpxTooManyPoints,
/// The bytes are not a finished ride-v4 object.
/// The bytes are not a finished ride-v5 object.
NotRide,
/// The finished ride carries no recorded points.
RideNoPoints,
Expand Down Expand Up @@ -113,7 +113,7 @@ pub fn gpx_to_obcr(gpx: &[u8], name: &str) -> Result<Vec<u8>, ConvertFailure> {
Ok(sink.0)
}

/// Convert a finished ride-v4 object's bytes into a GPX 1.1 document named `name`.
/// Convert a finished ride-v5 object's bytes into a GPX 1.1 document named `name`.
///
/// Byte-for-byte the same output as `obc_route::track_to_gpx`; only the buffer adapter is new.
pub fn track_to_gpx(ride: &[u8], name: &str) -> Result<String, ConvertFailure> {
Expand Down Expand Up @@ -319,7 +319,7 @@ fn describe_track_error(e: Error) -> ConvertFailure {
),
Error::BadOffset | Error::BadMagic | Error::BadVersion => ConvertFailure::new(
ErrorCode::NotRide,
"These bytes are not one complete ride-v4 object. Download the finished ride again; \
"These bytes are not one complete ride-v5 object. Download the finished ride again; \
unfinished sample logs and older ride formats are not accepted.",
),
Error::Io => ConvertFailure::new(
Expand Down Expand Up @@ -572,7 +572,7 @@ mod tests {
assert!(not_gpx.message.contains(".fit"), "points at the likely real format: {not_gpx}");

let short = track_to_gpx(&[0xAB; 4], "x").unwrap_err();
assert!(short.message.contains("ride-v4"), "names the required format: {short}");
assert!(short.message.contains("ride-v5"), "names the required format: {short}");
}

/// A route past the storage ceiling reports that, with the number in it. The zig-zag keeps
Expand Down
2 changes: 1 addition & 1 deletion apps/obc-web-convert/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ mod web {
crate::convert::gpx_to_obcr(bytes, name).map_err(to_js)
}

/// Convert a finished ride-v4 object into a GPX 1.1 document, naming the track `name`.
/// Convert a finished ride-v5 object into a GPX 1.1 document, naming the track `name`.
///
/// Throws an `Error` carrying `code` and `message` on failure; see [`crate::ErrorCode`].
#[wasm_bindgen]
Expand Down
4 changes: 3 additions & 1 deletion builder/app/dev-harness/ride-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,20 @@ function loopRide(
}
const distanceM = Math.round(radiusDeg * 111_000 * 2 * Math.PI * 1.2);
return {
version: 4,
version: 5,
name,
startTime,
distanceM,
movingTimeS: n * 10,
avgSpeedCms: Math.round((distanceM / (n * 10)) * 100),
climbM,
descentM: climbM,
avgHr: 139,
maxHr: 171,
avgCadence: 78,
avgPower: null,
maxPower: null,
energyKj: null,
bikeType: 0,
trip: null,
points,
Expand Down
4 changes: 3 additions & 1 deletion builder/app/dev-harness/simulated-device.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,18 +283,20 @@ function syntheticRide(name: string, startTime: number, points: number, sensors:
});
}
return {
version: 4,
version: 5,
name,
startTime,
distanceM: points * 7,
movingTimeS: points,
avgSpeedCms: 700,
climbM: 600,
descentM: 600,
avgHr: sensors ? 139 : null,
maxHr: sensors ? 171 : null,
avgCadence: sensors ? 79 : null,
avgPower: sensors ? 209 : null,
maxPower: sensors ? 410 : null,
energyKj: sensors ? Math.floor((points * 209) / 1000) : null,
bikeType: 0,
trip: null,
points: list,
Expand Down
4 changes: 2 additions & 2 deletions builder/app/src/lib/convert/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe("gpxToObcr", () => {

describe("trackToGpx", () => {
it("reproduces the native exporter's GPX byte-for-byte", async () => {
const gpx = await trackToGpx(vector("ride-v4.bin"), TRACK_NAME);
const gpx = await trackToGpx(vector("ride-v5.bin"), TRACK_NAME);
const expected = new TextDecoder().decode(vector("track-export.gpx"));
// Compared as text so a failure diffs readably; the fixture is ASCII apart from nothing,
// so text equality here *is* byte equality.
Expand Down Expand Up @@ -262,7 +262,7 @@ describe("failures", () => {
expect(notGpx.message).toMatch(/\.fit|\.tcx/);

const shortLog = await failure(() => trackToGpx(new Uint8Array(9), "x"));
expect(shortLog.message).toContain("ride-v4");
expect(shortLog.message).toContain("ride-v5");

for (const e of [noTrack, notGpx, shortLog]) {
expect(e.message.length, `"${e.message}" is too terse to be actionable`).toBeGreaterThan(60);
Expand Down
2 changes: 1 addition & 1 deletion builder/app/src/lib/convert/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export async function gpxToObcr(gpx: Uint8Array, name: string): Promise<Uint8Arr
}

/**
* Convert a finished ride-v4 object's bytes into a GPX 1.1 document naming the track `name`.
* Convert a finished ride-v5 object's bytes into a GPX 1.1 document naming the track `name`.
*
* @throws {ConvertError} with an actionable message; see {@link ConvertErrorCode}.
*/
Expand Down
4 changes: 3 additions & 1 deletion builder/app/src/lib/device/library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,20 @@ function rideObject(name: string, startTime: number, points = 24): RideObject {
});
}
return {
version: 4,
version: 5,
name,
startTime,
distanceM: 12_340,
movingTimeS: 2_700,
avgSpeedCms: 457,
climbM: 96,
descentM: 96,
avgHr: 143,
maxHr: 171,
avgCadence: 80,
avgPower: null,
maxPower: null,
energyKj: null,
bikeType: 0,
trip: null,
points: track,
Expand Down
23 changes: 15 additions & 8 deletions builder/app/src/lib/device/rides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Two things are being decided here, and only one of them is "does the flow work".
*
* The first is **byte identity**: the GPX a visitor saves has to be the file the device itself would
* have written. The pinned pair is `specs/vectors/ride-v4.bin` → `track-export.gpx`, produced by the
* have written. The pinned pair is `specs/vectors/ride-v5.bin` → `track-export.gpx`, produced by the
* real `obc_route::track_to_gpx`, and the export path has to land on those exact bytes after a full
* round trip through the wire's ride object — with one documented exception the wire format makes
* unavoidable, asserted as *the only* exception rather than waved at.
Expand Down Expand Up @@ -94,18 +94,20 @@ function rideFromTrackLog(log: Uint8Array, name: string, startTime: number): Rid
});
}
return {
version: 4,
version: 5,
name,
startTime,
distanceM: 4210,
movingTimeS: 1_284,
avgSpeedCms: 328,
climbM: 118,
descentM: 118,
avgHr: 135,
maxHr: 138,
avgCadence: 78,
avgPower: 205,
maxPower: 240,
energyKj: 263,
bikeType: 0,
trip: null,
points,
Expand All @@ -128,18 +130,20 @@ function longRide(points: number): RideObject {
});
}
return {
version: 4,
version: 5,
name: "Long Way Round",
startTime: 1_783_598_400,
distanceM: points * 8,
movingTimeS: points,
avgSpeedCms: 800,
climbM: 900,
descentM: 900,
avgHr: 140,
maxHr: 160,
avgCadence: 80,
avgPower: 200,
maxPower: 400,
energyKj: points / 5,
bikeType: 0,
trip: null,
points: list,
Expand Down Expand Up @@ -185,7 +189,7 @@ function deviceWith(rides: RideObject[], options: LoopbackOptions & FlatDeviceOp

describe("the exported GPX", () => {
it("reproduces the native exporter byte-for-byte, pulled from the device", async () => {
const ride = { ...decodeRideObject(vector("ride-v4.bin")), name: TRACK_NAME };
const ride = { ...decodeRideObject(vector("ride-v5.bin")), name: TRACK_NAME };
const { entries, source, close } = deviceWith([ride]);
try {
// The catalog is what a rider picks from, so the export starts where they do.
Expand Down Expand Up @@ -221,21 +225,23 @@ describe("the exported GPX", () => {

describe("the ride object", () => {
it("decodes and re-encodes the cross-language vector byte-for-byte", () => {
const bytes = vector("ride-v4.bin");
const bytes = vector("ride-v5.bin");
const ride = decodeRideObject(bytes);
expect(ride).toMatchObject({
version: 4,
version: 5,
name: "Sensor Ride",
startTime: 1_751_460_000,
distanceM: 12_345,
movingTimeS: 3_600,
avgSpeedCms: 343,
climbM: 120,
descentM: 95,
avgHr: 142,
maxHr: 176,
avgCadence: 85,
avgPower: 210,
maxPower: 480,
energyKj: 756,
bikeType: 1,
trip: { key: 0x0123_4567_89ab_cdefn, dayIndex: 1, dayCount: 3, name: "Alpen Traverse" },
});
Expand Down Expand Up @@ -313,7 +319,8 @@ describe("when the export cannot finish", () => {
if (!armed || flipped || slice.length === 0) return slice;
flipped = true;
const damaged = slice.slice();
damaged[damaged.length - 1] ^= 0xff;
// The middle byte lies in the payload; the record's tail can be alignment padding.
damaged[damaged.length >> 1] ^= 0xff;
return damaged;
},
write: (bytes, signal) => stream.write(bytes, signal),
Expand Down Expand Up @@ -424,7 +431,7 @@ describe("when the export cannot finish", () => {
const { device, source, close } = deviceWith([]);
try {
const future = encodeRideObject(ride);
future[future.length - 140] = 5;
future[future.length - 146] = 6;
device.seed({ kind: ObjectKind.Ride, displayName: ride.name, bytes: future });
const failure = await exportRide(source, (await source.listRides())[0], context()).catch(
(e: unknown) => e,
Expand Down
Loading
Loading