From 425df6bf6184c39c6373f12e6f6d6a3e50a6ec3d Mon Sep 17 00:00:00 2001 From: timohueser Date: Fri, 25 Sep 2026 10:35:53 +0200 Subject: [PATCH 1/8] feat(firmware): record the effort limits in force at ride start Each ride now keeps the rider's max HR and FTP from the moment it starts, so an old ride keeps its own effort zones when the settings change later. - Ride object v6: the footer grows to 154 bytes with the max HR limit, a reserved byte and the FTP limit (0 = not set). The spec states the fixed zone edges a reader applies to them. - The recorder snapshots the limits into the ride origin at a fresh start. The resume image (v4) carries them, so a continued ride keeps its original snapshot. - effort::Limits becomes the shared obc_formats::ride::EffortLimits. - ride-v6.bin replaces ride-v5.bin, regenerated with the obc-vectors generator; the web decoder, the conversion bridge, the UI-frame staging and the flat-engine tests read the new vector. - The seeded demo sensor rides carry max HR 185 and FTP 250. - flat_ride_delta re-pinned at 570 B (+4 B footer). Co-Authored-By: Claude Opus 5.5 --- apps/obc-web-convert/Cargo.toml | 2 +- apps/obc-web-convert/src/convert.rs | 8 +-- apps/obc-web-convert/src/lib.rs | 2 +- builder/app/src/lib/convert/bridge.test.ts | 4 +- builder/app/src/lib/convert/bridge.ts | 2 +- builder/app/src/lib/device/library.test.ts | 2 +- builder/app/src/lib/device/rides.test.ts | 12 ++-- builder/app/src/lib/usb/objects.ts | 18 +++--- firmware/obc-app/src/app.rs | 19 +++++-- firmware/obc-app/src/device_core/pass.rs | 6 +- firmware/obc-app/src/effort.rs | 43 ++++++-------- firmware/obc-app/src/recorder.rs | 26 ++++++--- firmware/obc-app/src/recorder/continuation.rs | 17 +++--- firmware/obc-app/src/screen/map.rs | 2 +- firmware/obc-app/src/screen/ride_detail.rs | 4 +- firmware/obc-app/src/screen/statistics.rs | 2 +- firmware/obc-app/src/settings.rs | 4 +- firmware/obc-app/tests/cases/ride_recovery.rs | 14 +++-- firmware/obc-app/tests/cases/trips.rs | 27 ++++++--- firmware/obc-formats/src/ride.rs | 53 ++++++++++++------ firmware/obc-fw-nrf54l/README.md | 5 +- .../obc-fw-nrf54l/src/bin/flat_store_bench.rs | 2 +- firmware/obc-fw-nrf54l/src/demo_rides.rs | 5 +- firmware/obc-link/tests/flat_engine.rs | 2 +- .../obc-link/tests/flat_engine/recovery.rs | 20 +++---- firmware/obc-platform/src/debug_link.rs | 2 +- firmware/obc-route/src/ride.rs | 10 +++- firmware/obc-route/tests/cases/ride.rs | 7 ++- firmware/tools/resource_baseline.json | 8 ++- firmware/tools/ui_frames/environments.py | 13 +++-- host/obc-host-core/src/conformance.rs | 1 + host/obc-host-core/src/flat_recorder/tests.rs | 2 + host/obc-host-core/tests/board_ride.rs | 1 + host/obc-vectors/src/lib.rs | 21 ++++--- host/obc-vectors/tests/vectors.rs | 22 ++++---- specs/FLAT_Store_Format.md | 6 +- specs/obc-ble-interface-spec.md | 29 +++++++--- specs/vectors/README.md | 6 +- specs/vectors/manifest.json | 14 +++-- specs/vectors/{ride-v5.bin => ride-v6.bin} | Bin 210 -> 214 bytes 40 files changed, 271 insertions(+), 172 deletions(-) rename specs/vectors/{ride-v5.bin => ride-v6.bin} (59%) 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/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..5fdd60fcc 100644 --- a/builder/app/src/lib/device/library.test.ts +++ b/builder/app/src/lib/device/library.test.ts @@ -83,7 +83,7 @@ function rideObject(name: string, startTime: number, points = 24): RideObject { }); } return { - version: 5, + version: 6, 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..48a6d697d 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,7 @@ function rideFromTrackLog(log: Uint8Array, name: string, startTime: number): Rid }); } return { - version: 5, + version: 6, name, startTime, distanceM: 4210, @@ -130,7 +130,7 @@ function longRide(points: number): RideObject { }); } return { - version: 5, + version: 6, name: "Long Way Round", startTime: 1_783_598_400, distanceM: points * 8, @@ -189,7 +189,7 @@ function deviceWith(rides: RideObject[], options: LoopbackOptions & FlatDeviceOp describe("the exported GPX", () => { it("reproduces the native exporter byte-for-byte, pulled from the device", async () => { - const ride = { ...decodeRideObject(vector("ride-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 +225,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, diff --git a/builder/app/src/lib/usb/objects.ts b/builder/app/src/lib/usb/objects.ts index ebf5d40e3..30a33ece4 100644 --- a/builder/app/src/lib/usb/objects.ts +++ b/builder/app/src/lib/usb/objects.ts @@ -35,7 +35,7 @@ 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; @@ -65,9 +65,9 @@ export interface RideTrip { name: string; } -/** A downloaded v5 ride: the recorded sample bytes followed by one fixed summary footer. */ +/** 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; @@ -89,7 +89,7 @@ export interface RideObject { } /** - * 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 +100,8 @@ 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.`); + if (view.getUint16(footer + 6, true) !== RIDE_FOOTER_LEN || data[footer + 33] !== 0 || data[footer + 151] !== 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 +139,7 @@ export function decodeRideObject(data: Uint8Array): RideObject { } return { - version: 5, + version: 6, points, name, startTime: view.getUint32(footer + 8, true), @@ -171,7 +171,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, with no effort limits, 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 +188,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); 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/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..77379b5c9 100644 --- a/firmware/obc-app/src/screen/ride_detail.rs +++ b/firmware/obc-app/src/screen/ride_detail.rs @@ -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>| { 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 0def39b68..0cf180871 100644 --- a/firmware/obc-fw-nrf54l/README.md +++ b/firmware/obc-fw-nrf54l/README.md @@ -102,8 +102,9 @@ Select a probe with `PROBE_RS_PROBE=VID:PID:SERIAL`. BLE and USB are always enab For finished test rides, run `obc flash seed-rides` over J4. Wait for `demo rides: complete`. This adds three 30-minute GPS loops: GPS, heart rate, and heart rate with power, dated the -previous three days. Existing names are skipped. Existing objects remain; unformatted cards -and active recordings are refused. Press Ctrl-C, then run `obc flash` to restore normal firmware. The rides remain on the card. +previous three days. The two sensor rides record max HR 185 and FTP 250. Existing names are +skipped. Existing objects remain; unformatted cards and active recordings are refused. Press +Ctrl-C, then run `obc flash` to restore normal firmware. The rides remain on the card. | Feature | What it does | | :-- | :-- | diff --git a/firmware/obc-fw-nrf54l/src/bin/flat_store_bench.rs b/firmware/obc-fw-nrf54l/src/bin/flat_store_bench.rs index 45762439c..a7853dd1e 100644 --- a/firmware/obc-fw-nrf54l/src/bin/flat_store_bench.rs +++ b/firmware/obc-fw-nrf54l/src/bin/flat_store_bench.rs @@ -113,7 +113,7 @@ const COMMIT_SAMPLES: usize = 3; const RIDE_RESERVE: u64 = 32 * EXTENT_SIZE; /// The shipping recorder checkpoints every ten seconds. At the minimum one-second fix cadence that -/// is ten exact ride-v5 records, not an arbitrary byte-growth surrogate. +/// is ten exact ride records, not an arbitrary byte-growth surrogate. const SAMPLES_PER_CHECKPOINT: u32 = 10; const CHECKPOINT_SAMPLE_BYTES: usize = SAMPLES_PER_CHECKPOINT as usize * SAMPLE_LEN; /// Enough ten-sample checkpoints to cross the 16 KiB boundary and turn the 16-slot ring repeatedly. diff --git a/firmware/obc-fw-nrf54l/src/demo_rides.rs b/firmware/obc-fw-nrf54l/src/demo_rides.rs index 0d0a8ffae..50754f067 100644 --- a/firmware/obc-fw-nrf54l/src/demo_rides.rs +++ b/firmware/obc-fw-nrf54l/src/demo_rides.rs @@ -1,7 +1,7 @@ //! Development-only finished rides. Called before the board starts its storage writer. use obc_crc::Crc32; -use obc_formats::ride::{encode_footer, Footer, FOOTER_LEN, SAMPLE_LEN}; +use obc_formats::ride::{encode_footer, EffortLimits, Footer, FOOTER_LEN, SAMPLE_LEN}; use obc_formats::track::encode_record; use obc_ports::TrackPoint; use obc_storage::flat::{ @@ -132,6 +132,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); 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..f26d56e39 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) } 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..4ebcf0708 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -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", + "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. The App allocation is unchanged, because the limits sit in padding of the recorder's ride origin. 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,480 B linked resident inside the band, residual main stack 43,944 B, poll frame 9,864 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..4b9950908 100644 --- a/firmware/tools/ui_frames/environments.py +++ b/firmware/tools/ui_frames/environments.py @@ -62,19 +62,20 @@ 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, with no effort limits. `samples` are `(lon, lat, ele)`; `hr` is one heart + rate per sample or `None`; `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-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("