diff --git a/apps/obc-web-convert/Cargo.toml b/apps/obc-web-convert/Cargo.toml index 6b8fc28a3..524c0c2d4 100644 --- a/apps/obc-web-convert/Cargo.toml +++ b/apps/obc-web-convert/Cargo.toml @@ -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 diff --git a/apps/obc-web-convert/src/convert.rs b/apps/obc-web-convert/src/convert.rs index e8d6a25b6..69c85eefd 100644 --- a/apps/obc-web-convert/src/convert.rs +++ b/apps/obc-web-convert/src/convert.rs @@ -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, @@ -116,7 +116,7 @@ pub fn gpx_to_obcr(gpx: &[u8], name: &str, bike: BikeType) -> Result, 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 { @@ -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( @@ -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 diff --git a/apps/obc-web-convert/src/lib.rs b/apps/obc-web-convert/src/lib.rs index cfead77fb..5111803fb 100644 --- a/apps/obc-web-convert/src/lib.rs +++ b/apps/obc-web-convert/src/lib.rs @@ -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] diff --git a/builder/app/dev-harness/ride-library.ts b/builder/app/dev-harness/ride-library.ts index a484a59a1..b5cbb13ce 100644 --- a/builder/app/dev-harness/ride-library.ts +++ b/builder/app/dev-harness/ride-library.ts @@ -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, diff --git a/builder/app/dev-harness/simulated-device.svelte.ts b/builder/app/dev-harness/simulated-device.svelte.ts index e137a8079..de36bdfbe 100644 --- a/builder/app/dev-harness/simulated-device.svelte.ts +++ b/builder/app/dev-harness/simulated-device.svelte.ts @@ -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, diff --git a/builder/app/src/lib/convert/bridge.test.ts b/builder/app/src/lib/convert/bridge.test.ts index 0f75cfb63..3ee4ce210 100644 --- a/builder/app/src/lib/convert/bridge.test.ts +++ b/builder/app/src/lib/convert/bridge.test.ts @@ -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. @@ -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); diff --git a/builder/app/src/lib/convert/bridge.ts b/builder/app/src/lib/convert/bridge.ts index 4a90cbbd7..8854ff744 100644 --- a/builder/app/src/lib/convert/bridge.ts +++ b/builder/app/src/lib/convert/bridge.ts @@ -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}. */ diff --git a/builder/app/src/lib/device/library.test.ts b/builder/app/src/lib/device/library.test.ts index 5fcfbc387..418844ddc 100644 --- a/builder/app/src/lib/device/library.test.ts +++ b/builder/app/src/lib/device/library.test.ts @@ -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, diff --git a/builder/app/src/lib/device/rides.test.ts b/builder/app/src/lib/device/rides.test.ts index b6cbb88f9..cc2fe2612 100644 --- a/builder/app/src/lib/device/rides.test.ts +++ b/builder/app/src/lib/device/rides.test.ts @@ -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. @@ -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, @@ -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, @@ -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. @@ -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, @@ -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({ @@ -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, diff --git a/builder/app/src/lib/usb/objects.ts b/builder/app/src/lib/usb/objects.ts index ebf5d40e3..714d072dc 100644 --- a/builder/app/src/lib/usb/objects.ts +++ b/builder/app/src/lib/usb/objects.ts @@ -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. */ @@ -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; @@ -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 { @@ -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]); @@ -139,7 +148,7 @@ export function decodeRideObject(data: Uint8Array): RideObject { } return { - version: 5, + version: 6, points, name, startTime: view.getUint32(footer + 8, true), @@ -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 }, }; } @@ -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; @@ -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); @@ -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; } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift index 5b0014989..4fd182f2e 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift @@ -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 @@ -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, @@ -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 @@ -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 } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift index 068e38beb..f2975f982 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift @@ -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 ) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideZoneLimits.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideZoneLimits.swift new file mode 100644 index 000000000..d9d283d5a --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideZoneLimits.swift @@ -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 + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift index 15bfba88b..ffdcc6064 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift @@ -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 @@ -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. diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/default.json b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/default.json index c26d4cc90..13bb0ab2f 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/default.json +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/default.json @@ -132,6 +132,7 @@ "movingTime": 10260, "averageSpeedMps": 5.6667, "climbMeters": 812, + "zoneLimits": { "maxHeartRate": 185, "ftpWatts": 250 }, "track": [ { "lat": 42.8672, "lon": -88.4471, "ele": 264 }, { "lat": 42.8801, "lon": -88.4402, "ele": 288 }, @@ -169,6 +170,7 @@ "movingTime": 15120, "averageSpeedMps": 5.2222, "climbMeters": 1180, + "zoneLimits": { "maxHeartRate": 185, "ftpWatts": 250 }, "track": [ { "lat": 43.0289, "lon": -89.8412, "ele": 260 }, { "lat": 43.0512, "lon": -89.8004, "ele": 301 }, @@ -188,6 +190,7 @@ "movingTime": 3900, "averageSpeedMps": 6.3611, "climbMeters": 140, + "zoneLimits": { "maxHeartRate": 185, "ftpWatts": 250 }, "track": [ { "lat": 43.1142, "lon": -89.6487, "ele": 277 }, { "lat": 43.1358, "lon": -89.6102, "ele": 302 }, diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website.json b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website.json index 4cfb315fd..a36ba97a2 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website.json +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website.json @@ -533,6 +533,10 @@ "averageSpeedMps": 3.2774, "climbMeters": 274, "payloadBytes": 98060, + "zoneLimits": { + "maxHeartRate": 185, + "ftpWatts": 250 + }, "track": [ { "lat": 46.601847, diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift index 53612c6f8..02ba143de 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift @@ -1,11 +1,11 @@ import Foundation import OBCDomain -/// Ride object v5: verbatim 20-byte recorded samples followed by one fixed 150-byte `OBRF` footer. +/// Ride object v6: verbatim 20-byte recorded samples followed by one fixed 154-byte `OBRF` footer. public enum RideObjectCodec { - static let version: UInt8 = 5 + static let version: UInt8 = 6 static let sampleLength = 20 - static let footerLength = 150 + static let footerLength = 154 static let nameCapacity = 48 static let noSensorU8: UInt8 = 0xFF static let noSensorU16: UInt16 = 0xFFFF @@ -56,6 +56,9 @@ public enum RideObjectCodec { data.append(UInt8(tripName.count)) data.append(tripName) data.append(Data(repeating: 0, count: nameCapacity - tripName.count)) + data.append(limitU8(ride.zoneLimits.maxHeartRate)) + data.append(0) + data.appendLE(limitU16(ride.zoneLimits.ftpWatts)) return data } @@ -92,6 +95,11 @@ public enum RideObjectCodec { let dayCount = Int(try footer.u8()) guard let bikeType = BikeType(rawValue: try footer.u8()) else { throw DeviceError.readFailed } let tripName = try nameField(&footer, length: Int(try footer.u8())) + let maxHeartRateLimit = try footer.u8() + guard try footer.u8() == 0 else { throw DeviceError.readFailed } + let ftpLimit = try footer.u16() + let zoneLimits = RideZoneLimits(maxHeartRate: maxHeartRateLimit == 0 ? nil : Int(maxHeartRateLimit), + ftpWatts: ftpLimit == 0 ? nil : Int(ftpLimit)) let trip: RideTrip? if tripKey == 0 { guard dayIndex == 0, dayCount == 0, tripName.isEmpty else { throw DeviceError.readFailed } @@ -137,7 +145,7 @@ public enum RideObjectCodec { trackPreview: TrackPreview.normalizing(points.map(\.coordinate)), avgHeartRate: avgHR, maxHeartRate: maxHR, avgCadence: avgCadence, avgPower: avgPower, maxPower: maxPower, energyKJ: energy == noEnergy ? nil : Int(energy), - bikeType: bikeType, trip: trip) + bikeType: bikeType, trip: trip, zoneLimits: zoneLimits) return Ride(summary: summary, points: points) } @@ -167,6 +175,10 @@ public enum RideObjectCodec { return UInt16(clamping: Swift.min(value, Int(noSensorU16) - 1)) } + /// A limit on the wire: `0` is not set. + private static func limitU8(_ value: Int?) -> UInt8 { UInt8(clamping: value ?? 0) } + private static func limitU16(_ value: Int?) -> UInt16 { UInt16(clamping: value ?? 0) } + private static func optSensorU8(_ raw: UInt8) -> Int? { raw == noSensorU8 ? nil : Int(raw) } private static func optSensorU16(_ raw: UInt16) -> Int? { raw == noSensorU16 ? nil : Int(raw) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift index 13b5e61c2..2baa7efab 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift @@ -964,6 +964,8 @@ private struct RideSummaryDTO: Codable { var energyKJ: Int? var bikeType: UInt8 var trip: RideTripDTO? + // Optional, so a `summary.json` written before rides carried limits decodes as not set. + var zoneLimits: RideZoneLimits? init(_ summary: RideSummary) { source = summary.source @@ -984,6 +986,7 @@ private struct RideSummaryDTO: Codable { energyKJ = summary.energyKJ bikeType = summary.bikeType.rawValue trip = summary.trip.map(RideTripDTO.init) + zoneLimits = summary.zoneLimits } var domain: RideSummary { @@ -994,7 +997,8 @@ private struct RideSummaryDTO: Codable { trackPreview: preview?.domain, avgHeartRate: avgHeartRate, maxHeartRate: maxHeartRate, avgCadence: avgCadence, avgPower: avgPower, maxPower: maxPower, energyKJ: energyKJ, - bikeType: BikeType(rawValue: bikeType) ?? .road, trip: trip?.domain, source: source + bikeType: BikeType(rawValue: bikeType) ?? .road, trip: trip?.domain, + zoneLimits: zoneLimits ?? .notSet, source: source ) } } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/LibraryStoreTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/LibraryStoreTests.swift index 87fc7b923..f7fce1efb 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/LibraryStoreTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/LibraryStoreTests.swift @@ -63,7 +63,8 @@ final class LibraryStoreTests: XCTestCase { Coordinate(latitude: 47.1, longitude: 7.2), ]), bikeType: .touring, - trip: RideTrip(key: 42, dayIndex: 1, dayCount: 3, name: "Alpen Traverse") + trip: RideTrip(key: 42, dayIndex: 1, dayCount: 3, name: "Alpen Traverse"), + zoneLimits: RideZoneLimits(maxHeartRate: 185, ftpWatts: 250) ), points: [ RidePoint(timestamp: Date(timeIntervalSince1970: 2_000), @@ -241,6 +242,19 @@ final class LibraryStoreTests: XCTestCase { ) } + func testARideArchivedWithoutZoneLimitsReadsAsNotSet() throws { + let (store, dir) = makeFileStore() + try store.saveRide(makeRide()) + let file = dir.appendingPathComponent("rides/ride-1/summary.json") + var manifest = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]) + var summary = try XCTUnwrap(manifest["summary"] as? [String: Any]) + XCTAssertNotNil(summary.removeValue(forKey: "zoneLimits")) + manifest["summary"] = summary + try JSONSerialization.data(withJSONObject: manifest).write(to: file) + + XCTAssertEqual(FileLibraryStore(directory: dir).rideSummaries().first?.zoneLimits, .notSet) + } + func testRidePointsRoundTripAcrossInstances() throws { let (store, dir) = makeFileStore() let ride = makeRide() diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift index 0ec640ff7..76dc78612 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift @@ -3,7 +3,7 @@ import Testing import OBCDomain @testable import OBCTransport -/// The single cross-language ride-object contract: recorded 20-byte samples plus the v5 footer. +/// The single cross-language ride-object contract: recorded 20-byte samples plus the v6 footer. struct RideCodecVectorTests { private static let vectorsDir = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -15,9 +15,9 @@ struct RideCodecVectorTests { .appendingPathComponent("specs/vectors") private func vector() throws -> Data { - let url = Self.vectorsDir.appendingPathComponent("ride-v5.bin") + let url = Self.vectorsDir.appendingPathComponent("ride-v6.bin") return try #require(FileManager.default.contents(atPath: url.path), - "fixture ride-v5.bin missing at \(url.path)") + "fixture ride-v6.bin missing at \(url.path)") } @Test func decodesAndReencodesTheVectorExactly() throws { @@ -41,6 +41,7 @@ struct RideCodecVectorTests { #expect(summary.bikeType == .gravel) #expect(summary.trip == RideTrip(key: 0x0123_4567_89AB_CDEF, dayIndex: 1, dayCount: 3, name: "Alpen Traverse")) + #expect(ride.zoneLimits == RideZoneLimits(maxHeartRate: 185, ftpWatts: 250)) #expect(ride.points.count == 3) #expect(ride.points.map(\.segmentStart) == [true, false, true]) @@ -58,8 +59,9 @@ struct RideCodecVectorTests { @Test func rejectsBadFooterAndReservedSampleFlags() throws { let bytes = try vector() let footer = bytes.count - RideObjectCodec.footerLength - // Reserved byte, a day past the count, a bike type past the four, trip-name padding. - for (offset, value) in [(33, 1), (98, 3), (100, 4), (149, 1)] as [(Int, UInt8)] { + // Reserved byte, a day past the count, a bike type past the four, trip-name padding, and + // the reserved byte after the max heart rate limit. + for (offset, value) in [(33, 1), (98, 3), (100, 4), (149, 1), (151, 1)] as [(Int, UInt8)] { var badFooter = bytes badFooter[footer + offset] = value #expect(throws: (any Error).self) { diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideZoneLimitsTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideZoneLimitsTests.swift new file mode 100644 index 000000000..ca75997b4 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideZoneLimitsTests.swift @@ -0,0 +1,28 @@ +import Testing +import OBCDomain + +/// The zone edges mirror the device's `effort.rs` cases, so a ride shows the zones it showed on +/// the device. +struct RideZoneLimitsTests { + private let limits = RideZoneLimits(maxHeartRate: 200, ftpWatts: 200) + + @Test func heartRateEdgesShareTheirEndsUpward() { + // Max HR 200: Z2 from 120, Z3 from 140, Z4 from 160, Z5 from 180. + let zones = [119, 120, 139, 140, 159, 160, 179, 180].map { limits.heartRateZone(bpm: $0) } + #expect(zones == [0, 1, 1, 2, 2, 3, 3, 4]) + } + + @Test func powerEdgesAboveZ2AreExclusive() { + // FTP 200: Z2 from 55 %, Z3 above 75 %, Z4 above 90 %, Z5 above 105 %. + let zones = [109, 110, 150, 151, 180, 181, 210, 211].map { limits.powerZone(watts: $0) } + #expect(zones == [0, 1, 1, 2, 2, 3, 3, 4]) + #expect(RideZoneLimits(maxHeartRate: nil, ftpWatts: 250).powerZone(watts: 264) == 4, + "105.6 % is Z5, with no percent truncated away") + } + + @Test func aLimitNotSetHasNoZones() { + #expect(RideZoneLimits.notSet.heartRateZone(bpm: 150) == nil) + #expect(RideZoneLimits.notSet.powerZone(watts: 200) == nil) + #expect(RideZoneLimits(maxHeartRate: 185, ftpWatts: nil).heartRateZone(bpm: 140) == 2) + } +} diff --git a/companion-ios/scripts/generate-website-fixture.py b/companion-ios/scripts/generate-website-fixture.py index 83d075869..10c1a8c2b 100755 --- a/companion-ios/scripts/generate-website-fixture.py +++ b/companion-ios/scripts/generate-website-fixture.py @@ -148,6 +148,7 @@ def fixture( "averageSpeedMps": round(ride_distance / ride_moving, 4), "climbMeters": ride_climb, "payloadBytes": max(1, ride_distance * 20), + "zoneLimits": {"maxHeartRate": 185, "ftpWatts": 250}, "track": ride_compact, }], } diff --git a/docs/content/software/formats.md b/docs/content/software/formats.md index 4a0685c53..a86731cae 100644 --- a/docs/content/software/formats.md +++ b/docs/content/software/formats.md @@ -846,10 +846,10 @@ An unknown point pauses ascent integration instead of adding a false climb. See Finish append only - - 150-byte footer — totals · sensors · name · bike · trip + + 154-byte footer — totals · sensors · name · bike · trip · limits - + one commit — final length + CRC, RECORDING cleared @@ -860,8 +860,10 @@ An unknown point pauses ascent integration instead of adding a false climb. See A ride is a run of fixed-size samples with a summary footer appended at the end. Finalization does not rewrite the samples, so a long ride closes in constant time and an interrupted recording keeps everything written before the interruption. The footer also records the bike type and the trip day -that the ride started on, so the device and the phone group the rides of a trip without dates. The -byte contract is in [the BLE interface specification](src:specs/obc-ble-interface-spec.md). +that the ride started on, so the device and the phone group the rides of a trip without dates. It +keeps the rider's maximum heart rate and FTP from the start too, so a ride keeps its effort zones +when the rider changes those settings later. The byte contract is in +[the BLE interface specification](src:specs/obc-ble-interface-spec.md). ## OBCT — the terrain raster diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 0dee7bbe9..55b4bb948 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1095,10 +1095,14 @@ impl App { self.catalogs.trips() } - /// What a ride that starts now records: the current bike type, and the trip day when the - /// loaded route is one. + /// What a ride that starts now records: the current bike type and effort limits, and the trip + /// day when the loaded route is one. pub(crate) fn ride_origin(&self) -> crate::RideOrigin { - crate::RideOrigin { bike: self.settings.bike_type, trip: self.loaded_trip_day() } + crate::RideOrigin { + bike: self.settings.bike_type, + trip: self.loaded_trip_day(), + limits: self.settings.effort_limits(), + } } /// The trip day of the loaded route. A day built from the rest of the day before counts as the @@ -6243,6 +6247,7 @@ mod tests { app.recorder.set_origin(crate::RideOrigin { bike: obc_formats::bike::BikeType::Road, trip: obc_formats::ride::TripRef::new(42, 2, 3), + ..Default::default() }); let record = finish_at(&mut app, 25_000); assert_eq!((record.day, record.day_route.id, record.metres, record.last_finished), (2, 30, 8_000, Some(2))); @@ -6256,7 +6261,11 @@ mod tests { assert_eq!(app.loaded_trip_day(), None, "without a ride, an internal route is no trip day"); app.test_start_ride(); let day3 = obc_formats::ride::TripRef::new(42, 2, 3); - app.recorder.set_origin(crate::RideOrigin { bike: obc_formats::bike::BikeType::Road, trip: day3 }); + app.recorder.set_origin(crate::RideOrigin { + bike: obc_formats::bike::BikeType::Road, + trip: day3, + ..Default::default() + }); assert_eq!(app.loaded_trip_day(), day3); assert_eq!(app.trip_later_m(), Some(0), "Day 3 is the last day"); } @@ -6333,6 +6342,7 @@ mod tests { app.recorder.set_origin(crate::RideOrigin { bike: obc_formats::bike::BikeType::Road, trip: obc_formats::ride::TripRef::new(42, 1, 3), + ..Default::default() }); finish_at(&mut app, 54_000); let stats = app.recorder.ride_stats(); @@ -6353,6 +6363,7 @@ mod tests { app.recorder.set_origin(crate::RideOrigin { bike: obc_formats::bike::BikeType::Road, trip: obc_formats::ride::TripRef::new(42, 2, 3), + ..Default::default() }); let stats = app.recorder.ride_stats(); app.land_day_done(1, &stats); diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index ecdc28659..3662e3bed 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -1041,7 +1041,11 @@ mod tests { app.activity.mode = Mode::Riding; app.test_start_ride(); let trip = day.and_then(|day| obc_formats::ride::TripRef::new(42, day, 2)); - app.recorder.set_origin(crate::RideOrigin { bike: obc_formats::bike::BikeType::Road, trip }); + app.recorder.set_origin(crate::RideOrigin { + bike: obc_formats::bike::BikeType::Road, + trip, + ..Default::default() + }); // Finish on the Paused page. let paused = crate::screen::Transition::Push(Screen::RideControl(crate::screen::RideControl::new())); crate::screen::apply(&mut app.ui.stack, paused); diff --git a/firmware/obc-app/src/effort.rs b/firmware/obc-app/src/effort.rs index e7b06d494..b30981950 100644 --- a/firmware/obc-app/src/effort.rs +++ b/firmware/obc-app/src/effort.rs @@ -7,6 +7,7 @@ //! Zones are indices `0..=4` for Z1..Z5. Without a limit (max HR or FTP of 0) nothing has a zone. //! The limits are the rider's settings and are passed in, never copied here. +pub use obc_formats::ride::EffortLimits; use obc_route::POWER_STEP_W; /// Bars in a history graph: five minutes of 5 s buckets. @@ -67,6 +68,15 @@ impl Metric { } } + /// This metric's limit, or `None` when it is not set. + pub fn limit(self, limits: EffortLimits) -> Option { + let v = match self { + Metric::Hr => limits.max_hr as u32, + Metric::Power => limits.ftp_w as u32, + }; + (v > 0).then_some(v) + } + /// The zone of `value` against `limit`, with no hysteresis. pub fn zone_of(self, value: u32, limit: u32) -> u8 { let v = value * 100; @@ -120,23 +130,6 @@ impl Metric { } } -/// The rider's two limits, as the settings store them: `0` is not set. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct Limits { - pub max_hr: u8, - pub ftp_w: u16, -} - -impl Limits { - pub fn of(self, m: Metric) -> Option { - let v = match m { - Metric::Hr => self.max_hr as u32, - Metric::Power => self.ftp_w as u32, - }; - (v > 0).then_some(v) - } -} - /// A live value and its zone. The zone is `None` without a limit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Reading { @@ -153,9 +146,9 @@ pub struct Gauge { /// The map gauge for a `w` px wide panel: power when the meter is live and FTP is set, otherwise /// heart rate when the strap is live and max HR is set, otherwise none. -pub fn gauge(power: Option, hr: Option, limits: Limits, w: i32) -> Option { +pub fn gauge(power: Option, hr: Option, limits: EffortLimits, w: i32) -> Option { let (m, r, limit) = - [(Metric::Power, power), (Metric::Hr, hr)].into_iter().find_map(|(m, r)| Some((m, r?, limits.of(m)?)))?; + [(Metric::Power, power), (Metric::Hr, hr)].into_iter().find_map(|(m, r)| Some((m, r?, m.limit(limits)?)))?; let slot = (w / 5).max(0); let fill = (m.position(r.value as u32, limit) * slot as f32) as i32; Some(Gauge { zone: r.zone?, fill_px: fill.clamp(0, 5 * slot) as u16 }) @@ -246,11 +239,11 @@ impl Effort { /// Once per pass: scroll the history to `now_ms`, so the graph moves through a dropout, and /// step each zone to the latest value against the rider's limits. /// `hr` and `power` are the latest values, `None` before a metric's first sample. - pub(crate) fn advance(&mut self, now_ms: u32, limits: Limits, hr: Option, power: Option) { + pub(crate) fn advance(&mut self, now_ms: u32, limits: EffortLimits, hr: Option, power: Option) { self.roll(now_ms); for (m, value) in [(Metric::Hr, hr), (Metric::Power, power)] { let shown = Some(self.zone[m as usize]).filter(|&z| z != NO_ZONE); - self.zone[m as usize] = match (value, limits.of(m)) { + self.zone[m as usize] = match (value, m.limit(limits)) { (Some(v), Some(l)) => m.step(shown, v as u32, l), _ => NO_ZONE, }; @@ -325,7 +318,7 @@ pub(crate) fn sanitize_ftp(v: &mut u16) { mod tests { use super::*; - const LIMITS: Limits = Limits { max_hr: 200, ftp_w: 200 }; + const LIMITS: EffortLimits = EffortLimits { max_hr: 200, ftp_w: 200 }; #[test] fn zone_edges_follow_the_table() { @@ -354,14 +347,14 @@ mod tests { assert_eq!(hr(&mut e, 159, 3_000), Some(3), "1 bpm under the edge holds Z4"); assert_eq!(hr(&mut e, 157, 4_000), Some(2), "past the margin, it drops"); assert_eq!(hr(&mut e, 200, 5_000), Some(4), "a jump crosses several zones at once"); - e.advance(5_000, Limits { max_hr: 0, ftp_w: 200 }, Some(200), None); + e.advance(5_000, EffortLimits { max_hr: 0, ftp_w: 200 }, Some(200), None); assert_eq!(e.zone(Metric::Hr), None, "no max HR, no zone"); } #[test] fn the_first_power_reading_takes_its_zone_outright() { let mut e = Effort::new(); - let ftp = Limits { max_hr: 0, ftp_w: 250 }; + let ftp = EffortLimits { max_hr: 0, ftp_w: 250 }; e.advance(0, ftp, None, None); assert_eq!(e.zone(Metric::Power), None, "no sample yet, no zone"); e.sample(Metric::Power, 190, 1_000); @@ -411,7 +404,7 @@ mod tests { assert_eq!(gauge(power, hr, LIMITS, w), Some(Gauge { zone: 3, fill_px: fill })); assert_eq!(gauge(None, hr, LIMITS, w), Some(Gauge { zone: 2, fill_px: 2 * 48 + 24 }), "stale power: HR"); assert_eq!(gauge(None, None, LIMITS, w), None, "no live sensor, no gauge"); - let no_ftp = Limits { max_hr: 0, ftp_w: 0 }; + let no_ftp = EffortLimits { max_hr: 0, ftp_w: 0 }; assert_eq!(gauge(power, None, no_ftp, w), None, "live power without FTP draws no gauge"); assert_eq!(gauge(None, reading(90, 0), LIMITS, w).unwrap().fill_px, 0, "below the span is empty"); assert_eq!(gauge(None, reading(220, 4), LIMITS, w).unwrap().fill_px, 240, "above it is full"); diff --git a/firmware/obc-app/src/recorder.rs b/firmware/obc-app/src/recorder.rs index 085db95fe..82bfd784a 100644 --- a/firmware/obc-app/src/recorder.rs +++ b/firmware/obc-app/src/recorder.rs @@ -21,7 +21,7 @@ use obc_route::RideStats; use crate::altitude::AltitudeFusion; use crate::breadcrumb::Breadcrumb; use crate::device_core::{OperationToken, RecorderCapabilities, RecorderTag, TokenSource}; -use crate::effort::{Effort, Gauge, Limits, Metric, Reading}; +use crate::effort::{Effort, EffortLimits, Gauge, Metric, Reading}; use crate::placement::define_placement_constructors; use crate::CatalogObjectId; @@ -76,11 +76,13 @@ struct Motion { segment_start: bool, } -/// What a ride records about its start: the current bike type and the trip day it started on. +/// What a ride records about its start: the current bike type, the trip day it started on and the +/// rider's effort limits. A later settings change does not reach a ride already started. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct RideOrigin { pub bike: BikeType, pub trip: Option, + pub limits: EffortLimits, } /// The state that must cross a reset when a journaled ride is continued. @@ -184,7 +186,7 @@ pub enum RecorderError { /// this domain may attempt — a catalog it could not read completely is not one it may mutate. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RideDamage { - /// The recovered bytes are not a ride-v5 sample/footer boundary. + /// The recovered bytes are not a ride sample/footer boundary. Payload, /// The recovered samples carry no decodable continuation image. Metadata, @@ -539,7 +541,7 @@ impl RecorderMachine { cadence_ms: 0, energy_j: None, effort: Effort::new(), - origin: RideOrigin { bike: BikeType::Road, trip: None }, + origin: RideOrigin { bike: BikeType::Road, trip: None, limits: EffortLimits { max_hr: 0, ftp_w: 0 } }, } ); @@ -888,7 +890,7 @@ impl RecorderMachine { } /// Scroll the effort history to this pass and zone the latest values against the limits. - pub(crate) fn advance_effort(&mut self, limits: Limits) { + pub(crate) fn advance_effort(&mut self, limits: EffortLimits) { let power = self.power_last.map(|_| self.effort.power()); self.effort.advance(self.sensor_now_ms, limits, self.hr_last, power); } @@ -1144,7 +1146,7 @@ impl RecorderMachine { } /// The map's effort gauge for a `w` px wide panel, or `None` when no band shows. - pub fn gauge(&self, limits: Limits, w: i32) -> Option { + pub fn gauge(&self, limits: EffortLimits, w: i32) -> Option { crate::effort::gauge(self.reading(Metric::Power), self.reading(Metric::Hr), limits, w) } @@ -1213,6 +1215,7 @@ impl RecorderMachine { max_power: self.max_power(), energy_kj: self.kj(), bike: self.origin.bike, + limits: self.origin.limits, trip: self.origin.trip, trip_name: Name::EMPTY, } @@ -2346,7 +2349,11 @@ mod tests { #[test] fn a_recovered_continuation_restores_the_raw_summary_state() { let state = RideContinuation { - origin: RideOrigin { bike: BikeType::Mtb, trip: TripRef::new(9, 1, 3) }, + origin: RideOrigin { + bike: BikeType::Mtb, + trip: TripRef::new(9, 1, 3), + limits: EffortLimits { max_hr: 185, ftp_w: 250 }, + }, ridden_m: 12_345.5, moving_m: 12_000.25, moving_s: 2_400.0, @@ -2370,6 +2377,11 @@ mod tests { assert_eq!((rec.ride_stats().energy_kj, rec.ride_stats().descent_m), (Some(19), 123), "and reach the footer"); assert_eq!(rec.climb_m(), 321.0); assert_eq!((rec.ride_stats().bike, rec.ride_stats().trip), (BikeType::Mtb, TripRef::new(9, 1, 3))); + assert_eq!( + rec.ride_stats().limits, + EffortLimits { max_hr: 185, ftp_w: 250 }, + "a resumed ride keeps its limits" + ); } #[test] diff --git a/firmware/obc-app/src/recorder/continuation.rs b/firmware/obc-app/src/recorder/continuation.rs index 3a9748b15..a5f6fdd13 100644 --- a/firmware/obc-app/src/recorder/continuation.rs +++ b/firmware/obc-app/src/recorder/continuation.rs @@ -1,11 +1,11 @@ //! The fixed continuation payload stored with a ride checkpoint. use obc_formats::bike::BikeType; -use obc_formats::ride::TripRef; +use obc_formats::ride::{EffortLimits, TripRef}; pub const RIDE_RESUME_LEN: usize = 96; const RESUME_MAGIC: [u8; 4] = *b"OBRC"; -const RESUME_VERSION: u16 = 3; +const RESUME_VERSION: u16 = 4; pub fn encode(state: super::RideContinuation, start_time: Option) -> [u8; RIDE_RESUME_LEN] { const _: () = assert!(RIDE_RESUME_LEN == 96); @@ -28,6 +28,8 @@ pub fn encode(state: super::RideContinuation, start_time: Option) -> [u8; R out[64..72].copy_from_slice(&state.cadence_ms_sum.to_le_bytes()); out[72..76].copy_from_slice(&state.cadence_ms.to_le_bytes()); out[76] = u8::from(start_time.is_some()); + out[77] = state.origin.limits.max_hr; + out[78..80].copy_from_slice(&state.origin.limits.ftp_w.to_le_bytes()); if let Some(trip) = state.origin.trip { out[80..88].copy_from_slice(&trip.key().to_le_bytes()); out[88] = trip.day_index(); @@ -48,7 +50,6 @@ pub fn decode(bytes: &[u8; RIDE_RESUME_LEN]) -> Option<(super::RideContinuation, || bytes[46..48].iter().any(|byte| *byte != 0) || bytes[62..64].iter().any(|byte| *byte != 0) || bytes[76] > 1 - || bytes[77..80].iter().any(|byte| *byte != 0) || bytes[91] > 1 || (bytes[91] == 0 && bytes[92..].iter().any(|byte| *byte != 0)) { @@ -59,7 +60,8 @@ pub fn decode(bytes: &[u8; RIDE_RESUME_LEN]) -> Option<(super::RideContinuation, if trip.is_none() && (key != 0 || bytes[88] != 0 || bytes[89] != 0) { return None; } - let origin = super::RideOrigin { bike: BikeType::from_u8(bytes[90])?, trip }; + let limits = EffortLimits { max_hr: bytes[77], ftp_w: u16::from_le_bytes([bytes[78], bytes[79]]) }; + let origin = super::RideOrigin { bike: BikeType::from_u8(bytes[90])?, trip, limits }; let f32_at = |at: usize| { let mut raw = [0u8; 4]; raw.copy_from_slice(&bytes[at..at + 4]); @@ -100,6 +102,7 @@ mod tests { origin: super::super::RideOrigin { bike: BikeType::Touring, trip: TripRef::new(0x0102_0304_0506_0708, 2, 5), + limits: EffortLimits { max_hr: 185, ftp_w: 0x0102 }, }, ridden_m: 1.0, moving_m: 2.0, @@ -117,17 +120,17 @@ mod tests { energy_j: Some(14), }; let bytes = encode(state, Some(0x01020304)); - assert_eq!(&bytes[..16], &[79, 66, 82, 67, 3, 0, 96, 0, 4, 3, 2, 1, 0, 0, 128, 63]); + assert_eq!(&bytes[..16], &[79, 66, 82, 67, 4, 0, 96, 0, 4, 3, 2, 1, 0, 0, 128, 63]); assert_eq!(&bytes[32..48], &[6, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 8, 0, 0, 0]); assert_eq!(&bytes[48..64], &[9, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 11, 0, 0, 0]); - assert_eq!(&bytes[64..77], &[12, 0, 0, 0, 0, 0, 0, 0, 13, 0, 0, 0, 1]); + assert_eq!(&bytes[64..80], &[12, 0, 0, 0, 0, 0, 0, 0, 13, 0, 0, 0, 1, 185, 2, 1]); assert_eq!(&bytes[80..96], &[8, 7, 6, 5, 4, 3, 2, 1, 2, 5, 3, 1, 14, 0, 0, 0]); assert_eq!(decode(&bytes), Some((state, Some(0x01020304)))); assert_eq!(decode(&encode(state, None)), Some((state, None))); let no_trip = super::super::RideContinuation { origin: super::super::RideOrigin::default(), energy_j: None, ..state }; assert_eq!(decode(&encode(no_trip, None)), Some((no_trip, None))); - for at in [0, 4, 6, 46, 62, 77] { + for at in [0, 4, 6, 46, 62] { let mut invalid = bytes; invalid[at] ^= 1; assert!(decode(&invalid).is_none()); diff --git a/firmware/obc-app/src/ride.rs b/firmware/obc-app/src/ride.rs index a43d39f9b..d8210effc 100644 --- a/firmware/obc-app/src/ride.rs +++ b/firmware/obc-app/src/ride.rs @@ -8,7 +8,7 @@ use heapless::String; use obc_formats::obcr::NAME_CAP; -use obc_formats::ride::TripRef; +use obc_formats::ride::{EffortLimits, TripRef}; use obc_route::RideInfo; pub const MAX_RIDES: usize = 128; pub const UI_RIDES_CAP: usize = 32; @@ -31,7 +31,7 @@ const _: () = assert!( // The board holds `UI_RIDES_CAP` entries resident; a field that grows one costs 32 times its size. #[cfg(target_pointer_width = "32")] -const _: () = assert!(core::mem::size_of::() == 112); +const _: () = assert!(core::mem::size_of::() == 120); /// A stored ride's header facts for the Rides screen, plus the device-local `synced` flag the /// unsynced-delete guard keys on. @@ -54,6 +54,9 @@ pub struct RideSummary { pub avg_cadence: Option, pub avg_power: Option, pub energy_kj: Option, + /// The effort limits in force when the ride started. The detail's zones come from these, + /// never from the current settings. + pub limits: EffortLimits, } impl RideSummary { @@ -72,6 +75,7 @@ impl RideSummary { avg_cadence: info.avg_cadence, avg_power: info.avg_power, energy_kj: info.energy_kj, + limits: info.limits, } } } diff --git a/firmware/obc-app/src/screen/map.rs b/firmware/obc-app/src/screen/map.rs index d56b7d781..c4bd998e3 100644 --- a/firmware/obc-app/src/screen/map.rs +++ b/firmware/obc-app/src/screen/map.rs @@ -938,7 +938,7 @@ pub(crate) const GAUGE_H: i32 = 14; /// the bottom edge. The one home of that rule, so the band drawn and the render key cannot disagree. pub(crate) fn gauge_cue( recorder: &crate::recorder::RecorderMachine, - limits: crate::effort::Limits, + limits: crate::effort::EffortLimits, panning: bool, w: i32, ) -> Option { diff --git a/firmware/obc-app/src/screen/ride_detail.rs b/firmware/obc-app/src/screen/ride_detail.rs index 0c11b0b66..236e9a2a8 100644 --- a/firmware/obc-app/src/screen/ride_detail.rs +++ b/firmware/obc-app/src/screen/ride_detail.rs @@ -238,7 +238,7 @@ fn map_band(w: i32) -> Rectangle { fn sensor_page(cv: &mut impl Surface, rx: &RenderFrame<'_, '_>, ride: &RideSummary) { use palette::*; let w = rx.w; - let limits = rx.settings.effort_limits(); + let limits = ride.limits; let avg = rx.t(Msg::TileAvg); let facts = rx.ride_facts.unwrap_or(&RideTrackFacts::EMPTY); let mut y = GRAPH_TOP; @@ -250,11 +250,11 @@ fn sensor_page(cv: &mut impl Surface, rx: &RenderFrame<'_, '_>, ride: &RideSumma if let Some(hr) = ride.avg_hr { let series = facts.hr().iter().map(|&v| u16::from(v)); let caption = caption(avg, rx.t(Msg::TileHr)); - ride_graph(cv, area(), &caption, hr.into(), series, Metric::Hr, limits.of(Metric::Hr)); + ride_graph(cv, area(), &caption, hr.into(), series, Metric::Hr, Metric::Hr.limit(limits)); } if let Some(power) = ride.avg_power { let caption = caption(avg, rx.t(Msg::TilePwrShort)); - ride_graph(cv, area(), &caption, power, facts.power(), Metric::Power, limits.of(Metric::Power)); + ride_graph(cv, area(), &caption, power, facts.power(), Metric::Power, Metric::Power.limit(limits)); } let mut row = y + 2; let mut ledger = |caption: &str, value: heapless::String<8>| { @@ -426,4 +426,44 @@ mod tests { assert!(matches!(t, Transition::None)); assert_eq!(act.take_ride_delete(), None); } + + /// The sensor page colours its zones with the ride's own limits: a later settings change does + /// not recolour it, and a ride recorded without limits draws no zones. + #[test] + fn the_sensor_page_zones_come_from_the_ride_not_the_settings() { + use crate::harness::support::{build_min_obcm, Buf}; + use crate::screen::{apply, Screen}; + use embedded_graphics::pixelcolor::Rgb888; + use obc_formats::ride::EffortLimits; + use obc_reader::{MapCache, MapTables, Reader, SliceSource}; + + let bytes = build_min_obcm(0); + let (cache, src) = (MapCache::new(), SliceSource(&bytes)); + let tables = MapTables::parse(&src).unwrap(); + let reader = Reader::new(&src, &tables, &cache); + let sensor_page = |ride: EffortLimits, settings: EffortLimits| { + let mut app = crate::App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings { max_hr: settings.max_hr, ftp_w: settings.ftp_w, ..Settings::default() }); + let mut entry = summary("Sensor Ride"); + (entry.summary.avg_hr, entry.summary.avg_power, entry.summary.limits) = (Some(150), Some(200), ride); + app.set_rides(&[entry], &[]); + apply(&mut app.ui.stack, Transition::Push(Screen::RideDetail(RideDetailScreen::new(0)))); + app.apply_gesture(Gesture::Step(2)); + let mut buf = Buf::new(240, 320); + let mut scratch = Box::new(obc_render::RenderScratch::new()); + app.render_frame(Some(&mut scratch), &mut buf, &reader, None, 240.0, 320.0, |c| { + let (r, g, b) = obc_reader::rgb565_to_rgb888(c); + Rgb888::new(r, g, b) + }); + buf.px + }; + + let recorded = EffortLimits { max_hr: 185, ftp_w: 250 }; + let later = EffortLimits { max_hr: 150, ftp_w: 300 }; + let none = EffortLimits::default(); + let zoned = sensor_page(recorded, recorded); + assert!(sensor_page(recorded, later) == zoned, "a settings change does not recolour the ride"); + assert!(sensor_page(none, recorded) != zoned, "the ride's own limits draw its zones"); + assert!(sensor_page(none, recorded) == sensor_page(none, none), "a ride without limits has no zones"); + } } diff --git a/firmware/obc-app/src/screen/statistics.rs b/firmware/obc-app/src/screen/statistics.rs index 916373d05..86a8db215 100644 --- a/firmware/obc-app/src/screen/statistics.rs +++ b/firmware/obc-app/src/screen/statistics.rs @@ -338,7 +338,7 @@ impl StatisticsScreen { // the caption, so it has its own drawer. An effort tile with a zone takes its tint. match (placed.field.graph(), placed.field.category(), cell.zone) { (Some(m), ..) => { - let limit = rx.settings.effort_limits().of(m); + let limit = m.limit(rx.settings.effort_limits()); let history = rx.recorder.effort().history(m); graph_field(cv, area, GraphBlock::Live, &cell.caption, &cell.value, cell.zone, history, m, limit); } diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index a18f16432..83b1a9e1d 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -523,8 +523,8 @@ impl Settings { } } - pub(crate) fn effort_limits(&self) -> crate::effort::Limits { - crate::effort::Limits { max_hr: self.max_hr, ftp_w: self.ftp_w } + pub(crate) fn effort_limits(&self) -> crate::effort::EffortLimits { + crate::effort::EffortLimits { max_hr: self.max_hr, ftp_w: self.ftp_w } } /// The local wall-clock set-point the device shows: the UTC [`clock`](Settings::clock) anchor diff --git a/firmware/obc-app/tests/cases/ride_recovery.rs b/firmware/obc-app/tests/cases/ride_recovery.rs index c5dd98126..268654c9e 100644 --- a/firmware/obc-app/tests/cases/ride_recovery.rs +++ b/firmware/obc-app/tests/cases/ride_recovery.rs @@ -5,12 +5,16 @@ use obc_app::device_core::{ExternalFacts, OutcomeSlots}; use obc_app::recorder::{RecorderEffect, RecorderError, RecorderOutcome}; use obc_app::{App, AppState, Gesture, Mode, RideContinuation, RideDamage, RideOrigin, Screen, TripInput}; use obc_formats::bike::BikeType; -use obc_formats::ride::TripRef; +use obc_formats::ride::{EffortLimits, TripRef}; use obc_ports::{RideClock, Sensors}; fn continuation() -> RideContinuation { RideContinuation { - origin: RideOrigin { bike: BikeType::Touring, trip: TripRef::new(1, 1, 2) }, + origin: RideOrigin { + bike: BikeType::Touring, + trip: TripRef::new(1, 1, 2), + limits: EffortLimits { max_hr: 185, ftp_w: 250 }, + }, ridden_m: 12_345.0, moving_m: 12_000.0, moving_s: 2_700.0, @@ -53,9 +57,9 @@ fn continue_preserves_restored_totals_through_the_first_tick() { app.set_trips(&[TripInput { id: 5, key: 1, name: "Alps", start_date: 0, stage_ids: &[] }]); let stats = app.ride_stats(); assert_eq!( - (stats.bike, stats.trip, stats.trip_name.as_str()), - (BikeType::Touring, TripRef::new(1, 1, 2), "Alps"), - "the continued ride keeps its trip day and names the trip at save" + (stats.bike, stats.trip, stats.trip_name.as_str(), stats.limits), + (BikeType::Touring, TripRef::new(1, 1, 2), "Alps", EffortLimits { max_hr: 185, ftp_w: 250 }), + "the continued ride keeps its trip day and its limits, and names the trip at save" ); } diff --git a/firmware/obc-app/tests/cases/trips.rs b/firmware/obc-app/tests/cases/trips.rs index 1c9c38dad..ee47a2e26 100644 --- a/firmware/obc-app/tests/cases/trips.rs +++ b/firmware/obc-app/tests/cases/trips.rs @@ -155,30 +155,39 @@ fn reresolves_across_a_route_rescan() { } } -/// A ride records the bike type that is current at its start, and the trip day when the loaded -/// route is a day route. A ride on a loose route records no trip. +/// A ride records the bike type and the effort limits that are current at its start, and the trip +/// day when the loaded route is a day route. A ride on a loose route records no trip. Limits that +/// are not set record as 0, and a settings change after the start does not reach the ride. #[test] -fn a_ride_records_its_trip_day_and_bike_type() { +fn a_ride_records_its_trip_day_bike_type_and_limits() { use obc_app::{RecorderIntent, Settings}; - use obc_formats::{bike::BikeType, ride::TripRef}; + use obc_formats::{ + bike::BikeType, + ride::{EffortLimits, TripRef}, + }; - let start_on = |route: usize| { + let start_on = |route: usize, max_hr: u8, ftp_w: u16| { let mut app = app_with_three_routes(); app.set_trips(&[TripInput { id: 1, key: 42, name: "Alpen Traverse", start_date: 0, stage_ids: &[7, 8] }]); - app.set_settings(Settings { bike_type: BikeType::Gravel, ..Settings::default() }); + let settings = Settings { bike_type: BikeType::Gravel, max_hr, ftp_w, ..Settings::default() }; + app.set_settings(settings); crate::common::mount_store(&mut app); app.activate_route(route); app.recorder.request(RecorderIntent::Start); crate::common::quiet_pass(&mut app, 1); assert!(app.recording()); + app.set_settings(Settings { max_hr: 150, ftp_w: 300, ..settings }); + crate::common::quiet_pass(&mut app, 1); let stats = app.ride_stats(); - (stats.bike, stats.trip, stats.trip_name) + (stats.bike, stats.trip, stats.trip_name, stats.limits) }; - let (bike, trip, name) = start_on(1); + let (bike, trip, name, limits) = start_on(1, 185, 250); assert_eq!((bike, trip, name.as_str()), (BikeType::Gravel, TripRef::new(42, 1, 2), "Alpen Traverse")); - let (bike, trip, name) = start_on(2); + assert_eq!(limits, EffortLimits { max_hr: 185, ftp_w: 250 }, "the limits at the start, not the later ones"); + let (bike, trip, name, limits) = start_on(2, 0, 0); assert_eq!((bike, trip, name.as_str()), (BikeType::Gravel, None, "")); + assert_eq!(limits, EffortLimits::default(), "limits not set at the start stay not set"); } /// Ride the route at catalog index `route` over a store that takes every write, and save the ride diff --git a/firmware/obc-formats/src/ride.rs b/firmware/obc-formats/src/ride.rs index 9e722d50d..f90f1d34c 100644 --- a/firmware/obc-formats/src/ride.rs +++ b/firmware/obc-formats/src/ride.rs @@ -1,4 +1,4 @@ -//! Recorded-ride v5: verbatim 20-byte samples followed by one fixed summary footer. +//! Recorded-ride v6: verbatim 20-byte samples followed by one fixed summary footer. //! //! A recording appends [`crate::track::RECORD_LEN`]-byte samples directly to its final object. //! Finalize appends [`FOOTER_LEN`] bytes once. There is no leading header and no point rewrite: @@ -12,8 +12,8 @@ use crate::bike::BikeType; use crate::io::DecodeError; pub const MAGIC: [u8; 4] = *b"OBRF"; -pub const VERSION: u8 = 5; -pub const FOOTER_LEN: usize = 150; +pub const VERSION: u8 = 6; +pub const FOOTER_LEN: usize = 154; pub const NAME_CAP: usize = 48; pub const SAMPLE_LEN: usize = crate::track::RECORD_LEN; @@ -24,6 +24,7 @@ pub const KJ_NONE: u32 = u32::MAX; const NAME_AT: usize = 42; const TRIP_AT: usize = NAME_AT + NAME_CAP; const TRIP_NAME_AT: usize = TRIP_AT + 12; +const LIMITS_AT: usize = TRIP_NAME_AT + NAME_CAP; /// A footer name field: UTF-8, clipped at the last character boundary that fits [`NAME_CAP`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -102,6 +103,16 @@ impl TripRef { } } +/// The rider's effort limits, as the settings store them: `0` is not set, and that metric then has +/// no zones. The zone edges are fixed percentages of these, so a ride keeps only the limits. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct EffortLimits { + /// Maximum heart rate, bpm. + pub max_hr: u8, + /// Functional threshold power, W. + pub ftp_w: u16, +} + /// The fixed summary at the end of every finished ride object. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Footer { @@ -122,14 +133,16 @@ pub struct Footer { pub energy_kj: Option, /// The bike type that was current when the ride started. pub bike: BikeType, + /// The effort limits that were in force when the ride started. + pub limits: EffortLimits, name: Name, trip: Option, trip_name: Name, } impl Footer { - /// Build a footer for a ride on `BikeType::Road` with no descent, no energy and no trip; long - /// names are clipped. + /// Build a footer for a ride on `BikeType::Road` with no descent, no energy, no effort limits + /// and no trip; long names are clipped. #[allow(clippy::too_many_arguments)] pub fn new( name: &str, @@ -160,6 +173,7 @@ impl Footer { max_power, energy_kj: None, bike: BikeType::Road, + limits: EffortLimits::default(), name: Name::new(name), trip: None, trip_name: Name::EMPTY, @@ -214,9 +228,12 @@ pub fn encode_footer(footer: &Footer) -> [u8; FOOTER_LEN] { b[TRIP_AT + 8] = trip.day_index; b[TRIP_AT + 9] = trip.day_count; b[TRIP_AT + 11] = footer.trip_name.len; - b[TRIP_NAME_AT..FOOTER_LEN].copy_from_slice(&footer.trip_name.bytes); + b[TRIP_NAME_AT..LIMITS_AT].copy_from_slice(&footer.trip_name.bytes); } b[TRIP_AT + 10] = footer.bike as u8; + b[LIMITS_AT] = footer.limits.max_hr; + // The next byte is reserved and remains zero, aligning FTP. + b[LIMITS_AT + 2..FOOTER_LEN].copy_from_slice(&footer.limits.ftp_w.to_le_bytes()); b } @@ -230,13 +247,13 @@ pub fn decode_footer(b: &[u8; FOOTER_LEN]) -> Result { if b[4] != VERSION { return Err(DecodeError::Version); } - if u16::from_le_bytes([b[6], b[7]]) as usize != FOOTER_LEN || b[33] != 0 { + if u16::from_le_bytes([b[6], b[7]]) as usize != FOOTER_LEN || b[33] != 0 || b[LIMITS_AT + 1] != 0 { return Err(DecodeError::Layout); } let name = Name::decode(b[5], &b[NAME_AT..TRIP_AT])?; let bike = BikeType::from_u8(b[TRIP_AT + 10]).ok_or(DecodeError::Layout)?; let key = u64::from_le_bytes(b[TRIP_AT..TRIP_AT + 8].try_into().unwrap()); - let trip_name = Name::decode(b[TRIP_AT + 11], &b[TRIP_NAME_AT..FOOTER_LEN])?; + let trip_name = Name::decode(b[TRIP_AT + 11], &b[TRIP_NAME_AT..LIMITS_AT])?; let trip = TripRef::new(key, b[TRIP_AT + 8], b[TRIP_AT + 9]); // Without a trip, every trip byte is zero. let no_trip = key == 0 && b[TRIP_AT + 8] == 0 && b[TRIP_AT + 9] == 0 && trip_name.len == 0; @@ -259,6 +276,7 @@ pub fn decode_footer(b: &[u8; FOOTER_LEN]) -> Result { max_power: opt(u16::from_le_bytes(b[36..38].try_into().unwrap()), PWR_NONE), energy_kj: opt(u32::from_le_bytes(b[38..42].try_into().unwrap()), KJ_NONE), bike, + limits: EffortLimits { max_hr: b[LIMITS_AT], ftp_w: u16::from_le_bytes([b[LIMITS_AT + 2], b[LIMITS_AT + 3]]) }, name, trip, trip_name, @@ -280,7 +298,7 @@ fn opt(v: T, sentinel: T) -> Option { const _: () = assert!(SAMPLE_LEN == 20); const _: () = assert!(core::mem::size_of::>() == core::mem::size_of::()); -const _: () = assert!(FOOTER_LEN == TRIP_NAME_AT + NAME_CAP); +const _: () = assert!(FOOTER_LEN == LIMITS_AT + 4); #[cfg(test)] mod tests { @@ -306,6 +324,7 @@ mod tests { footer.descent_m = 640; footer.energy_kj = Some(756); footer.bike = BikeType::Gravel; + footer.limits = EffortLimits { max_hr: 185, ftp_w: 250 }; footer.set_trip(Some(TRIP), Name::new("Alpen Traverse")); footer } @@ -314,14 +333,15 @@ mod tests { fn footer_round_trip_pins_layout() { let footer = example(); let bytes = encode_footer(&footer); - assert_eq!(&bytes[..8], b"OBRF\x05\x0b\x96\0"); + assert_eq!(&bytes[..8], b"OBRF\x06\x0b\x9a\0"); assert_eq!(&bytes[22..30], &[0x2A, 3, 0x80, 2, 3, 0, 0, 0], "climb, descent, point count"); assert_eq!(&bytes[34..42], &[210, 0, 224, 1, 0xF4, 2, 0, 0], "power, then energy"); assert_eq!(&bytes[90..102], &[0xEF, 0xCD, 0xAB, 0x89, 0x67, 0x45, 0x23, 0x01, 1, 3, 1, 14]); + assert_eq!(&bytes[150..], &[185, 0, 250, 0], "max HR, reserved, FTP"); assert_eq!(decode_footer(&bytes), Ok(footer)); assert_eq!(footer.name(), "Sensor Ride"); assert_eq!(footer.trip_name(), "Alpen Traverse"); - assert_eq!(checked_object_len(3), Ok(3 * 20 + 150)); + assert_eq!(checked_object_len(3), Ok(3 * 20 + 154)); } #[test] @@ -337,15 +357,15 @@ mod tests { let mut footer = example(); footer.set_trip(None, Name::new("ignored")); let bytes = encode_footer(&footer); - assert!(bytes[90..100].iter().chain(&bytes[101..]).all(|&v| v == 0)); + assert!(bytes[90..100].iter().chain(&bytes[101..150]).all(|&v| v == 0)); assert_eq!(bytes[100], BikeType::Gravel as u8); assert_eq!(decode_footer(&bytes).unwrap().trip(), None); assert_eq!(TripRef::new(0, 0, 1), None, "key 0 is never a trip"); } #[test] - fn committed_v5_vector_uses_the_production_footer_codec() { - let object = include_bytes!("../../../specs/vectors/ride-v5.bin"); + fn committed_v6_vector_uses_the_production_footer_codec() { + let object = include_bytes!("../../../specs/vectors/ride-v6.bin"); assert_eq!(object.len() as u64, checked_object_len(3).unwrap()); let footer: &[u8; FOOTER_LEN] = object[object.len() - FOOTER_LEN..].try_into().unwrap(); let decoded = decode_footer(footer).unwrap(); @@ -354,6 +374,7 @@ mod tests { assert_eq!(decoded.bike, BikeType::Gravel); assert_eq!(decoded.trip(), Some(TRIP)); assert_eq!(decoded.trip_name(), "Alpen Traverse"); + assert_eq!(decoded.limits, EffortLimits { max_hr: 185, ftp_w: 250 }); assert_eq!(encode_footer(&decoded), *footer); } @@ -361,8 +382,8 @@ mod tests { fn footer_rejects_noncanonical_fixed_bytes() { let bytes = encode_footer(&example()); // Magic, version, length, reserved, name padding, a day past the count, a bike type past - // the four, and trip-name padding. - for (offset, value) in [(0, 0), (4, 4), (6, 148), (33, 1), (89, 1), (98, 3), (100, 4), (149, 1)] { + // the four, trip-name padding, and the reserved byte after max HR. + for (offset, value) in [(0, 0), (4, 5), (6, 150), (33, 1), (89, 1), (98, 3), (100, 4), (149, 1), (151, 1)] { let mut bad = bytes; bad[offset] = value; assert!(decode_footer(&bad).is_err(), "offset {offset}"); diff --git a/firmware/obc-fw-nrf54l/README.md b/firmware/obc-fw-nrf54l/README.md index 806120c4b..b90ae8058 100644 --- a/firmware/obc-fw-nrf54l/README.md +++ b/firmware/obc-fw-nrf54l/README.md @@ -100,11 +100,11 @@ riscv64-elf-gcc`, or the apt package `gcc-riscv64-unknown-elf`, or `RISCV_GCC=

(store: &FlatStore, first_start: u32) -> Result<[O if !store.entries_ok() { return Err(StoreError::Media); } + upgrade_v5_rides(store)?; if rides + ids.iter().filter(|id| id.0 == 0).count() > obc_app::MAX_RIDES { return Err(StoreError::CatalogFull); } @@ -116,16 +117,25 @@ pub fn seed_file(store: &FlatStore, bytes: &[u8]) -> Result= obc_app::MAX_RIDES { return Err(StoreError::CatalogFull); } @@ -154,6 +164,99 @@ pub fn seed_file(store: &FlatStore, bytes: &[u8]) -> Result(store: &FlatStore) -> Result<(), StoreError> { + loop { + let mut found = None; + for entry in store.entries().filter(|e| e.kind == ObjectKind::Ride && e.flags == EntryFlags::NONE) { + if is_v5(store, &entry)? { + found = Some(entry); + break; + } + } + if !store.entries_ok() { + return Err(StoreError::Media); + } + let Some(entry) = found else { return Ok(()) }; + let samples = entry.payload_len - V5_FOOTER_LEN as u64; + let mut footer = [0; FOOTER_LEN]; + let handle = store.open(entry.id, Some(entry.revision))?; + read_exact(store, &handle, samples, &mut footer[..V5_FOOTER_LEN])?; + footer[4] = obc_formats::ride::VERSION; + footer[6..8].copy_from_slice(&(FOOTER_LEN as u16).to_le_bytes()); + obc_formats::ride::decode_footer(&footer).map_err(|_| StoreError::Invalid)?; + let copy = |allocation: &mut Allocation| { + let mut buffer = [0; 500]; + let mut offset = 0; + while offset < samples { + let count = (samples - offset).min(buffer.len() as u64) as usize; + read_exact(store, &handle, offset, &mut buffer[..count])?; + store.write(allocation, &buffer[..count])?; + offset += count as u64; + } + store.write(allocation, &footer).map(|_| ()) + }; + let replaced = replace(store, &entry, copy, samples + FOOTER_LEN as u64); + store.close(handle); + replaced?; + } +} + +/// Whether a finished ride ends in a v5 footer over whole samples. +fn is_v5(store: &FlatStore, entry: &EntryMeta) -> Result { + let len = entry.payload_len; + if len < V5_FOOTER_LEN as u64 || !(len - V5_FOOTER_LEN as u64).is_multiple_of(SAMPLE_LEN as u64) { + return Ok(false); + } + let mut head = [0; 8]; + let handle = store.open(entry.id, Some(entry.revision))?; + let read = read_exact(store, &handle, len - V5_FOOTER_LEN as u64, &mut head); + store.close(handle); + read?; + Ok(head[..5] == *b"OBRF\x05" && u16::from_le_bytes([head[6], head[7]]) as usize == V5_FOOTER_LEN) +} + +/// Replace `entry` with `len` bytes that `fill` writes: the same object and name, one revision on. +fn replace( + store: &FlatStore, + entry: &EntryMeta, + fill: impl FnOnce(&mut Allocation) -> Result<(), StoreError>, + len: u64, +) -> Result<(), StoreError> { + let mut allocation = store.allocate(len)?; + fill(&mut allocation)?; + let revision = Revision(entry.revision.0.checked_add(1).ok_or(StoreError::Invalid)?); + let meta = EntryMeta { revision, payload_len: len, payload_crc: store.allocation_crc(&allocation)?, ..*entry }; + store + .commit(&[ + Mutation::Remove { id: entry.id, revision: entry.revision }, + Mutation::Put { meta, source: PutSource::Fresh(allocation) }, + ]) + .map(|_| ()) +} + +fn read_exact( + store: &FlatStore, + handle: &Handle, + offset: u64, + out: &mut [u8], +) -> Result<(), StoreError> { + let mut done = 0; + while done < out.len() { + let count = store.read(handle, offset + done as u64, &mut out[done..])?; + if count == 0 { + return Err(StoreError::Media); + } + done += count; + } + Ok(()) +} + fn check_store(store: &FlatStore) -> Result<(), StoreError> { if store.mode() != Mode::ReadWrite { return Err(StoreError::ReadOnly); @@ -225,6 +328,9 @@ fn write_ride(store: &FlatStore, sensors: usize, start: u32) ); footer.descent_m = descent; footer.energy_kj = (sensors == 2).then_some(power_sum * STEP_SECONDS / 1000); + if sensors >= 1 { + footer.limits = EffortLimits { max_hr: 185, ftp_w: 250 }; + } let bytes = encode_footer(&footer); store.write(&mut allocation, &bytes)?; crc.update(&bytes); @@ -255,6 +361,7 @@ fn write_ride(store: &FlatStore, sensors: usize, start: u32) check.update(&buffer[..count]); offset += count as u64; } + store.close(handle); if check.finalize() != payload_crc { return Err(StoreError::Media); } diff --git a/firmware/obc-link/tests/flat_engine.rs b/firmware/obc-link/tests/flat_engine.rs index 0de8f3f85..ffecacb19 100644 --- a/firmware/obc-link/tests/flat_engine.rs +++ b/firmware/obc-link/tests/flat_engine.rs @@ -404,7 +404,7 @@ fn metadata_is_readable_but_only_the_device_can_mutate_it() { fn a_finished_journal_ride_is_the_exact_object_served_by_normal_get() { let disk = formatted_card(140); let mut device = boot(&disk); - let bytes = include_bytes!("../../../specs/vectors/ride-v5.bin"); + let bytes = include_bytes!("../../../specs/vectors/ride-v6.bin"); let (id, revision) = device.finish_recording(bytes, "FS8 vector"); let entry = device.entry(id).expect("the final catalog names the ride"); diff --git a/firmware/obc-link/tests/flat_engine/recovery.rs b/firmware/obc-link/tests/flat_engine/recovery.rs index 4529c5874..c1c9b6a79 100644 --- a/firmware/obc-link/tests/flat_engine/recovery.rs +++ b/firmware/obc-link/tests/flat_engine/recovery.rs @@ -11,8 +11,8 @@ use obc_storage::flat::{ StoreError, RIDE_RESUME_LEN, }; -/// `specs/vectors/ride-v5.bin`: three 20-byte `TrackPoint` samples, then the fixed footer. -const RIDE_V5: &[u8] = include_bytes!("../../../../specs/vectors/ride-v5.bin"); +/// `specs/vectors/ride-v6.bin`: three 20-byte `TrackPoint` samples, then the fixed footer. +const RIDE_V6: &[u8] = include_bytes!("../../../../specs/vectors/ride-v6.bin"); /// The export `specs/vectors/track-export.gpx` was taken under (`obc_vectors::TRACK_NAME`); the /// ride's own footer carries a different one, which is what [`RideInfo`] reads. const EXPORT_NAME: &str = "Schauinsland & back"; @@ -65,9 +65,9 @@ fn journaled_recording(seed: u64) -> (SparseDisk, (u64, u64)) { let key = { let mut device = boot(&disk); let key = device.seed_recording(RESERVE); - let (samples, footer) = RIDE_V5.split_at(RIDE_V5.len() - FOOTER_LEN); + let (samples, footer) = RIDE_V6.split_at(RIDE_V6.len() - FOOTER_LEN); checkpoint(&device, key, samples, samples); - checkpoint(&device, key, footer, RIDE_V5); + checkpoint(&device, key, footer, RIDE_V6); key }; (disk, key) @@ -82,7 +82,7 @@ fn catalog_write_offset() -> u32 { let (disk, key) = journaled_recording(1_420); let device = boot(&disk); let baseline = disk.ops(); - finalise(&device, key, RIDE_V5.len() as u64, crc32(RIDE_V5)).expect("the probe finalises"); + finalise(&device, key, RIDE_V6.len() as u64, crc32(RIDE_V6)).expect("the probe finalises"); let (op, _, _) = disk .write_log() .into_iter() @@ -108,7 +108,7 @@ fn an_interrupted_recording_recovers_and_exports_the_pinned_gpx() { let baseline = disk.ops(); disk.plan(FaultPlan { op: baseline + cut_at, when: When::Before }); assert_eq!( - finalise(&device, key, RIDE_V5.len() as u64, crc32(RIDE_V5)), + finalise(&device, key, RIDE_V6.len() as u64, crc32(RIDE_V6)), Err(StoreError::Media), "the cut did not land inside finalisation", ); @@ -124,8 +124,8 @@ fn an_interrupted_recording_recovers_and_exports_the_pinned_gpx() { ); let recovered = device.store.recovered_ride().expect("the store recovers the interrupted ride"); assert_eq!((recovered.id.0, recovered.revision.0), key); - assert_eq!(recovered.payload_len(), RIDE_V5.len() as u64, "the recovery lost journaled bytes"); - assert_eq!(recovered.payload_crc, crc32(RIDE_V5), "the recovery reconstructed a different ride"); + assert_eq!(recovered.payload_len(), RIDE_V6.len() as u64, "the recovery lost journaled bytes"); + assert_eq!(recovered.payload_crc, crc32(RIDE_V6), "the recovery reconstructed a different ride"); // The recorder retries the commit, publishing what the recovery handed back — not what this test // knows the ride to be, so a recovery that rebuilt the wrong length or CRC cannot pass here. @@ -133,7 +133,7 @@ fn an_interrupted_recording_recovers_and_exports_the_pinned_gpx() { .expect("the retried finalisation publishes the ride"); let entry = device.entry(id).expect("the final catalog names the ride"); assert!(!entry.flags.has(EntryFlags::RECORDING)); - assert_eq!((entry.payload_len, entry.payload_crc), (RIDE_V5.len() as u64, crc32(RIDE_V5))); + assert_eq!((entry.payload_len, entry.payload_crc), (RIDE_V6.len() as u64, crc32(RIDE_V6))); assert!(device.store.recovered_ride().is_none(), "a published ride is still offered as recording"); // What the phone asks for, and what it gets. @@ -141,7 +141,7 @@ fn an_interrupted_recording_recovers_and_exports_the_pinned_gpx() { let answer = Answer::of(wire.answer()); assert!(!answer.is_error(), "{answer:?}"); let payload = wire.payload(); - assert_eq!(payload, RIDE_V5, "GET served something other than the recovered ride"); + assert_eq!(payload, RIDE_V6, "GET served something other than the recovered ride"); // What the phone makes of those bytes: the vector's own totals, then the pinned export. let source = SliceSource(&payload); diff --git a/firmware/obc-platform/src/debug_link.rs b/firmware/obc-platform/src/debug_link.rs index 9039980c6..6676f9274 100644 --- a/firmware/obc-platform/src/debug_link.rs +++ b/firmware/obc-platform/src/debug_link.rs @@ -81,7 +81,7 @@ pub enum Msg { /// a catalog that cannot be listed, cannot be produced on a real card without risking it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RideDamageKind { - /// Bytes that are not a ride-v5 sample/footer boundary. + /// Bytes that are not a ride sample/footer boundary. Payload, /// A valid sample boundary with a continuation image that does not decode. Metadata, diff --git a/firmware/obc-route/src/ride.rs b/firmware/obc-route/src/ride.rs index 1036c361b..86aa86922 100644 --- a/firmware/obc-route/src/ride.rs +++ b/firmware/obc-route/src/ride.rs @@ -1,6 +1,6 @@ -//! Recorded-ride v5 summary access. +//! Recorded-ride v6 summary access. //! -//! The object begins with the existing 20-byte track samples and ends with one fixed 150-byte +//! The object begins with the existing 20-byte track samples and ends with one fixed 154-byte //! footer. Recording therefore writes the final bytes directly; finalize is one footer append, //! never a whole-ride conversion. @@ -10,7 +10,8 @@ use obc_formats::{ bike::BikeType, io::{ByteSource, DecodeError, Error}, ride::{ - checked_object_len, decode_footer, encode_footer, Footer, Name, TripRef, FOOTER_LEN, MAGIC, NAME_CAP, VERSION, + checked_object_len, decode_footer, encode_footer, EffortLimits, Footer, Name, TripRef, FOOTER_LEN, MAGIC, + NAME_CAP, VERSION, }, }; @@ -37,6 +38,8 @@ pub struct RideStats { pub energy_kj: Option, /// The bike type that was current when the ride started. pub bike: BikeType, + /// The effort limits that were in force when the ride started. + pub limits: EffortLimits, /// The trip day the ride started on. pub trip: Option, pub trip_name: Name, @@ -76,6 +79,7 @@ pub fn encode_summary_footer( footer.descent_m = stats.descent_m; footer.energy_kj = stats.energy_kj; footer.bike = stats.bike; + footer.limits = stats.limits; footer.set_trip(stats.trip, stats.trip_name); encode_footer(&footer) } @@ -99,6 +103,8 @@ pub struct RideInfo { pub max_power: Option, pub energy_kj: Option, pub bike: BikeType, + /// The effort limits in force when the ride started. + pub limits: EffortLimits, pub trip: Option, pub trip_name: String, } @@ -145,6 +151,7 @@ impl RideInfo { max_power: footer.max_power, energy_kj: footer.energy_kj, bike: footer.bike, + limits: footer.limits, trip: footer.trip(), trip_name, }) diff --git a/firmware/obc-route/tests/cases/ride.rs b/firmware/obc-route/tests/cases/ride.rs index c065808a9..13c14f0aa 100644 --- a/firmware/obc-route/tests/cases/ride.rs +++ b/firmware/obc-route/tests/cases/ride.rs @@ -1,11 +1,11 @@ -//! Recorded ride v5 contract: verbatim samples, one fixed footer, and footer-based readers. +//! Recorded ride v6 contract: verbatim samples, one fixed footer, and footer-based readers. use core::cell::RefCell; use obc_formats::{ bike::BikeType, io::{ByteSource, Error, SliceSource}, - ride::{Name, TripRef, FOOTER_LEN, VERSION}, + ride::{decode_footer, EffortLimits, Name, TripRef, FOOTER_LEN, VERSION}, track::encode_record, }; use obc_ports::TrackPoint; @@ -27,6 +27,7 @@ const STATS: RideStats = RideStats { max_power: Some(480), energy_kj: Some(756), bike: BikeType::Mtb, + limits: EffortLimits { max_hr: 185, ftp_w: 250 }, trip: TripRef::new(7, 0, 2), trip_name: Name::EMPTY, }; @@ -66,6 +67,8 @@ fn recorded_samples_are_the_served_bytes() { (Some(142), Some(176), Some(85), Some(210), Some(480), Some(756)) ); assert_eq!((info.bike, info.trip, info.trip_name.as_str()), (BikeType::Mtb, STATS.trip, "Alpen")); + let footer = decode_footer(ride[ride.len() - FOOTER_LEN..].try_into().unwrap()).unwrap(); + assert_eq!(footer.limits, STATS.limits); } struct ReadSpy<'a> { diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index 53594ceae..e8238a5d7 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -26,7 +26,7 @@ "compile_time_allocations": { "framebuffer": 76800, "row_diff": 1284, - "app": 58184, + "app": 58440, "map_cache": 37084, "map_tables": 7528, "route_cache": 9260, @@ -53,7 +53,7 @@ "semmc_driver": 52, "flat_store": 10152, "flat_requests": 3064, - "flat_ride_delta": 566, + "flat_ride_delta": 570, "flat_catalog_uploads": 740, "flat_map_read": 76, "flat_route_read": 48, @@ -88,6 +88,12 @@ "_deep_ride_margin_note": "A durable raw 8,704 B floor leaves 8,192 B effective after the 512 B below MSPLIM reserved for fault handling; it is deliberately not pinned to today's exact margin. The shipping image measured beside the run above has 46,328 B residual, so it clears the 31,340 B peak by 14,988 B raw / 14,476 B effective.", "_deep_ride_high_water_note": "Read off the board's own stackmeter on the nRF54LM20-DK over the VCOM harness, with the grimsel-demo map on the card. One walk reached the peak and a second boot reproduced it exactly. What the walk covered: a route opened from the Route menu and a ride started from it; map redraws under an injected 1 Hz fix; a Find a place search and its return; three planner runs through the debug N line, each released; Peak View generation, a peak article and its photo page, and the return to Peak View; the ride finished and saved; and, across a warm reset taken while recording, the section 7.3 ride recovery, its Continue and a second finish. The peak lands on ride entry; nothing after it goes deeper. The image is the debug-uart one, because the host has to inject the buttons and the fixes, and that image compiles the ride loop differently: it binds the debug sensor sources where the shipping image builds the hub drains as call temporaries, and it never spawns sensor_task. The gated image is therefore not the measured one, so the static frames were compared before this row moved down. On the same two ELFs, resource_guard.py frames --match run_app gives 4,640 B shipping against 4,656 B debug-uart, --match ride gives the same 16,184 B largest frame in both, the largest guarded poll frame is 9,776 B in both, and the largest task body is 4,816 B shipping against 4,808 B. The measured compilation of the ride loop is therefore 16 B DEEPER than the shipping one, and the whole shipping-only sensor path fits in frames of at most 200 B inside its own task. Not covered, and therefore not claimed: the real-sensor paths, because the measured image runs none of them - the GPS, altimeter and compass drains and sensor_task itself; a plan that finds a path (this map answers no-path); an off-route rejoin; an archive receipt; and BLE or USB traffic during the ride.", "_boot_chain_roots_note": "The mount_in_place root is unscoped because toolchains demangle FlatStore differently. The symbol resolver rejects missing or ambiguous roots; a second matching constructor must be resolved with an explicit, portable needle.", + "_ride_zone_limits_allocation_measurement": { + "source_commit": "claude/ride-zone-limits on develop 0a0b15804", + "build": "cargo build --release --locked --features resource-report on the pinned aarch64-apple-darwin host, rustc 1.96.0 (App 58,440 B)", + "changes": "flat_ride_delta +4 B over 566 B: the ride footer grows from 150 B to 154 B to keep the effort limits in force at the ride start. App +256 B over 58,184 B: each of the 32 resident ride catalog entries keeps its ride's 4 B limits for the detail's zones, and the u64 object id rounds the entry from 112 B to 120 B. All other named allocations and ceilings are unchanged.", + "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,736 B linked resident inside the band, residual main stack 43,688 B, poll frame 9,864 B, init_idle frame 1,856 B." + }, "_sound_cues_allocation_measurement": { "source_commit": "a6485a93 on claude/sound-s7-alerts, merged with develop at fac1d7cc", "build": "cargo build --release --locked --features resource-report on x86_64-unknown-linux-gnu, rustc 1.98.1 (App 58,184 B)", diff --git a/firmware/tools/ui_frames/environments.py b/firmware/tools/ui_frames/environments.py index 3ec343269..96b907b76 100644 --- a/firmware/tools/ui_frames/environments.py +++ b/firmware/tools/ui_frames/environments.py @@ -62,19 +62,21 @@ def plain_route(stage: Stage) -> Staging: def _ride(samples, name, start, distance_m, moving_s, climb_m, descent_m, hr, trip): """A ride object as `specs/obc-ble-interface-spec.md` §7.2 lays it out: 20-byte samples, then - the 150-byte footer. `samples` are `(lon, lat, ele)`; `hr` is one heart rate per sample or - `None`; `trip` is `(key, day index, day count, name)` or `None`. + the 154-byte footer. `samples` are `(lon, lat, ele)`; `hr` is one heart rate per sample or + `None`, and a ride with heart rate records a 185 bpm max HR and a 250 W FTP; `trip` is + `(key, day index, day count, name)` or `None`. """ body = bytearray() for i, (lon, lat, ele) in enumerate(samples): body += struct.pack(" Staging: """Four stored rides for the Rides screens, oldest first, so the import gives the newest the - highest id: two loose copies of the pinned `ride-v5.bin` vector, "Sensor Ride" with all three + highest id: two loose copies of the pinned `ride-v6.bin` vector, "Sensor Ride" with all three sensors, and two days of the trip "Alps traverse" on the Grimsel climb's track. Day 2 has a heart rate that rises and falls with the climb; Day 1 has no sensor. Every ride is unsynced; the flat store stages no archive rows. """ where = stage.dir("tracks") - vector = (stage.vectors / "ride-v5.bin").read_bytes() - footer = len(vector) - 150 + vector = (stage.vectors / "ride-v6.bin").read_bytes() + footer = len(vector) - 154 for index, distance in enumerate((12_345, 17_800)): loose = bytearray(vector) struct.pack_into(" obc_route::RideStats { max_power: None, energy_kj: None, bike: obc_formats::bike::BikeType::Road, + limits: obc_formats::ride::EffortLimits::default(), trip: None, trip_name: obc_formats::ride::Name::EMPTY, } diff --git a/host/obc-host-core/src/flat_recorder/tests.rs b/host/obc-host-core/src/flat_recorder/tests.rs index f49095047..a2f15ad2f 100644 --- a/host/obc-host-core/src/flat_recorder/tests.rs +++ b/host/obc-host-core/src/flat_recorder/tests.rs @@ -18,6 +18,7 @@ fn context() -> RideContinuation { RideContinuation { origin: obc_app::RideOrigin { bike: obc_formats::bike::BikeType::Mtb, + limits: obc_formats::ride::EffortLimits { max_hr: 185, ftp_w: 250 }, trip: obc_formats::ride::TripRef::new(7, 0, 2), }, ridden_m: 200., @@ -53,6 +54,7 @@ fn stats() -> RideStats { max_power: Some(250), energy_kj: Some(2), bike: obc_formats::bike::BikeType::Road, + limits: obc_formats::ride::EffortLimits::default(), trip: None, trip_name: obc_formats::ride::Name::EMPTY, } diff --git a/host/obc-host-core/tests/board_ride.rs b/host/obc-host-core/tests/board_ride.rs index 7e24dc794..2649dc4ae 100644 --- a/host/obc-host-core/tests/board_ride.rs +++ b/host/obc-host-core/tests/board_ride.rs @@ -99,6 +99,7 @@ fn stats() -> obc_route::RideStats { max_power: None, energy_kj: None, bike: obc_formats::bike::BikeType::Road, + limits: obc_formats::ride::EffortLimits::default(), trip: None, trip_name: obc_formats::ride::Name::EMPTY, } diff --git a/host/obc-host-core/tests/demo_rides.rs b/host/obc-host-core/tests/demo_rides.rs index 25e32bab7..92f79463c 100644 --- a/host/obc-host-core/tests/demo_rides.rs +++ b/host/obc-host-core/tests/demo_rides.rs @@ -2,7 +2,7 @@ mod demo_rides; use obc_formats::io::SliceSource; -use obc_formats::ride::{encode_footer, Footer}; +use obc_formats::ride::{decode_footer, encode_footer, EffortLimits, Footer, FOOTER_LEN}; use obc_formats::track::{decode_record, RECORD_LEN}; use obc_storage::flat::{ sim::SparseDisk, DisplayName, EntryFlags, EntryMeta, FlatStore, Mutation, ObjectId, ObjectKind, PutSource, @@ -180,3 +180,73 @@ fn file_seed_refuses_invalid_or_conflicting_payload_without_writes() { assert_eq!(demo_rides::seed_file(&store, &conflicting), Err(StoreError::Invalid)); assert_eq!(disk.write_log().len(), writes); } + +/// A v5 ride from the previous firmware: the same samples under the 150-byte v5 footer. +fn v5_file(name: &str, start: u32) -> Vec { + let mut bytes = finished_file(name, start); + bytes.truncate(bytes.len() - 4); + let footer = bytes.len() - 150; + bytes[footer + 4] = 5; + bytes[footer + 6..footer + 8].copy_from_slice(&150u16.to_le_bytes()); + bytes +} + +fn put_ride(store: &FlatStore<&SparseDisk>, bytes: &[u8], name: &str) -> EntryMeta { + let mut allocation = store.allocate(bytes.len() as u64).unwrap(); + store.write(&mut allocation, bytes).unwrap(); + let meta = EntryMeta { + id: store.next_object_id(), + revision: Revision(1), + kind: ObjectKind::Ride, + flags: EntryFlags::NONE, + payload_len: bytes.len() as u64, + payload_crc: obc_crc::crc32(bytes), + name: DisplayName::new(name).unwrap(), + added_at_utc: 1, + }; + store.commit(&[Mutation::Put { meta, source: PutSource::Fresh(allocation) }]).unwrap(); + meta +} + +fn stored(store: &FlatStore<&SparseDisk>, id: ObjectId) -> (Revision, Vec) { + let entry = store.entries().find(|entry| entry.id == id).unwrap(); + let mut bytes = vec![0; entry.payload_len as usize]; + let handle = store.open(id, None).unwrap(); + store.read(&handle, 0, &mut bytes).unwrap(); + store.close(handle); + (entry.revision, bytes) +} + +/// A card from the v5 firmware: its copy of the fixture is replaced in place, and every other v5 +/// ride becomes the same object one revision on, with its samples and a v6 footer without limits. +#[test] +fn file_seed_replaces_the_v5_fixture_and_upgrades_other_v5_rides_in_place() { + let disk = SparseDisk::blank(131_072, 12); + let store = FlatStore::initialize(&disk, StoreId([12; 16])).unwrap(); + let old_fixture = put_ride(&store, &v5_file("Kandel", 1_700_000_000), "Kandel"); + let recorded = v5_file("Morning ride", 1_600_000_000); + let other = put_ride(&store, &recorded, "Morning ride"); + // More rides than the store has open-object slots, so a leaked handle would refuse the seed. + let more: Vec<_> = + (0..6).map(|i| put_ride(&store, &v5_file("Older ride", 1_500_000_000 + i), "Older ride")).collect(); + + let mut fixture = finished_file("Kandel", 1_700_000_000); + let limits = fixture.len() - 4; + fixture[limits] = 185; + fixture[limits + 2..].copy_from_slice(&250u16.to_le_bytes()); + assert_eq!(demo_rides::seed_file(&store, &fixture), Ok(old_fixture.id)); + assert_eq!(stored(&store, old_fixture.id), (Revision(2), fixture.clone())); + + let (revision, upgraded) = stored(&store, other.id); + assert_eq!(revision, Revision(2)); + assert_eq!(upgraded[..40 * RECORD_LEN], recorded[..40 * RECORD_LEN], "the samples are unchanged"); + let footer = decode_footer(upgraded[upgraded.len() - FOOTER_LEN..].try_into().unwrap()).unwrap(); + assert_eq!((footer.name(), footer.start_time), ("Morning ride", 1_600_000_000)); + assert_eq!(footer.limits, EffortLimits::default()); + assert!(more.iter().all(|ride| stored(&store, ride.id).0 == Revision(2)), "every v5 ride is upgraded"); + + let sequence = store.sequence(); + let store = FlatStore::mount(&disk); + assert_eq!(demo_rides::seed_file(&store, &fixture), Ok(old_fixture.id)); + assert_eq!(store.sequence(), sequence, "a second run writes nothing"); +} diff --git a/host/obc-replay/examples/gpx_to_ride.rs b/host/obc-replay/examples/gpx_to_ride.rs index f2fda63ed..174ebc786 100644 --- a/host/obc-replay/examples/gpx_to_ride.rs +++ b/host/obc-replay/examples/gpx_to_ride.rs @@ -5,7 +5,7 @@ use std::{env, fs, path::Path}; use obc_elevation::DeadBand; use obc_formats::{ bike::BikeType, - ride::{encode_footer, Footer, FOOTER_LEN}, + ride::{encode_footer, EffortLimits, Footer, FOOTER_LEN}, track::{encode_record, RECORD_LEN}, }; use obc_ports::TrackPoint; @@ -14,19 +14,28 @@ use obc_replay::Track; const MAX_POINTS: usize = 20_000; const MAX_GPX_BYTES: u64 = 8 * 1024 * 1024; const EARTH_M: f64 = 6_371_000.0; +/// The limits the device's demo rides record, so the simulated sensors fall into zones. +const DEFAULT_LIMITS: EffortLimits = EffortLimits { max_hr: 185, ftp_w: 250 }; fn main() -> Result<(), String> { let args: Vec<_> = env::args().collect(); - if args.len() != 5 { - return Err("usage: gpx_to_ride INPUT.gpx OUTPUT.obcr START_UNIX_S RIDE_NAME".into()); + if args.len() != 5 && args.len() != 7 { + return Err("usage: gpx_to_ride INPUT.gpx OUTPUT.obcr START_UNIX_S RIDE_NAME [MAX_HR_BPM FTP_W]".into()); } let start_time = args[3].parse::().map_err(|_| "START_UNIX_S must be a Unix timestamp in seconds")?; + let limits = match args.get(5..7) { + Some([max_hr, ftp_w]) => EffortLimits { + max_hr: max_hr.parse().map_err(|_| "MAX_HR_BPM must be 0..=255")?, + ftp_w: ftp_w.parse().map_err(|_| "FTP_W must be 0..=65535")?, + }, + _ => DEFAULT_LIMITS, + }; let input = Path::new(&args[1]); if fs::metadata(input).map_err(|e| e.to_string())?.len() > MAX_GPX_BYTES { return Err(format!("GPX exceeds {MAX_GPX_BYTES} bytes")); } let track = Track::load(input)?; - let bytes = build_ride(&track, start_time, &args[4])?; + let bytes = build_ride(&track, start_time, &args[4], limits)?; let output = Path::new(&args[2]); if let Some(parent) = output.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; @@ -36,7 +45,7 @@ fn main() -> Result<(), String> { Ok(()) } -fn build_ride(track: &Track, start_time: u32, name: &str) -> Result, String> { +fn build_ride(track: &Track, start_time: u32, name: &str, limits: EffortLimits) -> Result, String> { let points = &track.points; if !(2..=MAX_POINTS).contains(&points.len()) { return Err(format!("ride needs 2..={MAX_POINTS} track points")); @@ -138,6 +147,7 @@ fn build_ride(track: &Track, start_time: u32, name: &str) -> Result, Str footer.descent_m = climb.descent().round() as u16; footer.energy_kj = Some((energy_j / 1000.0).round() as u32); footer.bike = BikeType::Road; + footer.limits = limits; bytes.extend_from_slice(&encode_footer(&footer)); Ok(bytes) } @@ -175,12 +185,13 @@ mod tests { }) .collect(), }; - let bytes = build_ride(&track, 1_790_236_800, "Kandel (simulated)").unwrap(); + let bytes = build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS).unwrap(); let footer = decode_footer(bytes[bytes.len() - FOOTER_LEN..].try_into().unwrap()).unwrap(); assert_eq!(bytes.len() as u64, checked_object_len(track.points.len() as u32).unwrap()); assert_eq!(footer.point_count, track.points.len() as u32); assert_eq!(footer.name(), "Kandel (simulated)"); assert_eq!(footer.start_time, 1_790_236_800); + assert_eq!(footer.limits, DEFAULT_LIMITS); assert!(footer.climb_m >= 20 && footer.descent_m >= 20); let records: Vec<_> = bytes[..bytes.len() - FOOTER_LEN].as_chunks::().0.iter().map(decode_record).collect(); @@ -192,7 +203,7 @@ mod tests { assert!(records[1].power.unwrap() > records[6].power.unwrap()); assert_eq!((records[6].power, records[6].cadence), (Some(0), Some(0))); assert!(records[6].hr.unwrap() > 105, "heart rate should lag the descent"); - assert_eq!(build_ride(&track, 1_790_236_800, "Kandel (simulated)").unwrap(), bytes); + assert_eq!(build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS).unwrap(), bytes); } #[test] @@ -203,9 +214,9 @@ mod tests { GpxPoint { lat: 48_000_000, lon: 8_001_000, ele: None, t: 10.0 }, ], }; - assert!(build_ride(&track, 1, "test").is_err()); + assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS).is_err()); track.points[1].ele = Some(200.0); track.points[1].t = 0.0; - assert!(build_ride(&track, 1, "test").is_err()); + assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS).is_err()); } } diff --git a/host/obc-vectors/src/lib.rs b/host/obc-vectors/src/lib.rs index 3edb853f0..0e9782e9f 100644 --- a/host/obc-vectors/src/lib.rs +++ b/host/obc-vectors/src/lib.rs @@ -157,7 +157,7 @@ pub fn track_log() -> Vec { v } -/// The GPX 1.1 export of [`ride_v5`], through the production converter (`track_to_gpx`). +/// The GPX 1.1 export of [`ride_v6`], through the production converter (`track_to_gpx`). /// /// Unlike the binary fixtures there is no independent spec to rebuild this from — the exporter's /// serialization *is* the contract — so this goes through the real code, exactly like @@ -165,7 +165,7 @@ pub fn track_log() -> Vec { /// (`obc-web-convert`, compiled to wasm) must reproduce these bytes character-for-character. pub fn track_export_gpx() -> Vec { let mut sink = VecSink(Vec::new()); - obc_route::track_to_gpx(&SliceSource(&ride_v5()), TRACK_NAME, &mut sink).unwrap(); + obc_route::track_to_gpx(&SliceSource(&ride_v6()), TRACK_NAME, &mut sink).unwrap(); sink.0 } @@ -176,10 +176,10 @@ fn le32(v: u32) -> [u8; 4] { v.to_le_bytes() } -/// Ride object v5: three exact 20-byte recorded samples followed by the fixed 150-byte footer. +/// Ride object v6: three exact 20-byte recorded samples followed by the fixed 154-byte footer. /// Built field-by-field from the specification rather than through the production codec. The ride -/// started on day 2 of 3 of the [`TRIP_KEY`] trip, on a Gravel bike. -pub fn ride_v5() -> Vec { +/// started on day 2 of 3 of the [`TRIP_KEY`] trip, on a Gravel bike, with max HR 185 and FTP 250. +pub fn ride_v6() -> Vec { let mut v = Vec::new(); // lon µdeg, lat µdeg, ele m, flags, t_ms, hr, cadence, power. for (lon, lat, ele, flags, t_ms, hr, cad, pwr) in [ @@ -199,9 +199,9 @@ pub fn ride_v5() -> Vec { let name = b"Sensor Ride"; v.extend_from_slice(b"OBRF"); - v.push(5); // version + v.push(6); // version v.push(name.len() as u8); - v.extend_from_slice(&le16(150)); // fixed footer length + v.extend_from_slice(&le16(154)); // fixed footer length v.extend_from_slice(&le32(1_751_460_000)); // start_time v.extend_from_slice(&le32(12_345)); // distance m v.extend_from_slice(&le32(3_600)); // moving_time s @@ -225,6 +225,9 @@ pub fn ride_v5() -> Vec { v.push(TRIP_NAME.len() as u8); v.extend_from_slice(TRIP_NAME.as_bytes()); v.resize(3 * 20 + 150, 0); // fixed 48-byte trip-name slot + v.push(185); // max HR limit, bpm + v.push(0); // reserved + v.extend_from_slice(&le16(250)); // FTP limit, W v } @@ -554,7 +557,7 @@ pub fn all() -> Vec<(&'static str, Vec)> { ("route-waypoints.obcr", route_wp), ("route-plain.obcr", route_plain), // The sample-codec fixture remains a codec vector only. GPX export is pinned from the - // finished ride-v5 object; headerless sample arrays are not accepted as rides. + // finished ride-v6 object; headerless sample arrays are not accepted as rides. ("track-log.obct", track_log()), ("track-export.gpx", track_export_gpx()), // The OBCT terrain shard (`OBCT_Spec.md`): a 2 × 2 cell rectangle with a hole @@ -563,7 +566,7 @@ pub fn all() -> Vec<(&'static str, Vec)> { // (the device, the `obc-dem` baker's cross-check, and eventually the browser), and the // spec's guarantee is that they agree bit-for-bit on the same coordinate. ("terrain-shard.obcd", terrain.clone()), - ("ride-v5.bin", ride_v5()), + ("ride-v6.bin", ride_v6()), ("config-v1.bin", config_v1()), ("place-train-v15.bin", place_record()), ("landmark-section-v16.bin", landmarks::section()), diff --git a/host/obc-vectors/tests/vectors.rs b/host/obc-vectors/tests/vectors.rs index c9a8a539f..a63609897 100644 --- a/host/obc-vectors/tests/vectors.rs +++ b/host/obc-vectors/tests/vectors.rs @@ -9,7 +9,7 @@ use obc_route::{ for_each_waypoint, track_to_gpx, BikeType, RouteIndex, RouteObjectInfo, RouteReader, MAX_POINTS_PER_CHUNK, }; use obc_vectors::{ - all, crc32, dir, ride_v5, terrain_coord, terrain_height, terrain_shard, TERRAIN_CELL_LOG2, TERRAIN_CELL_MIN_I, + all, crc32, dir, ride_v6, terrain_coord, terrain_height, terrain_shard, TERRAIN_CELL_LOG2, TERRAIN_CELL_MIN_I, TERRAIN_CELL_MIN_J, TERRAIN_COLS, TERRAIN_NODATA_AT, TERRAIN_POSTING_LOG2, TERRAIN_ROWS, TRACK_NAME, TRIP_DAYS, TRIP_KEY, TRIP_NAME, TRIP_START_DATE, }; @@ -169,7 +169,7 @@ fn route_descriptor_envelopes_match_the_shared_overlap_contract() { /// The sample-codec vector and the finished-ride GPX export. `track-log.obct` is exactly five /// complete 20-byte records used only to pin the sample codec. The exporter consumes -/// `ride-v5.bin`; unfinished/headerless arrays are deliberately not a ride input. +/// `ride-v6.bin`; unfinished/headerless arrays are deliberately not a ride input. #[test] fn track_vectors_pin_the_log_and_its_export() { let log = fixture("track-log.obct"); @@ -200,10 +200,10 @@ fn track_vectors_pin_the_log_and_its_export() { assert_eq!((point(0).segment_start, point(3).segment_start), (true, true), "two segments"); assert_eq!((point(3).lon, point(3).lat, point(3).ele), (-122_419_400, -37_774_900, -12), "negative signs"); - // The export re-derives from the checked-in finished ride-v5 object. + // The export re-derives from the checked-in finished ride-v6 object. let mut sink = VecSink::default(); - track_to_gpx(&SliceSource(&fixture("ride-v5.bin")), TRACK_NAME, &mut sink).unwrap(); - assert_eq!(String::from_utf8(sink.buf).unwrap(), gpx, "track-export.gpx drifted from ride-v5.bin"); + track_to_gpx(&SliceSource(&fixture("ride-v6.bin")), TRACK_NAME, &mut sink).unwrap(); + assert_eq!(String::from_utf8(sink.buf).unwrap(), gpx, "track-export.gpx drifted from ride-v6.bin"); // The shapes the exporter's branches produce, spelled out once (the browser bridge reproduces // this exact text, so a change here is a change to a cross-language contract). @@ -223,13 +223,13 @@ fn track_vectors_pin_the_log_and_its_export() { assert!(!gpx.contains("