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-v5 reader shared with firmware. The nRF54L records final ride bytes directly; this crate is a
# ride-v6 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 @@ -53,7 +53,7 @@ pub enum ErrorCode {
GpxNoTrackPoints,
/// The decimated route still exceeds [`MAX_STORED_POINTS`].
GpxTooManyPoints,
/// The bytes are not a finished ride-v5 object.
/// The bytes are not a finished ride-v6 object.
NotRide,
/// The finished ride carries no recorded points.
RideNoPoints,
Expand Down Expand Up @@ -116,7 +116,7 @@ pub fn gpx_to_obcr(gpx: &[u8], name: &str, bike: BikeType) -> Result<Vec<u8>, Co
Ok(sink.0)
}

/// Convert a finished ride-v5 object's bytes into a GPX 1.1 document named `name`.
/// Convert a finished ride-v6 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 @@ -322,7 +322,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-v5 object. Download the finished ride again; \
"These bytes are not one complete ride-v6 object. Download the finished ride again; \
unfinished sample logs and older ride formats are not accepted.",
),
Error::Io => ConvertFailure::new(
Expand Down Expand Up @@ -587,7 +587,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-v5"), "names the required format: {short}");
assert!(short.message.contains("ride-v6"), "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 @@ -49,7 +49,7 @@ mod web {
crate::convert::gpx_to_obcr(bytes, name, bike).map_err(to_js)
}

/// Convert a finished ride-v5 object into a GPX 1.1 document, naming the track `name`.
/// Convert a finished ride-v6 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
3 changes: 2 additions & 1 deletion builder/app/dev-harness/ride-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ function loopRide(
}
const distanceM = Math.round(radiusDeg * 111_000 * 2 * Math.PI * 1.2);
return {
version: 5,
version: 6,
effortLimits: { maxHrBpm: null, ftpW: null },
name,
startTime,
distanceM,
Expand Down
3 changes: 2 additions & 1 deletion builder/app/dev-harness/simulated-device.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,8 @@ function syntheticRide(name: string, startTime: number, points: number, sensors:
});
}
return {
version: 5,
version: 6,
effortLimits: { maxHrBpm: null, ftpW: null },
name,
startTime,
distanceM: points * 7,
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 @@ -115,7 +115,7 @@ describe("gpxToObcr", () => {

describe("trackToGpx", () => {
it("reproduces the native exporter's GPX byte-for-byte", async () => {
const gpx = await trackToGpx(vector("ride-v5.bin"), TRACK_NAME);
const gpx = await trackToGpx(vector("ride-v6.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 @@ -278,7 +278,7 @@ describe("failures", () => {
expect(notGpx.message).toMatch(/\.fit|\.tcx/);

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

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 @@ -112,7 +112,7 @@ export async function gpxToObcr(gpx: Uint8Array, name: string, bike: number): Pr
}

/**
* Convert a finished ride-v5 object's bytes into a GPX 1.1 document naming the track `name`.
* Convert a finished ride-v6 object's bytes into a GPX 1.1 document naming the track `name`.
*
* @throws {ConvertError} with an actionable message; see {@link ConvertErrorCode}.
*/
Expand Down
3 changes: 2 additions & 1 deletion builder/app/src/lib/device/library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ function rideObject(name: string, startTime: number, points = 24): RideObject {
});
}
return {
version: 5,
version: 6,
effortLimits: { maxHrBpm: null, ftpW: null },
name,
startTime,
distanceM: 12_340,
Expand Down
17 changes: 10 additions & 7 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-v5.bin` → `track-export.gpx`, produced by the
* have written. The pinned pair is `specs/vectors/ride-v6.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,7 +94,8 @@ function rideFromTrackLog(log: Uint8Array, name: string, startTime: number): Rid
});
}
return {
version: 5,
version: 6,
effortLimits: { maxHrBpm: null, ftpW: null },
name,
startTime,
distanceM: 4210,
Expand Down Expand Up @@ -130,7 +131,8 @@ function longRide(points: number): RideObject {
});
}
return {
version: 5,
version: 6,
effortLimits: { maxHrBpm: null, ftpW: null },
name: "Long Way Round",
startTime: 1_783_598_400,
distanceM: points * 8,
Expand Down Expand Up @@ -189,7 +191,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-v5.bin")), name: TRACK_NAME };
const ride = { ...decodeRideObject(vector("ride-v6.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 @@ -225,10 +227,10 @@ describe("the exported GPX", () => {

describe("the ride object", () => {
it("decodes and re-encodes the cross-language vector byte-for-byte", () => {
const bytes = vector("ride-v5.bin");
const bytes = vector("ride-v6.bin");
const ride = decodeRideObject(bytes);
expect(ride).toMatchObject({
version: 5,
version: 6,
name: "Sensor Ride",
startTime: 1_751_460_000,
distanceM: 12_345,
Expand All @@ -244,6 +246,7 @@ describe("the ride object", () => {
energyKj: 756,
bikeType: 1,
trip: { key: 0x0123_4567_89ab_cdefn, dayIndex: 1, dayCount: 3, name: "Alpen Traverse" },
effortLimits: { maxHrBpm: 185, ftpW: 250 },
});
expect(ride.points).toHaveLength(3);
expect(ride.points[0]).toMatchObject({
Expand Down Expand Up @@ -431,7 +434,7 @@ describe("when the export cannot finish", () => {
const { device, source, close } = deviceWith([]);
try {
const future = encodeRideObject(ride);
future[future.length - 146] = 6;
future[future.length - 150] = 7;
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
30 changes: 21 additions & 9 deletions builder/app/src/lib/usb/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ const NO_U8 = 0xff;
const NO_U16 = 0xffff;
const NO_U32 = 0xffffffff;
const RIDE_SAMPLE_LEN = 20;
const RIDE_FOOTER_LEN = 150;
const RIDE_FOOTER_LEN = 154;
const RIDE_NAME_CAP = 48;
const RIDE_NAME_AT = 42;
const RIDE_TRIP_AT = 90;
const RIDE_LIMITS_AT = 150;

/** One recorded point. Coordinates are degrees × 1e7 (a ~1 cm grid); `null` means the sensor was
* absent, dropped, or stale. */
Expand All @@ -65,9 +66,15 @@ export interface RideTrip {
name: string;
}

/** A downloaded v5 ride: the recorded sample bytes followed by one fixed summary footer. */
/** The rider's effort limits when the ride started (spec §7.2); `null` is not set. */
export interface RideEffortLimits {
maxHrBpm: number | null;
ftpW: number | null;
}

/** A downloaded v6 ride: the recorded sample bytes followed by one fixed summary footer. */
export interface RideObject {
version: 5;
version: 6;
name: string;
startTime: number;
distanceM: number;
Expand All @@ -85,11 +92,12 @@ export interface RideObject {
/** The bike type current at the start, `0..=3` (Road, Gravel, MTB, Touring). */
bikeType: number;
trip: RideTrip | null;
effortLimits: RideEffortLimits;
points: RidePoint[];
}

/**
* Decode the only ride-object format: verbatim 20-byte samples followed by the fixed 150-byte v5
* Decode the only ride-object format: verbatim 20-byte samples followed by the fixed 154-byte v6
* footer. The footer's point count determines the complete object length.
*/
export function decodeRideObject(data: Uint8Array): RideObject {
Expand All @@ -100,8 +108,9 @@ export function decodeRideObject(data: Uint8Array): RideObject {
throw new ObjectDecodeError("ride object has no OBRF footer.");
}
const version = data[footer + 4];
if (version !== 5) throw new ObjectDecodeError(`ride object version ${version}; this client decodes 5.`);
if (view.getUint16(footer + 6, true) !== RIDE_FOOTER_LEN || data[footer + 33] !== 0) {
if (version !== 6) throw new ObjectDecodeError(`ride object version ${version}; this client decodes 6.`);
const limits = footer + RIDE_LIMITS_AT;
if (view.getUint16(footer + 6, true) !== RIDE_FOOTER_LEN || data[footer + 33] !== 0 || data[limits + 1] !== 0) {
throw new ObjectDecodeError("ride object has a non-canonical summary footer.");
}
const name = footerName(data, footer + RIDE_NAME_AT, data[footer + 5]);
Expand Down Expand Up @@ -139,7 +148,7 @@ export function decodeRideObject(data: Uint8Array): RideObject {
}

return {
version: 5,
version: 6,
points,
name,
startTime: view.getUint32(footer + 8, true),
Expand All @@ -156,6 +165,7 @@ export function decodeRideObject(data: Uint8Array): RideObject {
energyKj: absent32(view.getUint32(footer + 38, true)),
bikeType,
trip: tripKey === 0n ? null : { key: tripKey, dayIndex, dayCount, name: tripName },
effortLimits: { maxHrBpm: data[limits] || null, ftpW: view.getUint16(limits + 2, true) || null },
};
}

Expand All @@ -171,7 +181,7 @@ function footerName(data: Uint8Array, at: number, len: number): string {
}
}

/** Encode a v5 object for the loopback device and byte-contract tests. */
/** Encode a v6 object for the loopback device and byte-contract tests. */
export function encodeRideObject(r: RideObject): Uint8Array {
const name = clippedUtf8(r.name, RIDE_NAME_CAP);
const footer = r.points.length * RIDE_SAMPLE_LEN;
Expand All @@ -188,7 +198,7 @@ export function encodeRideObject(r: RideObject): Uint8Array {
out[p + 17] = pt.cadenceRpm ?? NO_U8;
view.setUint16(p + 18, pt.powerW ?? NO_U16, true);
});
out.set([0x4f, 0x42, 0x52, 0x46, 5, name.length], footer);
out.set([0x4f, 0x42, 0x52, 0x46, 6, name.length], footer);
view.setUint16(footer + 6, RIDE_FOOTER_LEN, true);
view.setUint32(footer + 8, r.startTime, true);
view.setUint32(footer + 12, r.distanceM, true);
Expand All @@ -214,6 +224,8 @@ export function encodeRideObject(r: RideObject): Uint8Array {
out.set(tripName, trip + 12);
}
out[trip + 10] = r.bikeType;
out[footer + RIDE_LIMITS_AT] = r.effortLimits.maxHrBpm ?? 0;
view.setUint16(footer + RIDE_LIMITS_AT + 2, r.effortLimits.ftpW ?? 0, true);
return out;
}

Expand Down
7 changes: 7 additions & 0 deletions companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ public struct Ride: Identifiable, Equatable, Sendable {

public var id: RideID { summary.id }

/// The effort limits the ride's zones are taken from, never the rider's current settings.
public var zoneLimits: RideZoneLimits { summary.zoneLimits }

public init(summary: RideSummary, points: [RidePoint]) {
self.summary = summary
self.points = points
Expand Down Expand Up @@ -186,6 +189,8 @@ public struct RideSummary: Identifiable, Equatable, Sendable {
public var bikeType: BikeType
/// The trip day the ride started on, or nil.
public var trip: RideTrip?
/// The effort limits that were in force when the ride started.
public var zoneLimits: RideZoneLimits

public init(
id: RideID,
Expand All @@ -205,6 +210,7 @@ public struct RideSummary: Identifiable, Equatable, Sendable {
energyKJ: Int? = nil,
bikeType: BikeType = .road,
trip: RideTrip? = nil,
zoneLimits: RideZoneLimits = .notSet,
source: RideSource? = nil
) {
self.id = id
Expand All @@ -224,6 +230,7 @@ public struct RideSummary: Identifiable, Equatable, Sendable {
self.energyKJ = energyKJ
self.bikeType = bikeType
self.trip = trip
self.zoneLimits = zoneLimits
self.source = source
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ extension RideSummary {
climbMeters: climb, descentMeters: descent, trackPreview: TrackPreview.normalizing(points.map(\.coordinate)),
avgHeartRate: heartRate.average, maxHeartRate: heartRate.max, avgCadence: cadence.average,
avgPower: power.average, maxPower: power.max, energyKJ: joules.map { Int($0 / 1_000) },
bikeType: bikeType, trip: trip
bikeType: bikeType, trip: trip, zoneLimits: zoneLimits
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

/// The rider's effort limits that were in force when a ride started
/// (`specs/obc-ble-interface-spec.md` §7.2). A ride keeps them when the rider's settings change
/// later. Nil is not set on the device at ride time, and that metric then has no zones.
public struct RideZoneLimits: Equatable, Sendable, Codable {
public let maxHeartRate: Int?
public let ftpWatts: Int?

public init(maxHeartRate: Int?, ftpWatts: Int?) {
self.maxHeartRate = maxHeartRate
self.ftpWatts = ftpWatts
}

public static let notSet = RideZoneLimits(maxHeartRate: nil, ftpWatts: nil)

/// The zone index `0...4` (Z1...Z5) of a heart rate, or nil without a max heart rate.
public func heartRateZone(bpm: Int) -> Int? {
maxHeartRate.map { Self.zone(bpm, limit: $0, edges: Self.heartRateEdges) }
}

/// The zone index `0...4` (Z1...Z5) of a power, or nil without an FTP.
public func powerZone(watts: Int) -> Int? {
ftpWatts.map { Self.zone(watts, limit: $0, edges: Self.powerEdges) }
}

/// The Z2...Z5 edges in percent of the limit, and whether a value exactly on the edge is
/// already in the upper zone. The device's `effort.rs` holds the same table.
private static let heartRateEdges = [(60, true), (70, true), (80, true), (90, true)]
private static let powerEdges = [(55, true), (75, false), (90, false), (105, false)]

private static func zone(_ value: Int, limit: Int, edges: [(Int, Bool)]) -> Int {
let scaled = value * 100
return edges.filter { edge, onEdgeIsUpper in
onEdgeIsUpper ? scaled >= edge * limit : scaled > edge * limit
}.count
}
}
5 changes: 4 additions & 1 deletion companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ private struct RideDTO: Decodable {
let bikeType: String?
/// The trip day the ride started on; absent for a ride without a trip.
let trip: TripDayDTO?
/// The effort limits at the ride's start; absent for a ride recorded without them.
let zoneLimits: RideZoneLimits?

struct TripDayDTO: Decodable {
let key: UInt64
Expand All @@ -462,7 +464,8 @@ private struct RideDTO: Decodable {
movingTime: movingTime, averageSpeedMps: averageSpeedMps, climbMeters: climbMeters,
trackPreview: TrackPreview.normalizing(track.map(\.coordinate)),
bikeType: BikeType.allCases.first { $0.name.lowercased() == bikeType } ?? .road,
trip: trip.map { RideTrip(key: $0.key, dayIndex: $0.dayIndex, dayCount: $0.dayCount, name: $0.name) }
trip: trip.map { RideTrip(key: $0.key, dayIndex: $0.dayIndex, dayCount: $0.dayCount, name: $0.name) },
zoneLimits: zoneLimits ?? .notSet
)
// Fixture tracks carry no timestamps — synthesize them evenly across the
// moving time, so the encoded payload is a plausible recorded tracklog.
Expand Down
Loading
Loading