diff --git a/firmware/obc-app/src/screen/map.rs b/firmware/obc-app/src/screen/map.rs index ef574c1a1..ca42c2308 100644 --- a/firmware/obc-app/src/screen/map.rs +++ b/firmware/obc-app/src/screen/map.rs @@ -252,6 +252,8 @@ impl MapScreen { if warning_up { if rx.no_fix { draw_status_chip(cv, rx.w, rx.h, rx.t(Msg::MapNoGpsFix)); + } else if rx.navigation.dist_to_route_m == u32::MAX { + draw_status_chip(cv, rx.w, rx.h, rx.t(Msg::MapOffRoute).trim_end()); } else { let mut s: heapless::String<20> = heapless::String::new(); super::vocab::fmt::write_distance_coarse( @@ -1094,6 +1096,92 @@ mod tests { use crate::screen::{Screen, Transition}; use crate::Settings; + #[test] + fn unavailable_route_geometry_renders_only_the_existing_off_route_labels() { + use crate::harness::support::{build_min_obcm, Buf, OnceFix}; + use crate::screen::{apply, StatisticsScreen}; + use embedded_graphics::pixelcolor::Rgb888; + use obc_formats::io::{ByteSource, Error, SliceSource}; + use obc_ports::{Fix, RideClock, Sensors}; + use obc_route::{RouteIndex, RouteReader}; + + struct Unreadable; + impl ByteSource for Unreadable { + fn len(&self) -> u64 { + 4096 + } + fn read_at(&self, _: u64, _: &mut [u8]) -> Result<(), Error> { + Err(Error::Io) + } + } + let bytes = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../specs/vectors/route-plain.obcr")); + let source = SliceSource(bytes); + let index = RouteIndex::read(&source).unwrap(); + let good = RouteReader::new(&index, &source); + let empty = RouteIndex::empty(); + let map_bytes = build_min_obcm(0); + let map_source = SliceSource(&map_bytes); + let tables = obc_reader::MapTables::parse(&map_source).unwrap(); + let cache = obc_reader::MapCache::new(); + let map = obc_reader::Reader::new(&map_source, &tables, &cache); + let color = |c| { + let (r, g, b) = obc_reader::rgb565_to_rgb888(c); + Rgb888::new(r, g, b) + }; + for route in [RouteReader::new(&empty, &source), RouteReader::new(&index, &Unreadable)] { + for units in [Units::Metric, Units::Imperial] { + let mut app = crate::App::new(crate::AppState::new(0, 0, 0.05)); + app.set_settings(Settings { units, ..Settings::default() }); + let fix = Fix::at(good.start_lat, good.start_lon); + app.tick(RideClock(0), Sensors::new(&mut OnceFix(Some(fix))), None); + app.navigator.set_active_route(Some(0)); + app.navigator.refresh_route_profile(Some(&good)); + app.navigator.match_fix(fix, &route); + assert!(app.navigator.route_state().off_route); + assert_eq!(app.navigator.route_state().dist_to_route_m, u32::MAX); + for statistics in [false, true] { + let screen = if statistics { + Screen::Statistics(StatisticsScreen::new()) + } else { + Screen::Map(MapScreen::new()) + }; + apply(&mut app.ui.stack, Transition::Root(screen)); + let mut actual = Buf::new(240, 320); + let mut scratch = Box::new(obc_render::RenderScratch::new()); + app.render_frame(Some(&mut scratch), &mut actual, &map, Some(&route), 240.0, 320.0, color); + let mut expected = Buf::new(240, 320); + let mut canvas = Canvas::new(&mut expected, &color); + let rows = if statistics { + super::super::vocab::chrome::title_frame( + &mut canvas, + 240, + 320, + crate::t(Msg::StatsTitle, app.settings().language), + crate::t(Msg::StatsOff, app.settings().language).trim_end(), + ); + 8..28 + } else { + draw_status_chip( + &mut canvas, + 240, + 320, + crate::t(Msg::MapOffRoute, app.settings().language).trim_end(), + ); + (320 - CHIP_H - CHIP_MARGIN + 10)..(320 - CHIP_MARGIN - 10) + }; + // The whole text row must match the label-only chrome, including its centering. + for y in rows { + for x in 10..230 { + if statistics || (60..180).contains(&x) { + assert_eq!(actual.get(x, y), expected.get(x, y), "label at ({x}, {y})"); + } + } + } + } + } + } + } + #[test] fn translated_hint_pills_pad_actual_ink_evenly() { use crate::harness::support::Buf; diff --git a/firmware/obc-app/src/screen/statistics.rs b/firmware/obc-app/src/screen/statistics.rs index 8eada052e..5bfaf8130 100644 --- a/firmware/obc-app/src/screen/statistics.rs +++ b/firmware/obc-app/src/screen/statistics.rs @@ -286,6 +286,8 @@ impl StatisticsScreen { let mut readout: heapless::String<16> = heapless::String::new(); if rx.no_fix { let _ = readout.push_str(rx.t(Msg::StatsNoGps)); + } else if off && rx.navigation.dist_to_route_m == u32::MAX { + let _ = readout.push_str(rx.t(Msg::StatsOff).trim_end()); } else if off { write_distance_coarse(&mut readout, rx.t(Msg::StatsOff), rx.navigation.dist_to_route_m, units); } else { diff --git a/firmware/obc-app/src/screen/vocab/fmt.rs b/firmware/obc-app/src/screen/vocab/fmt.rs index dd9b367d8..67cdd462f 100644 --- a/firmware/obc-app/src/screen/vocab/fmt.rs +++ b/firmware/obc-app/src/screen/vocab/fmt.rs @@ -38,18 +38,18 @@ pub(crate) fn distance_figure(value: f32) -> heapless::String<8> { /// chip, the Up ahead rows, the detour figures. Metric: `NNNm` below 1 km, `N.Nkm` to one decimal /// below 100 km, whole `NNNkm` above. Imperial: `NNNft` below 1000 ft, `N.Nmi` below 100 mi, whole /// `NNNmi` above. Rounds to the readout's own grain (nearest tenth / whole). -pub(crate) fn distance_short(d_m: u32, units: Units) -> heapless::String<8> { +pub(crate) fn distance_short(d_m: u32, units: Units) -> heapless::String<10> { let mut s = heapless::String::new(); if units.is_imperial() { - let ft = (d_m as f32 * FT_PER_M) as u32; + let ft = (d_m as f32 * FT_PER_M) as u64; if ft < 1000 { let _ = write!(s, "{ft}ft"); - } else if ft < 100 * FT_PER_MI { + } else if ft < 100 * u64::from(FT_PER_MI) { // One decimal mile, rounded to the nearest tenth. - let tenths = (ft * 10 + FT_PER_MI / 2) / FT_PER_MI; + let tenths = (ft * 10 + u64::from(FT_PER_MI) / 2) / u64::from(FT_PER_MI); let _ = write!(s, "{}.{}mi", tenths / 10, tenths % 10); } else { - let _ = write!(s, "{}mi", (ft + FT_PER_MI / 2) / FT_PER_MI); + let _ = write!(s, "{}mi", (ft + u64::from(FT_PER_MI) / 2) / u64::from(FT_PER_MI)); } } else if d_m < 1000 { let _ = write!(s, "{d_m}m"); @@ -58,7 +58,7 @@ pub(crate) fn distance_short(d_m: u32, units: Units) -> heapless::String<8> { let tenths = (d_m + 50) / 100; let _ = write!(s, "{}.{}km", tenths / 10, tenths % 10); } else { - let _ = write!(s, "{}km", (d_m + 500) / 1000); + let _ = write!(s, "{}km", (u64::from(d_m) + 500) / 1000); } s } @@ -69,14 +69,14 @@ pub(crate) fn distance_short(d_m: u32, units: Units) -> heapless::String<8> { /// Map's off-route pill, the POI distance columns and the Up ahead side hint. pub(crate) fn write_distance_coarse(s: &mut heapless::String, prefix: &str, d_m: u32, units: Units) { if units.is_imperial() { - let ft = (d_m as f32 * FT_PER_M) as u32; - if ft >= FT_PER_MI { - let _ = write!(s, "{prefix}{}mi", (ft + FT_PER_MI / 2) / FT_PER_MI); + let ft = (d_m as f32 * FT_PER_M) as u64; + if ft >= u64::from(FT_PER_MI) { + let _ = write!(s, "{prefix}{}mi", (ft + u64::from(FT_PER_MI) / 2) / u64::from(FT_PER_MI)); } else { let _ = write!(s, "{prefix}{ft}ft"); } } else if d_m >= 1000 { - let _ = write!(s, "{prefix}{}km", (d_m + 500) / 1000); + let _ = write!(s, "{prefix}{}km", (u64::from(d_m) + 500) / 1000); } else { let _ = write!(s, "{prefix}{d_m}m"); } @@ -88,8 +88,8 @@ pub(crate) fn write_distance_coarse(s: &mut heapless::String, /// every language. pub(crate) fn write_distance_away(s: &mut heapless::String, d_m: u32, units: Units, away: &str) { if units.is_imperial() { - let ft = (d_m as f32 * FT_PER_M) as u32; - if ft >= FT_PER_MI { + let ft = (d_m as f32 * FT_PER_M) as u64; + if ft >= u64::from(FT_PER_MI) { let _ = write!(s, "{:.1} mi {away}", ft as f32 / FT_PER_MI as f32); } else { let _ = write!(s, "{ft} ft {away}"); @@ -114,9 +114,9 @@ pub(crate) fn write_distance_spaced(s: &mut heapless::String, let _ = write!(s, "{}.{} mi", mi10 / 10, mi10 % 10); } } else { - let km10 = (dist_m + 50) / 100; // tenths of a km + let km10 = (u64::from(dist_m) + 50) / 100; // tenths of a km if km10 >= 1000 { - let _ = write!(s, "{} km", (dist_m + 500) / 1000); + let _ = write!(s, "{} km", (u64::from(dist_m) + 500) / 1000); } else { let _ = write!(s, "{}.{} km", km10 / 10, km10 % 10); } @@ -399,6 +399,23 @@ mod tests { assert_eq!(distance_short(200_000, Units::Imperial).as_str(), "124mi", "well past 100 mi is whole miles"); } + #[test] + fn distance_rounding_preserves_large_u32_values() { + for metres in [u32::MAX - 1, u32::MAX] { + for (units, short, spaced) in + [(Units::Metric, "4294967km", "4294967 km"), (Units::Imperial, "2668769mi", "2668769 mi")] + { + assert_eq!(distance_short(metres, units).as_str(), short); + let mut value: heapless::String<24> = heapless::String::new(); + write_distance_coarse(&mut value, "", metres, units); + assert_eq!(value.as_str(), short); + value.clear(); + write_distance_spaced(&mut value, metres, units); + assert_eq!(value.as_str(), spaced); + } + } + } + /// The coarse chip readout compacts straight to a whole large unit — no decimal band at all, /// which is exactly what separates it from [`distance_short`]. #[test] diff --git a/firmware/obc-app/src/stat_fields.rs b/firmware/obc-app/src/stat_fields.rs index ea6e0ddee..b8c92bd98 100644 --- a/firmware/obc-app/src/stat_fields.rs +++ b/firmware/obc-app/src/stat_fields.rs @@ -416,7 +416,7 @@ fn next_of_category<'a>(cat: PoiCategory, cx: &'a Readout<'a>) -> Option<(u32, & /// waypoint name fits; the tile drawer ellipsis-truncates one that overflows the tile width. pub struct StatCell { pub caption: heapless::String<24>, - pub value: heapless::String<8>, + pub value: heapless::String<10>, pub arrow: bool, /// Where the value sits in the tile: [`Left`](TextAlign::Left) for the number-only built-in /// fields, [`Right`](TextAlign::Right) for the wide [`NextWaypoint`](StatField::NextWaypoint) @@ -425,7 +425,8 @@ pub struct StatCell { } impl StatCell { - fn new(caption: heapless::String<24>, value: heapless::String<8>, arrow: bool) -> Self { + fn new(caption: heapless::String<24>, value: impl AsRef, arrow: bool) -> Self { + let value = heapless::String::try_from(value.as_ref()).expect("stat values fit the value buffer"); StatCell { caption, value, arrow, value_align: TextAlign::Left } } } diff --git a/firmware/obc-route/src/matcher.rs b/firmware/obc-route/src/matcher.rs index 8be69a85f..692f48009 100644 --- a/firmware/obc-route/src/matcher.rs +++ b/firmware/obc-route/src/matcher.rs @@ -50,7 +50,8 @@ pub struct Match { /// threshold). pub off_route: bool, /// Cross-track distance from the fix to the nearest route point (m) — always live, so - /// the UI can show "off route · NNN m". + /// the UI can show "off route · NNN m". `u32::MAX` means no usable segment was + /// decoded (including a source read failure); no numeric distance is available. pub dist_m: u32, } diff --git a/firmware/obc-route/tests/matcher.rs b/firmware/obc-route/tests/matcher.rs index 1f144b8c9..6aad8b67e 100644 --- a/firmware/obc-route/tests/matcher.rs +++ b/firmware/obc-route/tests/matcher.rs @@ -634,3 +634,25 @@ fn progress_at_a_chunk_seam_equals_cum_distance() { seam_dist ); } + +#[test] +fn missing_or_unreadable_segments_report_unavailable_cross_track_distance() { + use obc_formats::io::{ByteSource, Error}; + struct Unreadable; + impl ByteSource for Unreadable { + fn len(&self) -> u64 { + 4096 + } + fn read_at(&self, _: u64, _: &mut [u8]) -> Result<(), Error> { + Err(Error::Io) + } + } + let bytes = convert("East", &gpx_from(EAST)); + let source = SliceSource(&bytes); + let index = RouteIndex::read(&source).unwrap(); + let empty = RouteIndex::empty(); + for route in [RouteReader::new(&empty, &source), RouteReader::new(&index, &Unreadable)] { + let result = RouteMatch::new().update(7_805_000, 48_000_000, &route); + assert_eq!(result, obc_route::Match { progress_m: 0, off_route: true, dist_m: u32::MAX }); + } +}