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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions firmware/obc-app/src/screen/map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions firmware/obc-app/src/screen/statistics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 31 additions & 14 deletions firmware/obc-app/src/screen/vocab/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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
}
Expand All @@ -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<const N: usize>(s: &mut heapless::String<N>, 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");
}
Expand All @@ -88,8 +88,8 @@ pub(crate) fn write_distance_coarse<const N: usize>(s: &mut heapless::String<N>,
/// every language.
pub(crate) fn write_distance_away<const N: usize>(s: &mut heapless::String<N>, 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}");
Expand All @@ -114,9 +114,9 @@ pub(crate) fn write_distance_spaced<const N: usize>(s: &mut heapless::String<N>,
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);
}
Expand Down Expand Up @@ -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]
Expand Down
5 changes: 3 additions & 2 deletions firmware/obc-app/src/stat_fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<str>, 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 }
}
}
Expand Down
3 changes: 2 additions & 1 deletion firmware/obc-route/src/matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
22 changes: 22 additions & 0 deletions firmware/obc-route/tests/matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
Loading