From 16a8bc27830054776349a93ebefdfe181a882b11 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 19:41:32 +0200 Subject: [PATCH 01/13] fix(navigation): follow backtracking and route to nearest join --- firmware/obc-app/i18n/de.toml | 5 +- firmware/obc-app/i18n/en.toml | 5 +- firmware/obc-app/i18n/es.toml | 5 +- firmware/obc-app/i18n/fr.toml | 5 +- firmware/obc-app/src/activity.rs | 8 +- firmware/obc-app/src/app.rs | 3 +- firmware/obc-app/src/navigator.rs | 10 +- firmware/obc-app/src/navigator/following.rs | 92 ++++++++++----- firmware/obc-app/src/screen/nav_route.rs | 4 +- firmware/obc-app/src/screen/start_away.rs | 98 +++++++++------- firmware/obc-app/tests/cases/detour_flow.rs | 120 +++++++++++++++----- firmware/obc-route/src/matcher.rs | 99 +++++++++------- firmware/obc-route/src/splice.rs | 23 ++-- firmware/obc-route/tests/cases/detour.rs | 44 +++++++ firmware/obc-route/tests/cases/matcher.rs | 98 ++++++++++++---- firmware/ui-frames.toml | 2 +- firmware/ui-snapshots.sha256 | 6 +- 17 files changed, 434 insertions(+), 193 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index 0f5f29cee..0ac5f9d93 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -459,10 +459,11 @@ not_found_hint = "Kein Weg in der Nähe - an einer Straße starten." # --- Start away from the route start: Ride to start or Join nearest --- [start_away] -title = "ZUM START" +title = "ROUTE ERREICHEN" you_are = "Du bist " from_start = " vom Start entfernt." -no_route = "Keine Route zum Start von hier." +no_route = "Keine Verbindung von hier." +start_without = "Trotzdem starten" ride_to_start = "Zum Start fahren" then_route = ", dann Route" join_nearest = "Nächster Einstieg" diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 5acdd7dca..aaa718bc8 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -493,10 +493,11 @@ not_found_hint = "No rideable way nearby - try from a road." # hint are fragments around a distance: `you_are` + "3.2 km" + `from_start`, and # "400 m" + `away_at` + "km 12". --- [start_away] -title = "RIDE TO START" +title = "JOIN ROUTE" you_are = "You are " from_start = " from the start." -no_route = "No route to the start from here." +no_route = "No connecting route from here." +start_without = "Start anyway" ride_to_start = "Ride to start" then_route = ", then route" join_nearest = "Join nearest" diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index b89e9d5ef..c84781b7a 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -458,10 +458,11 @@ not_found_hint = "No hay camino cerca - prueba desde una carretera." # --- Start away from the route start: Ride to start or Join nearest --- [start_away] -title = "IR AL INICIO" +title = "UNIRSE A LA RUTA" you_are = "Estás a " from_start = " del inicio." -no_route = "No hay ruta al inicio desde aquí." +no_route = "No hay conexión desde aquí." +start_without = "Iniciar sin guía" ride_to_start = "Ir al inicio" then_route = ", luego la ruta" join_nearest = "Punto más cercano" diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 4cb4e004a..9b134b7e7 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -458,10 +458,11 @@ not_found_hint = "Aucune voie proche - partez d'une route." # --- Start away from the route start: Ride to start or Join nearest --- [start_away] -title = "VERS LE DÉPART" +title = "REJOINDRE LE TRACÉ" you_are = "Vous êtes à " from_start = " du départ." -no_route = "Pas de parcours vers le départ d'ici." +no_route = "Pas de liaison depuis ici." +start_without = "Partir quand même" ride_to_start = "Aller au départ" then_route = ", puis parcours" join_nearest = "Rejoindre le tracé" diff --git a/firmware/obc-app/src/activity.rs b/firmware/obc-app/src/activity.rs index 072e1f483..7d2e86c1c 100644 --- a/firmware/obc-app/src/activity.rs +++ b/firmware/obc-app/src/activity.rs @@ -41,14 +41,14 @@ pub struct DetourRequest { /// The chosen rejoin distance along the route — the corridor's end and the splice point. pub target_m: u32, /// What the leg does to the route. An approach has no corridor and no trim, and its splice puts - /// it in front of the whole route. + /// it in front of the route from the join point. pub leg: obc_route::Leg, } impl DetourRequest { - /// Ride to start: the way from `from` to the start of `route`. - pub fn approach(route: usize, from: (i32, i32)) -> Self { - DetourRequest { route, from, progress_m: 0, target_m: 0, leg: obc_route::Leg::Approach } + /// Connect `from` to `target_m` on the stored route. + pub fn approach(route: usize, from: (i32, i32), target_m: u32) -> Self { + DetourRequest { route, from, progress_m: target_m, target_m, leg: obc_route::Leg::Approach } } /// The rest of the day before `route`, `[from_m, to_m]` on that day's route, then `route` from diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 0dfdc76c4..b8ddf47b9 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1729,8 +1729,7 @@ impl App { self.ui.map_dirty = true; } - /// A lead-in found no way: drop the spinner at `slot`. Ride to start leaves the prompt it came - /// from with Join nearest and Cancel. + /// A lead-in failed: drop the spinner and offer an unguided start on the original route. fn land_lead_in_failure(&mut self, slot: usize) { self.ui.stack.truncate(slot.max(1)); if let Some(Screen::StartAway(prompt)) = self.ui.stack.last_mut() { diff --git a/firmware/obc-app/src/navigator.rs b/firmware/obc-app/src/navigator.rs index 4b7a93f06..ecf3d2552 100644 --- a/firmware/obc-app/src/navigator.rs +++ b/firmware/obc-app/src/navigator.rs @@ -344,6 +344,7 @@ pub struct NavigatorMachine { climbs_route: Option, waypoints: Waypoints, waypoints_route: Option, + waypoints_from_m: u32, /// The loaded route whose bike type the settings do not have yet; see /// [`take_loaded_bike_type`](Self::take_loaded_bike_type). bike_type_owed: Option, @@ -353,8 +354,6 @@ pub struct NavigatorMachine { /// The one route matcher and the active-route key it last locked to. route_match: RouteMatch, matched_route: Option, - /// Where the next fresh ride joins the active route, instead of where its first fix locks. - join_m: Option, /// Where the rider stood on the route at Finish, which unloads it before the store confirms the /// save. ride_end: Option, @@ -387,13 +386,13 @@ impl NavigatorMachine { climbs_route: None, waypoints: Waypoints::new(), waypoints_route: None, + waypoints_from_m: 0, bike_type_owed: None, climb_profile: ClimbProfile::new(), #[cfg(test)] climb_fill_count: 0, route_match: RouteMatch::new(), matched_route: None, - join_m: None, ride_end: None, } ); @@ -786,12 +785,12 @@ impl NavigatorMachine { climbs_route, waypoints, waypoints_route, + waypoints_from_m, bike_type_owed, climb_profile, climb_fill_count, route_match, matched_route, - join_m, ride_end, } = self; assert_eq!(review.status, ReviewStatus::Idle); @@ -806,11 +805,12 @@ impl NavigatorMachine { assert!(profile.is_none() && profile_route.is_none(), "no elevation profile cached"); assert!(climbs.is_empty() && climbs_route.is_none(), "no climbs before a route loads"); assert!(waypoints.is_empty() && waypoints_route.is_none(), "no waypoints before a route loads"); + assert_eq!(*waypoints_from_m, 0); assert!(bike_type_owed.is_none(), "no route has been loaded"); assert!(climb_profile.cols().iter().all(|&column| column == 0), "the climb detail starts flat"); assert_eq!(*climb_fill_count, 0, "the climb detail has not been filled"); assert!(!route_match.started() && matched_route.is_none(), "the matcher is unlocked"); - assert!(join_m.is_none() && ride_end.is_none(), "no ride waits to join or has ended"); + assert!(ride_end.is_none(), "no ride has ended"); } } diff --git a/firmware/obc-app/src/navigator/following.rs b/firmware/obc-app/src/navigator/following.rs index 3540d29c3..5879496aa 100644 --- a/firmware/obc-app/src/navigator/following.rs +++ b/firmware/obc-app/src/navigator/following.rs @@ -117,7 +117,9 @@ const CLIMB_EXIT_MARGIN_M: u32 = 30; fn resolve_active_climb(climbs: &Climbs, progress: u32, prev: Option) -> Option { if let Some(i) = prev { if let Some(seg) = climbs.as_slice().get(i) { - if progress <= seg.end_m.saturating_add(CLIMB_EXIT_MARGIN_M) { + if progress >= seg.start_m.saturating_sub(CLIMB_ENTER_MARGIN_M + CLIMB_EXIT_MARGIN_M) + && progress <= seg.end_m.saturating_add(CLIMB_EXIT_MARGIN_M) + { return Some(i); } } @@ -134,16 +136,21 @@ pub(crate) const WAYPOINT_LINGER_M: u32 = 100; /// The next-waypoint index, as a pure function of the resident table, the matched progress and the /// previously-resolved index. The next waypoint is the first entry whose linger band is still open. -/// `prev` only keeps the index from regressing on a progress dip; `None` once every one is passed. +/// Hold passed waypoints through GPS jitter, but restore them when the rider backtracks. fn resolve_next_waypoint(wpts: &Waypoints, progress_m: u32, prev: Option) -> Option { let ahead = wpts.as_slice().iter().position(|w| progress_m < w.dist_along_m.saturating_add(WAYPOINT_LINGER_M)); match ahead { // Past every waypoint's linger — the chip / fields go empty even if one was held. None => None, - // Hold the furthest-reached index against a jittering cursor (never un-pass a waypoint). - // A stale `prev` (≥ len, after a table shrink) falls through to `a`. + // The entry and exit bands differ, so a small progress dip cannot flap the chip. Some(a) => match prev { - Some(p) if p > a && p < wpts.len() => Some(p), + Some(p) + if p > a + && p < wpts.len() + && progress_m >= wpts.as_slice()[a].dist_along_m.saturating_sub(WAYPOINT_LINGER_M) => + { + Some(p) + } _ => Some(a), }, } @@ -217,21 +224,11 @@ impl NavigatorMachine { &self.waypoints } - /// Make the next fresh ride follow the active route from `progress_m`: a forward-only floor - /// there, as a seam gives. - pub(crate) fn join_at(&mut self, progress_m: u32) { - self.join_m = Some(progress_m); - } - /// Start a fresh route-following pass for a new ride session while keeping the selected route. pub(crate) fn reset_ride(&mut self) { self.route_match.reset(); self.ride_end = None; - self.following.seam_request = self - .join_m - .take() - .zip(self.following.active_route) - .map(|(anchor_m, route)| SeamRequest { route: RouteIndex::new(route), anchor_m }); + self.following.seam_request = None; self.following.progress_m = 0; self.following.off_route = false; self.following.dist_to_route_m = 0; @@ -297,6 +294,7 @@ impl NavigatorMachine { let loaded = self.following.active_route.zip(route); self.waypoints = loaded.map_or_else(Waypoints::new, |(_, r)| r.load_waypoints(0)); self.waypoints_route = loaded.map(|(index, _)| index); + self.waypoints_from_m = 0; self.following.next_waypoint = None; // a fresh table — re-derive the next waypoint on the next match } self.following.waypoint_count = self.waypoints.len(); @@ -390,24 +388,25 @@ impl NavigatorMachine { Some((prev, next)) } - /// Recompute the next waypoint from the freshly-matched progress, and slide a truncated table's - /// window forward when the rider passes its tail. Returns whether the next waypoint changed. - /// - /// The re-window is gated on [`truncated`](obc_route::Waypoints), so a normal route never - /// re-streams, and it starts strictly past the old window, so it cannot re-fire next tick. - /// Off-route, `progress_m` is frozen, so the index self-freezes and is left alone. + /// Recompute the next waypoint and refill a resident window when progress leaves its span. + /// Off-route fixes freeze both progress and the window. pub(crate) fn update_next_waypoint(&mut self, route: &RouteReader) -> bool { if self.following.off_route { return false; } - // Slide a truncated window forward once its whole resident span is behind the rider. - if self.waypoints.truncated { - if let Some(last) = self.waypoints.as_slice().last() { - if self.following.progress_m >= last.dist_along_m.saturating_add(WAYPOINT_LINGER_M) { - self.waypoints = route.load_waypoints(self.following.progress_m); - self.following.next_waypoint = None; // the window slid — re-derive against it below - } - } + let progress = self.following.progress_m; + let before = progress < self.waypoints_from_m; + let after = self.waypoints.truncated + && self + .waypoints + .as_slice() + .last() + .is_some_and(|w| progress >= w.dist_along_m.saturating_add(WAYPOINT_LINGER_M)); + if before || after { + // Keep room behind the rider so backward travel does not reload the table every fix. + self.waypoints_from_m = if before { progress.saturating_sub(1_000) } else { progress }; + self.waypoints = route.load_waypoints(self.waypoints_from_m); + self.following.next_waypoint = None; } self.following.waypoint_count = self.waypoints.len(); let prev = self.following.next_waypoint; @@ -466,6 +465,7 @@ impl NavigatorMachine { self.following.active_climb = None; self.waypoints = Waypoints::new(); self.waypoints_route = None; + self.waypoints_from_m = 0; self.following.next_waypoint = None; self.following.waypoint_count = 0; self.following.progress_m = 0; @@ -509,6 +509,7 @@ impl NavigatorMachine { self.waypoints_route = old_wpts.and_then(remap); if old_wpts.is_some() && self.waypoints_route.is_none() { self.waypoints = Waypoints::new(); + self.waypoints_from_m = 0; self.following.next_waypoint = None; self.following.waypoint_count = 0; } @@ -704,6 +705,37 @@ mod tests { assert_eq!(resolve_active_climb(&cs, 3040, Some(0)), Some(1)); } + #[test] + fn backtracking_restores_earlier_climbs_and_waypoints() { + let cs = climbs(&[(1000, 3000), (4000, 6000)]); + assert_eq!(resolve_active_climb(&cs, 2500, Some(1)), Some(0)); + assert_eq!(resolve_active_climb(&cs, 800, Some(0)), None); + let w = wpts(&[(1000, "A"), (2000, "B")]); + assert_eq!(resolve_next_waypoint(&w, 1100, Some(0)), Some(1)); + assert_eq!(resolve_next_waypoint(&w, 1050, Some(1)), Some(1)); + assert_eq!(resolve_next_waypoint(&w, 850, Some(1)), Some(0)); + assert_eq!(resolve_next_waypoint(&w, 1800, None), Some(1)); + } + + #[test] + fn backtracking_restores_waypoints_from_an_earlier_window() { + use obc_formats::io::SliceSource; + let bytes = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../specs/vectors/route-waypoints.obcr")); + let source = SliceSource(bytes); + let index = obc_route::RouteIndex::read(&source).unwrap(); + let route = RouteReader::new(&index, &source); + let mut nav = NavigatorMachine::new(); + nav.waypoints_from_m = route.total_distance_m; + nav.waypoints = route.load_waypoints(nav.waypoints_from_m); + nav.following.progress_m = 0; + nav.update_next_waypoint(&route); + assert_eq!(nav.waypoints_from_m, 0); + assert_eq!(nav.waypoints.len(), route.load_waypoints(0).len()); + assert!(!nav.waypoints.is_empty()); + assert_eq!(nav.following.next_waypoint, Some(0)); + assert!(!nav.update_next_waypoint(&route)); + } + /// A stale index (the list shrank under the previous active climb) does not strand the resolver: /// it re-arms from scratch. #[test] diff --git a/firmware/obc-app/src/screen/nav_route.rs b/firmware/obc-app/src/screen/nav_route.rs index b7ca35550..990508158 100644 --- a/firmware/obc-app/src/screen/nav_route.rs +++ b/firmware/obc-app/src/screen/nav_route.rs @@ -23,7 +23,7 @@ pub enum PlanKind { Nav, /// The mid-ride detour plan, which pops to the Detour chooser. Detour, - /// The way to a route's start, which pops to the Start-away prompt. + /// The way to a route join point, which pops to the Start-away prompt. Approach, /// The rest of the day before and the next day, which the start card asked for. Day, @@ -58,7 +58,7 @@ impl NavPlanningScreen { NavPlanningScreen { kind: PlanKind::Detour, name: heapless::String::new(), spin: Spinner::default() } } - /// The planning screen for Ride to start. Its plan is a detour-family search, so it cancels + /// The planning screen for either route connection. Its plan is a detour-family search, so it cancels /// like one. pub fn approach() -> Self { NavPlanningScreen { kind: PlanKind::Approach, name: heapless::String::new(), spin: Spinner::default() } diff --git a/firmware/obc-app/src/screen/start_away.rs b/firmware/obc-app/src/screen/start_away.rs index d369320c1..6678e12f0 100644 --- a/firmware/obc-app/src/screen/start_away.rs +++ b/firmware/obc-app/src/screen/start_away.rs @@ -1,7 +1,6 @@ -//! The Start-away prompt: START RIDE pressed with a fix more than [`START_AWAY_M`] from the route's -//! start. Ride to start plans the way there and rides it in front of the route; Join nearest follows -//! the route from its nearest point. Every distance on the page is a straight line from the fix at -//! the press, so the page shows at once, without planning. +//! Choose a connecting route to the start or the nearest point of the stored route. +//! Distances are straight-line estimates from the fix at Press. A failed plan leaves an explicit +//! option to start without connecting directions. use core::fmt::Write; @@ -20,15 +19,11 @@ use obc_render::{rect, text::Font, Surface}; /// Straight-line metres from the route start past which START RIDE asks how to begin. pub(crate) const START_AWAY_M: u32 = 200; -/// On the first prompt, Join nearest shows only when its point is this far along the route. Nearer -/// the start it is the same as Ride to start. Once Ride to start has failed, it always shows, so a -/// row that starts the ride is always there. -const JOIN_MIN_M: u32 = 1_000; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Row { RideToStart, JoinNearest, + StartWithout, Cancel, } @@ -47,8 +42,8 @@ pub struct StartAwayScreen { from: (i32, i32), start_m: u32, nearest: Nearest, - /// Ride to start found no way, so only Join nearest and Cancel are left. - no_route: bool, + failed_start: bool, + failed_join: bool, selected: Row, } @@ -65,7 +60,8 @@ impl StartAwayScreen { from, start_m, nearest: Nearest::Pending, - no_route: false, + failed_start: false, + failed_join: false, selected: Row::RideToStart, }) } @@ -74,17 +70,29 @@ impl StartAwayScreen { #[cfg(test)] pub(crate) fn sample(no_route: bool) -> Self { let nearest = Nearest::Found { dist_m: 4_100, along_m: 12_000 }; - StartAwayScreen { route: 0, from: (0, 0), start_m: 3_200, nearest, no_route, selected: Row::RideToStart } + StartAwayScreen { + route: 0, + from: (0, 0), + start_m: 3_200, + nearest, + failed_start: no_route, + failed_join: no_route, + selected: Row::RideToStart, + } } pub(crate) fn route(&self) -> usize { self.route } - /// Ride to start found no way: leave Join nearest and Cancel, the cursor on the first. + /// The selected connection failed. Keep the other connection and offer an unguided start. pub(crate) fn set_no_route(&mut self) { - self.no_route = true; - self.selected = self.rows()[0]; + match self.selected { + Row::RideToStart => self.failed_start = true, + Row::JoinNearest => self.failed_join = true, + _ => {} + } + self.selected = Row::StartWithout; } fn join(&self) -> Option<(u32, u32)> { @@ -96,12 +104,15 @@ impl StartAwayScreen { fn rows(&self) -> heapless::Vec { let mut rows = heapless::Vec::new(); - if !self.no_route { + if !self.failed_start { let _ = rows.push(Row::RideToStart); } - if self.no_route || self.join().is_some_and(|(_, along_m)| along_m >= JOIN_MIN_M) { + if !self.failed_join && self.join().is_some_and(|(_, along_m)| along_m >= START_AWAY_M) { let _ = rows.push(Row::JoinNearest); } + if self.failed_start || self.failed_join { + let _ = rows.push(Row::StartWithout); + } let _ = rows.push(Row::Cancel); rows } @@ -111,14 +122,14 @@ impl StartAwayScreen { rows.iter().position(|&r| r == self.selected).unwrap_or(0) } - /// Project the fix onto the whole route once the geometry is open. A later point wins only when - /// it is more than [`START_AWAY_M`] nearer, so a loop or an out-and-back joins its early pass. + /// Project onto the whole route. The live matcher's GPS tolerance breaks ties toward the + /// earliest occurrence of repeated geometry. pub(crate) fn prepare(&mut self, px: &mut Prepare) { if self.nearest != Nearest::Pending || px.active_route != Some(self.route) { return; } let Some(route) = px.route else { return }; - self.nearest = match RouteMatch::nearest(self.from.0, self.from.1, route, START_AWAY_M as f32) { + self.nearest = match RouteMatch::nearest(self.from.0, self.from.1, route, 8.0) { Some(m) => Nearest::Found { dist_m: m.dist_m, along_m: m.progress_m }, None => Nearest::Missing, }; @@ -133,22 +144,28 @@ impl StartAwayScreen { Transition::None } Gesture::Press => match rows[i] { - // Without a routing graph the device never plans, so the answer is known at once. - Row::RideToStart if !cx.state.has_nav_graph => { - self.set_no_route(); - Transition::None - } - Row::RideToStart => { - cx.navigator - .admit_intent(NavigatorIntent::PlanDetour(DetourRequest::approach(self.route, self.from))); - Transition::Push(Screen::NavPlanning(NavPlanningScreen::approach())) - } - Row::JoinNearest => { - if let Some((_, along_m)) = self.join() { - cx.navigator.join_at(along_m); + Row::RideToStart | Row::JoinNearest => { + let target_m = if rows[i] == Row::JoinNearest { + let Some((dist_m, along_m)) = self.join() else { return Transition::None }; + if dist_m < 15 { + return super::start_ride(cx, self.route); + } + along_m + } else { + 0 + }; + if !cx.state.has_nav_graph { + self.failed_start = true; + self.failed_join = true; + self.set_no_route(); + return Transition::None; } - super::start_ride(cx, self.route) + cx.navigator.admit_intent(NavigatorIntent::PlanDetour(DetourRequest::approach( + self.route, self.from, target_m, + ))); + Transition::Push(Screen::NavPlanning(NavPlanningScreen::approach())) } + Row::StartWithout => super::start_ride(cx, self.route), Row::Cancel => Transition::Pop, }, Gesture::Back => Transition::Pop, @@ -164,7 +181,7 @@ impl StartAwayScreen { let units = rx.settings.units; let mut question: heapless::String<64> = heapless::String::new(); - if self.no_route { + if self.failed_start || self.failed_join { let _ = question.push_str(rx.t(Msg::StartAwayNoRoute)); } else { let _ = question.push_str(rx.t(Msg::StartAwayYouAre)); @@ -195,6 +212,7 @@ impl StartAwayScreen { Row::JoinNearest => { PromptOption { label: rx.t(Msg::StartAwayJoinNearest), hint: join_hint, guard: false } } + Row::StartWithout => PromptOption { label: rx.t(Msg::StartAwayStartWithout), hint: None, guard: false }, Row::Cancel => PromptOption { label: rx.t(Msg::StartAwayCancel), hint: None, guard: false }, }; let _ = options.push(option); @@ -251,17 +269,17 @@ mod tests { let cx = Ctx { routes: &routes, ..test_ctx(&mut state, &mut activity, &mut settings) }; let mut prompt = StartAwayScreen::ask(&cx, 0).unwrap(); assert_eq!(prompt.rows().as_slice(), [Row::RideToStart, Row::Cancel], "no Join row before the projection"); - prompt.nearest = Nearest::Found { dist_m: 400, along_m: JOIN_MIN_M - 1 }; + prompt.nearest = Nearest::Found { dist_m: 400, along_m: START_AWAY_M - 1 }; assert_eq!(prompt.rows().as_slice(), [Row::RideToStart, Row::Cancel], "a join near the start is Ride to start"); prompt.nearest = Nearest::Found { dist_m: 400, along_m: 12_000 }; assert_eq!(prompt.rows().as_slice(), [Row::RideToStart, Row::JoinNearest, Row::Cancel]); prompt.set_no_route(); - assert_eq!(prompt.rows().as_slice(), [Row::JoinNearest, Row::Cancel]); - assert_eq!(prompt.selected, Row::JoinNearest, "the cursor lands on Join nearest"); + assert_eq!(prompt.rows().as_slice(), [Row::JoinNearest, Row::StartWithout, Row::Cancel]); + assert_eq!(prompt.selected, Row::StartWithout); // After a failure a row that starts the ride stays, wherever the join point is. for nearest in [Nearest::Found { dist_m: 300, along_m: 0 }, Nearest::Pending, Nearest::Missing] { prompt.nearest = nearest; - assert_eq!(prompt.rows().as_slice(), [Row::JoinNearest, Row::Cancel], "{nearest:?}"); + assert_eq!(prompt.rows().as_slice(), [Row::StartWithout, Row::Cancel], "{nearest:?}"); } } } diff --git a/firmware/obc-app/tests/cases/detour_flow.rs b/firmware/obc-app/tests/cases/detour_flow.rs index 906f2ab19..9b934cb2f 100644 --- a/firmware/obc-app/tests/cases/detour_flow.rs +++ b/firmware/obc-app/tests/cases/detour_flow.rs @@ -667,7 +667,7 @@ fn ride_to_start_splices_the_leg_and_starts_the_ride_on_it() { app.apply_gesture(Gesture::Press); // Ride to start assert!(matches!(app.top_screen(), Screen::NavPlanning(_)), "the shared planning spinner"); - assert_eq!(detour_req(&mut app, &mut host), Some(DetourRequest::approach(0, (fix.lon, fix.lat)))); + assert_eq!(detour_req(&mut app, &mut host), Some(DetourRequest::approach(0, (fix.lon, fix.lat), 0))); answer_plan( &mut app, &mut host, @@ -684,38 +684,104 @@ fn ride_to_start_splices_the_leg_and_starts_the_ride_on_it() { assert_eq!(host.retained_result(&mut app), Some(true), "the release keeps the spliced route"); } -/// With no way to the start, the prompt keeps Join nearest and Cancel. Join nearest starts the -/// route and follows it from the nearest point, not from wherever the first far fix locks. -#[test] -fn join_nearest_follows_the_route_from_its_nearest_point() { - let obcr = road_obcr(); - let src = SliceSource(&obcr[..]); - let idx = RouteIndex::read(&src).unwrap(); - let route = RouteReader::new(&idx, &src); - let mut host = Planner::on(&route); - let mut app = start_away(away_fix()); - // A frame with the route open projects the fix onto it. +fn prepare_start_away(app: &mut App, route: &RouteReader) { let map = crate::common::build_min_obcm(0xF800); let (map_src, cache) = (SliceSource(&map[..]), obc_reader::MapCache::new()); let tables = obc_reader::MapTables::parse(&map_src).unwrap(); let reader = obc_reader::Reader::new(&map_src, &tables, &cache); let mut buf = crate::common::Buf::new(240, 320); - app.render_frame(None, &mut buf, &reader, Some(&route), 240.0, 320.0, rgb); + app.render_frame(None, &mut buf, &reader, Some(route), 240.0, 320.0, rgb); +} - app.apply_gesture(Gesture::Press); // Ride to start - answer_plan(&mut app, &mut host, Err(NavError::NoPath)); - assert!(matches!(app.top_screen(), Screen::StartAway(_)), "no way: back on the prompt"); - assert_eq!(app.active_route_index(), Some(0), "nothing was adopted"); +#[test] +fn join_nearest_plans_a_connection_and_starts_on_the_splice() { + let obcr = road_obcr(); + let src = SliceSource(&obcr); + let idx = RouteIndex::read(&src).unwrap(); + let route = RouteReader::new(&idx, &src); + let mut host = Planner::on(&route); + let fix = away_fix(); + let mut app = start_away(fix); + prepare_start_away(&mut app, &route); + app.apply_gesture(Gesture::Step(1)); + app.apply_gesture(Gesture::Press); + let request = detour_req(&mut app, &mut host).unwrap(); + let target = route.total_distance_m * 6 / 10; + assert!(request.target_m.abs_diff(target) < 30); + assert_eq!(request, DetourRequest::approach(0, (fix.lon, fix.lat), request.target_m)); + assert_eq!(request.progress_m, request.target_m, "a connection excludes no route corridor"); + assert!(!app.recording()); + answer_plan( + &mut app, + &mut host, + Ok(DetourPreview { cost_delta_m: 300, total_distance_m: 300, rejoin_m: request.target_m, ascent_m: None }), + ); + assert!(host.took_commit(&mut app)); + app.set_routes_with_ids(&[summary("Road"), summary("To route · Road")], &[7, 9]); + answer_commit(&mut app, &mut host, Ok(9)); + assert!(app.recording()); + assert_eq!(app.active_route_index(), Some(1)); + assert_eq!(app.ride_name(), Some("Road")); + assert_eq!(host.retained_result(&mut app), Some(true)); +} - app.apply_gesture(Gesture::Press); // the cursor is on Join nearest - assert!(app.recording(), "Join nearest starts the ride"); - tick(&mut app, 0, None, Some(&route)); - let expected = route.total_distance_m * 6 / 10; - assert!(app.progress_m().abs_diff(expected) < 30, "the ride joins at {expected} m, not {}", app.progress_m()); +#[test] +fn either_connection_failure_offers_an_unguided_start_on_the_original_route() { + let obcr = road_obcr(); + let src = SliceSource(&obcr); + let idx = RouteIndex::read(&src).unwrap(); + let route = RouteReader::new(&idx, &src); + for choice in [0, 1] { + for error in [NavError::NoPath, NavError::Exhausted] { + let mut host = Planner::on(&route); + let mut app = start_away(away_fix()); + prepare_start_away(&mut app, &route); + app.apply_gesture(Gesture::Step(choice)); + app.apply_gesture(Gesture::Press); + assert!(detour_req(&mut app, &mut host).is_some()); + answer_plan(&mut app, &mut host, Err(error)); + assert!(matches!(app.top_screen(), Screen::StartAway(_))); + assert!(!app.recording()); + if choice == 0 { + // A failed start connection must not disable routing to the nearest point. + app.apply_gesture(Gesture::Step(-1)); + app.apply_gesture(Gesture::Press); + let request = detour_req(&mut app, &mut host).unwrap(); + assert!(request.target_m > 0); + answer_plan(&mut app, &mut host, Err(error)); + assert!(matches!(app.top_screen(), Screen::StartAway(_))); + } + app.apply_gesture(Gesture::Press); // Start without directions + assert!(app.recording()); + assert_eq!(app.active_route_index(), Some(0)); + tick(&mut app, 0, Some(away_fix()), Some(&route)); + assert!(app.off_route()); + tick(&mut app, 1_000, Some(road_at(0.6)), Some(&route)); + assert!(app.progress_m().abs_diff(route.total_distance_m * 6 / 10) < 30); + } + } +} + +#[test] +fn joining_while_already_on_the_route_needs_no_connection() { + let obcr = road_obcr(); + let src = SliceSource(&obcr); + let idx = RouteIndex::read(&src).unwrap(); + let route = RouteReader::new(&idx, &src); + let mut host = Planner::on(&route); + let mut app = start_away(road_at(0.6)); + prepare_start_away(&mut app, &route); + app.apply_gesture(Gesture::Step(1)); + app.apply_gesture(Gesture::Press); + assert!(app.recording()); + assert!(detour_req(&mut app, &mut host).is_none()); + tick(&mut app, 0, Some(road_at(0.6)), Some(&route)); + tick(&mut app, 1_000, Some(road_at(0.5)), Some(&route)); + assert!(app.progress_m().abs_diff(route.total_distance_m / 2) < 30); } /// Without a routing graph the device never plans: Ride to start lands on the no-route prompt at -/// once, and Join nearest starts the ride. +/// once, and Start without directions starts the ride. #[test] fn without_a_routing_graph_ride_to_start_fails_at_once() { let obcr = road_obcr(); @@ -727,13 +793,13 @@ fn without_a_routing_graph_ride_to_start_fails_at_once() { app.apply_gesture(Gesture::Press); // Ride to start assert!(matches!(app.top_screen(), Screen::StartAway(_))); assert!(detour_req(&mut app, &mut host).is_none(), "no plan without a graph"); - app.apply_gesture(Gesture::Press); // the cursor is on Join nearest - assert!(app.recording(), "Join nearest starts the ride"); + app.apply_gesture(Gesture::Press); // the cursor is on Start without directions + assert!(app.recording(), "Start without directions starts the ride"); } /// A failure leaves a row that starts the ride, also when the join point is inside the first km. #[test] -fn a_failure_near_the_start_still_offers_join_nearest() { +fn a_failure_near_the_start_still_offers_an_unguided_start() { let obcr = road_obcr(); let src = SliceSource(&obcr[..]); let idx = RouteIndex::read(&src).unwrap(); @@ -745,7 +811,7 @@ fn a_failure_near_the_start_still_offers_join_nearest() { answer_plan(&mut app, &mut host, Err(NavError::NoPath)); assert!(matches!(app.top_screen(), Screen::StartAway(_))); app.apply_gesture(Gesture::Press); - assert!(app.recording(), "the cursor is on Join nearest, and it starts the ride"); + assert!(app.recording(), "the cursor is on Start without directions, and it starts the ride"); } /// The escape chord drops Ride to start at any point before the ride: nothing is adopted, no ride diff --git a/firmware/obc-route/src/matcher.rs b/firmware/obc-route/src/matcher.rs index 635a6aafa..df845f4ba 100644 --- a/firmware/obc-route/src/matcher.rs +++ b/firmware/obc-route/src/matcher.rs @@ -1,14 +1,8 @@ -//! Forward-biased route matcher: snap a live position onto the loaded route. +//! Route matching with a bounded, bidirectional search around the last matched occurrence. //! -//! [`RouteMatch`] keeps a cursor `(chunk, segment, progress)` and, for each fix, searches a -//! bounded forward window around it for the nearest route segment, so the cost is O(window), not -//! O(route). Past a distance threshold it flags off-route, with hysteresis so the flag does not -//! flap on GPS jitter, and freezes progress so a far fix cannot drag the route position. It -//! widens the search at the same time, so a rejoin is still found. The forward bias stops a -//! loop's second pass from snapping back to the first. -//! -//! One reused chunk-decode buffer holds the handful of chunks the window spans, so matching -//! allocates nothing per fix. +//! Near overlapping sections, along-route continuity distinguishes repeated passes. Progress can +//! decrease when the rider turns back. Off-route fixes freeze the cursor and widen the search; +//! until the first on-route fix, the search covers the whole route. One decode buffer is reused. use heapless::Vec; @@ -21,19 +15,13 @@ const OFF_M: f32 = 25.0; /// Cross-track distance (m) below which the rider is back on-route. The gap to [`OFF_M`] is the /// hysteresis band that keeps the flag from flapping on GPS noise. const ON_M: f32 = 15.0; -/// Segments of backward slack in the on-route search window. It absorbs a little GPS jitter -/// without losing the forward bias. -const BACK_SEGS: i64 = 3; -/// Forward search window (segments) while on-route. One fix's travel is far less than this -/// at any cycling speed, so the nearest segment is well inside it. -const FWD_SEGS_ON: i64 = 64; -/// Wider forward window while off-route, so a rejoin further along the route is found -/// without an unbounded full scan. -const FWD_SEGS_OFF: i64 = 320; -/// Tie-break margin (m) for the first lock only. The initial scan runs front-to-back, so this -/// margin keeps the earliest of several near-equal matches. On an out-and-back a few metres of -/// cross-track offset would otherwise latch the cursor onto the finish, and the forward bias -/// could never follow the outbound leg. +/// Search radius in segments on either side of the cursor. This bounds per-fix decoding while +/// allowing several fixes of travel in either direction, even on densely sampled routes. +const WINDOW_SEGS_ON: i64 = 64; +/// Wider radius for a rejoin or a known gap in matching. +const WINDOW_SEGS_OFF: i64 = 320; +/// GPS tolerance (m) for continuity and earliest-occurrence ties on first lock. This keeps a small +/// cross-track offset from selecting the finish of an out-and-back instead of its outbound leg. const TIE_EPS_M: f32 = 8.0; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -56,12 +44,13 @@ enum RecoveryScan { Check { progress_m: u32, dist_m: u32 }, } -/// A forward-biased cursor that snaps fixes to a route. One per active route, reset on route +/// A cursor that follows either direction on a route. One per active route, reset on route /// load or change. pub struct RouteMatch { chunk: usize, seg: usize, progress_m: u32, + last_fix: Option<(i32, i32)>, /// Durable lower bound installed by a skip-ahead commit. It survives off-route fixes and /// stops the backward slack from re-entering the skipped stretch. floor_progress_m: u32, @@ -71,7 +60,7 @@ pub struct RouteMatch { /// `false` until the first fix has been matched. That first match scans the whole route, to /// lock on from anywhere. started: bool, - /// Widen the next match's forward window to the rejoin window, then clear. Set when the + /// Widen the next match's search window to the rejoin window, then clear. Set when the /// caller knows fixes went unmatched, so the cursor is stale by more than one fix's travel. wide_next: bool, buf: Vec, @@ -89,6 +78,7 @@ impl RouteMatch { chunk: 0, seg: 0, progress_m: 0, + last_fix: None, floor_progress_m: 0, floor_global_seg: 0, off_route: false, @@ -103,6 +93,7 @@ impl RouteMatch { self.chunk = 0; self.seg = 0; self.progress_m = 0; + self.last_fix = None; self.floor_progress_m = 0; self.floor_global_seg = 0; self.off_route = false; @@ -120,6 +111,7 @@ impl RouteMatch { self.chunk = pos.chunk; self.seg = pos.seg; self.progress_m = pos.progress_m; + self.last_fix = Some((pos.lon, pos.lat)); self.floor_progress_m = pos.progress_m; self.floor_global_seg = route.global_seg_index(pos.chunk, pos.seg) as u32; self.off_route = false; @@ -222,10 +214,10 @@ impl RouteMatch { self.off_route }; self.off_route = now_off; - self.started = true; - - // Advance only when on-route, so a far fix cannot drag progress. + // Move only when on-route, so a far fix cannot drag progress. if !now_off { + self.started = true; + self.last_fix = Some((lon, lat)); self.chunk = bc; self.seg = bs; self.progress_m = bprog; @@ -251,12 +243,25 @@ impl RouteMatch { // The first lock, an off-route rejoin and a requested re-lock scan wide. The re-lock // request is consumed here whichever branch wins, so it costs at most one wide search. let wide_relock = core::mem::take(&mut self.wide_next); - let (first_chunk, back, fwd) = if !self.started { - (0usize, i64::MAX, i64::MAX) // first lock: whole route - } else if self.off_route || wide_relock { - (self.chunk.saturating_sub(1), BACK_SEGS, FWD_SEGS_OFF) - } else { - (self.chunk.saturating_sub(1), BACK_SEGS, FWD_SEGS_ON) + let radius = if self.off_route || wide_relock { WINDOW_SEGS_OFF } else { WINDOW_SEGS_ON }; + let mut first_chunk = if self.started { self.chunk } else { 0 }; + while first_chunk > 0 && route.global_seg_index(first_chunk, 0) as i64 >= cur_gidx - radius { + first_chunk -= 1; + } + let travel = self.last_fix.map_or(0.0, |last| obc_map_scene::ground_dist_m(last, p)); + let on_limit = if self.off_route { ON_M } else { OFF_M }; + // A candidate must be spatially on-route before continuity can prefer it. Within that + // band, penalize travel beyond the fix displacement and its GPS tolerance. This preserves + // the occurrence on overlaps without penalizing ordinary forward or backward motion. + let rank = |dist: f32, progress: u32| { + let delta = progress.abs_diff(self.progress_m); + let excess = (delta as f32 - travel - TIE_EPS_M).max(0.0); + ( + dist >= on_limit, + dist + if dist < on_limit { excess * 0.25 } else { 0.0 }, + delta, + progress < self.progress_m, + ) }; let mut best: Option = None; @@ -265,7 +270,7 @@ impl RouteMatch { let mut base_gidx = route.global_seg_index(first_chunk, 0) as i64; 'outer: while c < chunks.len() { // Segments only run forward, so a chunk past the window ends the scan. - if self.started && base_gidx - cur_gidx > fwd { + if self.started && base_gidx - cur_gidx > radius { break; } let pc_segs = (chunks[c].point_count as usize).saturating_sub(1) as i64; @@ -283,7 +288,7 @@ impl RouteMatch { for s in 0..n - 1 { let off = base_gidx + s as i64 - cur_gidx; let global = (base_gidx + s as i64).max(0) as u32; - if self.started && off > fwd { + if self.started && off > radius { break 'outer; } let a = (self.buf[s].lon, self.buf[s].lat); @@ -292,7 +297,7 @@ impl RouteMatch { if cum0 + intra > ceiling_m as f32 { break 'outer; } - if (!self.started || off >= -back) && global >= self.floor_global_seg { + if (!self.started || off >= -radius) && global >= self.floor_global_seg { let (mut t, mut dist) = project_to_segment(a, b, p, cl); let mut progress = (cum0 + intra + t * seg_len) as u32; if progress > ceiling_m { @@ -333,11 +338,25 @@ impl RouteMatch { && separation > 2.0 * (nearest_dist + TIE_EPS_M)) || (dist <= nearest_dist + 1.0 && separation > 2.0 * (nearest_dist + 1.0)); } - // The first lock biases near-ties to the earliest segment. Once tracking, - // the forward window bounds the search, so a strict nearest is right. + // At an exact turnaround tie, prefer the next occurrence. Away from a + // turnaround, the nearer along-route occurrence wins repeated geometry. let better = match best { None => true, - Some((_, _, bd, _)) if self.started || recovery.is_some() => dist < bd, + Some((_, _, bd, bp)) if self.started => { + let candidate = rank(dist, progress); + let kept = rank(bd, bp); + // Metre-rounded progress and sub-metre projection error must not + // decide which side of an exact turnaround the rider takes. + if candidate.0 == kept.0 + && (candidate.1 - kept.1).abs() < 0.25 + && candidate.2.abs_diff(kept.2) <= 2 + { + (candidate.3, candidate.1, candidate.2) < (kept.3, kept.1, kept.2) + } else { + candidate < kept + } + } + Some((_, _, bd, _)) if recovery.is_some() => dist < bd, Some((_, _, bd, _)) => dist < bd - tie_m, }; if better { diff --git a/firmware/obc-route/src/splice.rs b/firmware/obc-route/src/splice.rs index eff322392..f33dc9cab 100644 --- a/firmware/obc-route/src/splice.rs +++ b/firmware/obc-route/src/splice.rs @@ -23,12 +23,16 @@ pub(crate) const SPLICE_CHUNKS_PER_STEP: usize = 1; /// stacking prefixes. const NAME_PREFIX: &str = "Detour · "; const APPROACH_PREFIX: &str = "To start · "; +const JOIN_PREFIX: &str = "To route · "; const REST_PREFIX: &str = "From stop · "; /// The name of the route a derived route was built on: `name` without its detour, approach or rest /// prefix. pub fn original_name(name: &str) -> &str { - [NAME_PREFIX, APPROACH_PREFIX, REST_PREFIX].iter().find_map(|prefix| name.strip_prefix(prefix)).unwrap_or(name) + [NAME_PREFIX, APPROACH_PREFIX, JOIN_PREFIX, REST_PREFIX] + .iter() + .find_map(|prefix| name.strip_prefix(prefix)) + .unwrap_or(name) } /// What a planned leg does to the route it joins. @@ -36,7 +40,7 @@ pub fn original_name(name: &str) -> &str { pub enum Leg { /// Leaves the route, skips a span of it and rejoins it. Detour, - /// Leads to the route's start. The whole route follows it. + /// Leads to a join point. The route follows it from `rejoin_m`. Approach, /// The rest of the previous trip day: its stored route over `[from_m, to_m]`, verbatim. The /// route follows it from its join point. @@ -132,9 +136,8 @@ impl Splicer { /// facts and the final geometry determine the output, so the distance and elevation hints in /// the call shape are unused. `orig_name` supplies the derived route name. /// - /// An [`Leg::Approach`] is the way to the start, then the whole route: it splits and rejoins at - /// 0 and skips nothing, so it adds no avoidance. Its heights take one offset, which lands its end - /// on the start's height, and every waypoint moves behind it. + /// A [`Leg::Approach`] leads to `rejoin_m`, then follows the route from there. It adds no + /// avoidance. One height offset aligns its end with the join point. Tail waypoints follow it. /// /// A [`Leg::Rest`] is a stored route, so its heights stay as stored. The route follows from /// `rejoin_m`, and the output is a built day. The rest's waypoints are not kept. @@ -148,18 +151,18 @@ impl Splicer { ) -> Splicer { let (split_m, rejoin_m) = match leg { Leg::Detour => (split_m, rejoin_m), - Leg::Approach => (0, 0), + Leg::Approach => (0, rejoin_m), Leg::Rest { .. } => (0, rejoin_m), }; let mut name = heapless::String::new(); let prefix = match leg { Leg::Detour => Some(NAME_PREFIX), - Leg::Approach => Some(APPROACH_PREFIX), + Leg::Approach => Some(if rejoin_m == 0 { APPROACH_PREFIX } else { JOIN_PREFIX }), Leg::Rest { .. } => Some(REST_PREFIX), }; - if let Some(prefix) = prefix - .filter(|_| ![NAME_PREFIX, APPROACH_PREFIX, REST_PREFIX].iter().any(|prefix| orig_name.starts_with(prefix))) - { + if let Some(prefix) = prefix.filter(|_| { + ![NAME_PREFIX, APPROACH_PREFIX, JOIN_PREFIX, REST_PREFIX].iter().any(|prefix| orig_name.starts_with(prefix)) + }) { let _ = name.push_str(prefix); } for ch in orig_name.chars() { diff --git a/firmware/obc-route/tests/cases/detour.rs b/firmware/obc-route/tests/cases/detour.rs index 35ce93e82..cac378c3f 100644 --- a/firmware/obc-route/tests/cases/detour.rs +++ b/firmware/obc-route/tests/cases/detour.rs @@ -580,6 +580,50 @@ fn splice_self_input_is_previous_output() { assert_eq!(idx.total_distance_m, stats.total_distance_m); } +#[test] +fn a_nearest_point_approach_keeps_only_the_tail_and_its_waypoints() { + let bytes = road_route_obcr(); + let source = SliceSource(&bytes); + let index = RouteIndex::read(&source).unwrap(); + let route = RouteReader::new(&index, &source); + let join_m = route.total_distance_m / 2; + let join = route.position_at(join_m).unwrap(); + let leg_bytes = convert( + "Connection", + &format!( + "300\ + 300", + (join.lat + STREET_OFF) as f64 / 1e6, + join.lon as f64 / 1e6, + join.lat as f64 / 1e6, + join.lon as f64 / 1e6, + ), + ); + let leg_source = SliceSource(&leg_bytes); + let leg_index = RouteIndex::read(&leg_source).unwrap(); + let leg = RouteReader::new(&leg_index, &leg_source); + let mut sink = VecSink::default(); + splice_detour(Leg::Approach, &route, &leg, join_m, join_m, leg.total_distance_m, true, &mut sink).unwrap(); + let source = SliceSource(&sink.buf); + let index = RouteIndex::read(&source).unwrap(); + let result = RouteReader::new(&index, &source); + assert_eq!(index.name(), "To route · Road trip"); + assert_eq!(obc_route::splice::original_name(index.name()), "Road trip"); + assert!(!index.has_unresolved_avoidance()); + let expected_m = leg.total_distance_m + route.total_distance_m - join_m; + assert!(result.total_distance_m.abs_diff(expected_m) <= 3); + let at_join = result.position_at(leg.total_distance_m).unwrap(); + assert!(obc_map_scene::ground_dist_m((at_join.lon, at_join.lat), (join.lon, join.lat)) < 3.0); + let original_waypoints = route.load_waypoints(join_m); + let result_waypoints = result.load_waypoints(0); + assert!(!original_waypoints.is_empty()); + assert_eq!(result_waypoints.len(), original_waypoints.len()); + for (original, shifted) in original_waypoints.entries.iter().zip(&result_waypoints.entries) { + assert_eq!(original.name, shifted.name); + assert!(shifted.dist_along_m.abs_diff(original.dist_along_m - join_m + leg.total_distance_m) <= 3); + } +} + /// Ride to start: a leg that rejoins at the route start comes first, and the whole route follows /// it unchanged, with the waypoints moved behind the leg. #[test] diff --git a/firmware/obc-route/tests/cases/matcher.rs b/firmware/obc-route/tests/cases/matcher.rs index dea8daaef..80a7f4ac7 100644 --- a/firmware/obc-route/tests/cases/matcher.rs +++ b/firmware/obc-route/tests/cases/matcher.rs @@ -483,8 +483,7 @@ fn single_teleport_spike_does_not_lurch_progress() { assert!(after.progress_m.abs_diff(want) <= 5, "recovered progress {} ~ {want}", after.progress_m); } -/// Going backwards within the `BACK_SEGS` = 3 slack: progress descends onto the earlier segment, -/// so "distance ridden" follows a rider who briefly reverses. +/// Progress follows a rider who reverses across individual segments. #[test] fn small_backward_step_descends_progress() { let bytes = convert("Zig", &zigzag_gpx(6)); @@ -515,29 +514,86 @@ fn small_backward_step_descends_progress() { assert_eq!(prev, 0, "walking all the way back reaches the route start (0 m)"); } -/// A backward jump past `BACK_SEGS` leaves the backward window, so the nearest in-window segment -/// is far away: the matcher reports off-route and freezes instead of teleporting the cursor back. +/// Backtracking crosses chunk boundaries in fixes that each span more than three segments. #[test] -fn backward_jump_beyond_back_segs_freezes() { - let bytes = convert("Zig", &zigzag_gpx(12)); +fn sustained_backtracking_and_forward_recovery_keep_the_route_position() { + let bytes = convert("Long ride", &sawtooth_gpx(1_500)); let src = SliceSource(&bytes); - let ridx = RouteIndex::read(&src).unwrap(); - let r = RouteReader::new(&ridx, &src); - let pts = decode_all(&r); - assert!(pts.len() >= 12, "need a long zigzag, got {}", pts.len()); + let index = RouteIndex::read(&src).unwrap(); + let route = RouteReader::new(&index, &src); + let mut matcher = RouteMatch::new(); + for progress in + (0..=10_000).step_by(50).chain((8_000..10_000).step_by(50).rev()).chain((8_000..=10_500).step_by(50)) + { + let p = route.position_at(progress).unwrap(); + let matched = matcher.update(p.lon, p.lat, &route); + assert!(!matched.off_route, "on the route at {progress}: {matched:?}"); + assert!(matched.progress_m.abs_diff(progress) <= 3, "at {progress}: {matched:?}"); + } + matcher.relock_wide(); + let p = route.position_at(8_500).unwrap(); + let matched = matcher.update(p.lon, p.lat, &route); + assert!(!matched.off_route && matched.progress_m.abs_diff(8_500) <= 3); +} - let mut m = RouteMatch::new(); - for p in &pts[..=8] { - m.update(p.lon, p.lat, &r); +#[test] +fn a_far_first_fix_does_not_lock_the_search_to_the_start() { + let bytes = convert("Long ride", &sawtooth_gpx(1_500)); + let src = SliceSource(&bytes); + let index = RouteIndex::read(&src).unwrap(); + let route = RouteReader::new(&index, &src); + let mut matcher = RouteMatch::new(); + let p = route.position_at(10_000).unwrap(); + for _ in 0..3 { + assert!(matcher.update(p.lon, p.lat + north_ud(500.0), &route).off_route); + assert!(!matcher.started()); + } + let matched = matcher.update(p.lon, p.lat, &route); + assert!(!matched.off_route && matched.progress_m.abs_diff(10_000) <= 3); +} + +#[test] +fn repeated_sections_follow_the_current_pass_in_both_directions() { + // Both laps use exactly the same geometry, with the same travel direction. + let mut points = Vec::new(); + for i in 0..=40 { + let a = (i % 20) as f64 / 20.0 * std::f64::consts::TAU; + points.push((0.008 * a.sin(), 0.008 * a.cos(), 100.0)); + } + let bytes = convert("Two laps", &gpx_from(&points)); + let src = SliceSource(&bytes); + let index = RouteIndex::read(&src).unwrap(); + let route = RouteReader::new(&index, &src); + let mut matcher = RouteMatch::new(); + for progress in (0..=7_000) + .step_by(25) + .chain((6_000..7_000).step_by(25).rev()) + .chain((6_000..route.total_distance_m).step_by(25)) + { + let p = route.position_at(progress).unwrap(); + // A small cross-track error must not select the other lap. + let matched = matcher.update(p.lon + 15, p.lat - 15, &route); + assert!(!matched.off_route && matched.progress_m.abs_diff(progress) <= 5, "at {progress}: {matched:?}"); + } +} + +#[test] +fn exact_out_and_back_advances_at_the_turnaround() { + let bytes = convert("Return", &gpx_from(&[(0.0, 0.0, 0.0), (0.01, 0.0, 0.0), (0.0, 0.0, 0.0)])); + let src = SliceSource(&bytes); + let index = RouteIndex::read(&src).unwrap(); + let route = RouteReader::new(&index, &src); + let mut matcher = RouteMatch::new(); + let turn = route.position_at(route.total_distance_m / 2).unwrap(); + for progress in (0..turn.progress_m) + .step_by(20) + .chain([turn.progress_m]) + .chain((turn.progress_m + 20..route.total_distance_m).step_by(20)) + { + let p = route.position_at(progress).unwrap(); + let matched = matcher.update(p.lon, p.lat, &route); + assert!(!matched.off_route && matched.progress_m.abs_diff(progress) <= 3, "at {progress}: {matched:?}"); } - let at8 = m.update(pts[8].lon, pts[8].lat, &r); - assert!(!at8.off_route); - let frozen = at8.progress_m; - - // Vertex 2 is 6 segments behind, well past BACK_SEGS = 3. - let jumped = m.update(pts[2].lon, pts[2].lat, &r); - assert!(jumped.off_route, "a 6-segment backward jump is outside the slack → off-route"); - assert_eq!(jumped.progress_m, frozen, "progress freezes; the cursor must not snap backwards"); } /// A hairpin whose out and back legs are metres apart, so at the apex two segments are almost diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 36f2e2401..496f21d7d 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -969,7 +969,7 @@ script = "p p p p f p" expect = "NavPlanning" args = ["--center", "8298495,46625396", "--heading", "0", "--hold", "detour"] -# No way to the start: Join nearest (the cursor) and Cancel. This fix is 4.1 km east of km 12. +# No connection to the start: Start without directions is selected; Join nearest remains available. [[frame]] name = "start-away-noroute" map = "{grimsel}" diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index e1df4cdd2..bd3cc9d7b 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -288,9 +288,9 @@ def75c60c02b8a81143681b19ac76fa65f70b5065100633103a7b370e64c3018 sensors-scanni 58089884e92873dcccad5f005241025aa8bfe95f8596fd5ceea2f584af8915de settings-fr.png 7ae11fea7661cc8bb4b70bce47f901e995e8c1693971a60ae06162703e6865e0 settings-ride-waypoints.png 4174bf6a3581c76a138cd290f41d9aac7fe09f571bce688ad4eb4bda19ddd73d settings.png -74e3ae6f0226801afb9d87c4a8cecc61aadd8fe30b6e8745dbb087f50295a2ee start-away-noroute.png -11367318e136625b16f7b4f099afd09e72f77763881868e1c654d286ea08b739 start-away-planning.png -714e9f8e426ffcd3dbeb7a0f3f340a33a201d97230dbc0c6b05fac3a39bfbd8d start-away.png +defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-noroute.png +bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png +aef19ed12372f568f146e1e06235d6b64c8720d5d7726d0ca5d13ffdaa11ac0d start-away.png 02bb24bca7a65ce20b231456d8ce665af323af4d5031ac90c5ad02aef1356567 statistics-de.png c756c3340fa9499fa6fcff1f4c8dbcde791f724aed1118d594368691b57eea83 statistics-es.png c238326a4e35550116b83eabf8aabb64c475dc4dbd20d28a69d77a02bc1d79aa statistics-eta-flat.png From cf1c47e28810f3faa82794fd2b602ed66d3f0992 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:02:57 +0200 Subject: [PATCH 02/13] fix: bound route searches before the first on-route fix --- firmware/obc-route/src/matcher.rs | 28 ++++++++++++++++------- firmware/obc-route/tests/cases/matcher.rs | 26 +++++++++++++++++++-- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/firmware/obc-route/src/matcher.rs b/firmware/obc-route/src/matcher.rs index df845f4ba..882318717 100644 --- a/firmware/obc-route/src/matcher.rs +++ b/firmware/obc-route/src/matcher.rs @@ -2,7 +2,8 @@ //! //! Near overlapping sections, along-route continuity distinguishes repeated passes. Progress can //! decrease when the rider turns back. Off-route fixes freeze the cursor and widen the search; -//! until the first on-route fix, the search covers the whole route. One decode buffer is reused. +//! the first search covers the whole route, then keeps a search anchor even before an on-route +//! lock. One decode buffer is reused. use heapless::Vec; @@ -51,14 +52,15 @@ pub struct RouteMatch { seg: usize, progress_m: u32, last_fix: Option<(i32, i32)>, + /// A search cursor exists even when progress has never locked onto the route. + anchored: bool, /// Durable lower bound installed by a skip-ahead commit. It survives off-route fixes and /// stops the backward slack from re-entering the skipped stretch. floor_progress_m: u32, /// Global segment containing `floor_progress_m`; segments before it are not candidates. floor_global_seg: u32, off_route: bool, - /// `false` until the first fix has been matched. That first match scans the whole route, to - /// lock on from anywhere. + /// `false` until an on-route fix establishes progress. started: bool, /// Widen the next match's search window to the rejoin window, then clear. Set when the /// caller knows fixes went unmatched, so the cursor is stale by more than one fix's travel. @@ -79,6 +81,7 @@ impl RouteMatch { seg: 0, progress_m: 0, last_fix: None, + anchored: false, floor_progress_m: 0, floor_global_seg: 0, off_route: false, @@ -94,6 +97,7 @@ impl RouteMatch { self.seg = 0; self.progress_m = 0; self.last_fix = None; + self.anchored = false; self.floor_progress_m = 0; self.floor_global_seg = 0; self.off_route = false; @@ -112,6 +116,7 @@ impl RouteMatch { self.seg = pos.seg; self.progress_m = pos.progress_m; self.last_fix = Some((pos.lon, pos.lat)); + self.anchored = true; self.floor_progress_m = pos.progress_m; self.floor_global_seg = route.global_seg_index(pos.chunk, pos.seg) as u32; self.off_route = false; @@ -214,7 +219,13 @@ impl RouteMatch { self.off_route }; self.off_route = now_off; - // Move only when on-route, so a far fix cannot drag progress. + self.anchored = true; + // Before progress locks, follow the nearest search window without publishing progress. + if !self.started { + self.chunk = bc; + self.seg = bs; + } + // Move progress only when on-route, so a far fix cannot drag it. if !now_off { self.started = true; self.last_fix = Some((lon, lat)); @@ -244,7 +255,8 @@ impl RouteMatch { // request is consumed here whichever branch wins, so it costs at most one wide search. let wide_relock = core::mem::take(&mut self.wide_next); let radius = if self.off_route || wide_relock { WINDOW_SEGS_OFF } else { WINDOW_SEGS_ON }; - let mut first_chunk = if self.started { self.chunk } else { 0 }; + let bounded = self.anchored && recovery.is_none(); + let mut first_chunk = if bounded { self.chunk } else { 0 }; while first_chunk > 0 && route.global_seg_index(first_chunk, 0) as i64 >= cur_gidx - radius { first_chunk -= 1; } @@ -270,7 +282,7 @@ impl RouteMatch { let mut base_gidx = route.global_seg_index(first_chunk, 0) as i64; 'outer: while c < chunks.len() { // Segments only run forward, so a chunk past the window ends the scan. - if self.started && base_gidx - cur_gidx > radius { + if bounded && base_gidx - cur_gidx > radius { break; } let pc_segs = (chunks[c].point_count as usize).saturating_sub(1) as i64; @@ -288,7 +300,7 @@ impl RouteMatch { for s in 0..n - 1 { let off = base_gidx + s as i64 - cur_gidx; let global = (base_gidx + s as i64).max(0) as u32; - if self.started && off > radius { + if bounded && off > radius { break 'outer; } let a = (self.buf[s].lon, self.buf[s].lat); @@ -297,7 +309,7 @@ impl RouteMatch { if cum0 + intra > ceiling_m as f32 { break 'outer; } - if (!self.started || off >= -radius) && global >= self.floor_global_seg { + if (!bounded || off >= -radius) && global >= self.floor_global_seg { let (mut t, mut dist) = project_to_segment(a, b, p, cl); let mut progress = (cum0 + intra + t * seg_len) as u32; if progress > ceiling_m { diff --git a/firmware/obc-route/tests/cases/matcher.rs b/firmware/obc-route/tests/cases/matcher.rs index 80a7f4ac7..a9e10990e 100644 --- a/firmware/obc-route/tests/cases/matcher.rs +++ b/firmware/obc-route/tests/cases/matcher.rs @@ -539,14 +539,36 @@ fn sustained_backtracking_and_forward_recovery_keep_the_route_position() { #[test] fn a_far_first_fix_does_not_lock_the_search_to_the_start() { let bytes = convert("Long ride", &sawtooth_gpx(1_500)); - let src = SliceSource(&bytes); + use core::cell::Cell; + use obc_formats::io::{ByteSource, Error}; + struct Counted<'a> { + bytes: &'a [u8], + reads: Cell, + } + impl ByteSource for Counted<'_> { + fn len(&self) -> u64 { + self.bytes.len() as u64 + } + fn read_at(&self, offset: u64, out: &mut [u8]) -> Result<(), Error> { + self.reads.set(self.reads.get() + 1); + SliceSource(self.bytes).read_at(offset, out) + } + } + let src = Counted { bytes: &bytes, reads: Cell::new(0) }; let index = RouteIndex::read(&src).unwrap(); let route = RouteReader::new(&index, &src); let mut matcher = RouteMatch::new(); let p = route.position_at(10_000).unwrap(); - for _ in 0..3 { + let mut first_reads = 0; + for attempt in 0..3 { + src.reads.set(0); assert!(matcher.update(p.lon, p.lat + north_ud(500.0), &route).off_route); assert!(!matcher.started()); + if attempt == 0 { + first_reads = src.reads.get(); + } else { + assert!(src.reads.get() < first_reads, "off-route updates reuse a bounded search anchor"); + } } let matched = matcher.update(p.lon, p.lat, &route); assert!(!matched.off_route && matched.progress_m.abs_diff(10_000) <= 3); From 08e1e31506c2aafb2383ea20a3ad5f4a832d6c4d Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:02:58 +0200 Subject: [PATCH 03/13] fix: hide temporary navigation routes from saved routes --- firmware/obc-formats/src/obcr.rs | 2 ++ firmware/obc-fw-nrf54l/src/flat_store.rs | 2 +- firmware/obc-route/src/nav.rs | 3 ++- firmware/obc-route/src/reader.rs | 2 +- firmware/obc-route/src/splice.rs | 1 + firmware/obc-route/tests/cases/detour.rs | 3 ++- firmware/obc-route/tests/cases/nav.rs | 3 ++- host/obc-host-core/src/flat_routes.rs | 6 +++--- host/obc-host-core/src/flat_routes/tests.rs | 22 +++++++++++++++++++++ 9 files changed, 36 insertions(+), 8 deletions(-) diff --git a/firmware/obc-formats/src/obcr.rs b/firmware/obc-formats/src/obcr.rs index 53d8d1fd9..733451dd5 100644 --- a/firmware/obc-formats/src/obcr.rs +++ b/firmware/obc-formats/src/obcr.rs @@ -26,6 +26,8 @@ pub const FLAG_ASSISTANT_CANDIDATE: u8 = 8; /// A trip day the device built from the rest of the day before. Lists hide it, and the device keeps /// at most one, which each new build replaces. pub const FLAG_BUILT_DAY: u8 = 16; +/// Generated navigation geometry. Saved-route lists do not show it. +pub const FLAG_TEMPORARY: u8 = 32; pub const WAYPOINT_PROVENANCE_OFF: usize = 44; pub const VISIT_DESCRIPTOR_VERSION: u8 = 1; pub const VISIT_DESCRIPTOR_LEN: usize = 80; diff --git a/firmware/obc-fw-nrf54l/src/flat_store.rs b/firmware/obc-fw-nrf54l/src/flat_store.rs index e5c515ea1..1407630af 100644 --- a/firmware/obc-fw-nrf54l/src/flat_store.rs +++ b/firmware/obc-fw-nrf54l/src/flat_store.rs @@ -1680,7 +1680,7 @@ pub(crate) fn load_routes(store: &'static FlatStore, app: &mut obc_app { Ok(Ok((summary, flags))) => { let candidate = flags & obc_formats::obcr::FLAG_ASSISTANT_CANDIDATE != 0; - if candidate || flags & obc_formats::obcr::FLAG_BUILT_DAY != 0 { + if candidate || flags & (obc_formats::obcr::FLAG_BUILT_DAY | obc_formats::obcr::FLAG_TEMPORARY) != 0 { internal_routes |= 1 << routes.len(); } if candidate && accepted & (1 << index) == 0 { diff --git a/firmware/obc-route/src/nav.rs b/firmware/obc-route/src/nav.rs index 5e0de5a85..c69ec3dc9 100644 --- a/firmware/obc-route/src/nav.rs +++ b/firmware/obc-route/src/nav.rs @@ -960,7 +960,8 @@ impl NavPlanner { em.set_attribution_map(self.map_source); em.set_bike_type(self.bike); em.set_flags( - if self.assistant_candidate { obc_formats::obcr::FLAG_ASSISTANT_CANDIDATE } else { 0 } + obc_formats::obcr::FLAG_TEMPORARY + | if self.assistant_candidate { obc_formats::obcr::FLAG_ASSISTANT_CANDIDATE } else { 0 } | if self.unresolved_avoidance { obc_formats::obcr::FLAG_UNRESOLVED_AVOIDANCE } else { 0 }, ); self.em = Some(em); diff --git a/firmware/obc-route/src/reader.rs b/firmware/obc-route/src/reader.rs index 7cc2b30c8..3608749a2 100644 --- a/firmware/obc-route/src/reader.rs +++ b/firmware/obc-route/src/reader.rs @@ -1163,7 +1163,7 @@ pub(crate) fn read_header(src: &dyn ByteSource) -> Result { let Some(bike) = BikeType::from_u8(h[obc_formats::obcr::BIKE_TYPE_OFF]) else { return Err(Error::BadOffset); }; - if h[5] & !31 != 0 || h[119] != 0 { + if h[5] & !63 != 0 || h[119] != 0 { return Err(Error::BadOffset); } if h[5] & obc_formats::obcr::FLAG_ATTRIBUTION_MAP == 0 && h[128..160].iter().any(|b| *b != 0) { diff --git a/firmware/obc-route/src/splice.rs b/firmware/obc-route/src/splice.rs index f33dc9cab..0bcd7c92a 100644 --- a/firmware/obc-route/src/splice.rs +++ b/firmware/obc-route/src/splice.rs @@ -248,6 +248,7 @@ impl Splicer { } else { 0 } | if self.assistant_candidate { obc_formats::obcr::FLAG_ASSISTANT_CANDIDATE } else { 0 } + | obc_formats::obcr::FLAG_TEMPORARY | if matches!(self.leg, Leg::Rest { .. }) { obc_formats::obcr::FLAG_BUILT_DAY } else { 0 }, ); // Preserve the sampled heights the planner densified. diff --git a/firmware/obc-route/tests/cases/detour.rs b/firmware/obc-route/tests/cases/detour.rs index cac378c3f..b2ae336f3 100644 --- a/firmware/obc-route/tests/cases/detour.rs +++ b/firmware/obc-route/tests/cases/detour.rs @@ -657,8 +657,9 @@ fn an_approach_splice_is_the_leg_then_the_whole_route() { let src = SliceSource(&sink.buf[..]); let idx = RouteIndex::read(&src).unwrap(); let spliced = RouteReader::new(&idx, &src); - assert_eq!(idx.name(), "To start · Road trip", "the Routes list tells it from the route"); + assert_eq!(idx.name(), "To start · Road trip", "the active route identifies its connection"); assert!(!idx.has_unresolved_avoidance(), "an approach avoids nothing"); + assert_ne!(sink.buf[5] & obc_formats::obcr::FLAG_TEMPORARY, 0); assert_eq!(idx.bike_type(), BikeType::Touring, "an approach keeps the route's type, not the leg's"); assert!( stats.total_distance_m.abs_diff(leg_m + route_m) <= 2, diff --git a/firmware/obc-route/tests/cases/nav.rs b/firmware/obc-route/tests/cases/nav.rs index f59574543..72a950e9d 100644 --- a/firmware/obc-route/tests/cases/nav.rs +++ b/firmware/obc-route/tests/cases/nav.rs @@ -140,6 +140,7 @@ fn grid_route_matches_known_optimum_and_round_trips() { let src = SliceSource(&obcr); let idx = RouteIndex::read(&src).expect("round trip"); assert_eq!(idx.name(), "Water stop"); + assert_ne!(obcr[5] & obc_formats::obcr::FLAG_TEMPORARY, 0); let info = RouteObjectInfo::read(&src).unwrap(); assert_eq!(info.distance_m, 4474, "header length = summed edge costs"); assert_eq!(info.ascent_m, 0); @@ -1273,7 +1274,7 @@ fn a_null_elevation_plan_emits_the_pre_terrain_bytes() { } /// FNV-1a of the no-terrain emit for the fixture above. -const NULL_PATH_DIGEST: u64 = 2855711472671813495; +const NULL_PATH_DIGEST: u64 = 1853384897492501207; /// A real source fills every point's height and the header's min, max and dead-banded climb. The /// crest is reachable only through the densification: a vertex-only fill tops out at 900 m. diff --git a/host/obc-host-core/src/flat_routes.rs b/host/obc-host-core/src/flat_routes.rs index 5451d701a..c76885cd5 100644 --- a/host/obc-host-core/src/flat_routes.rs +++ b/host/obc-host-core/src/flat_routes.rs @@ -87,7 +87,7 @@ impl FlatRouteStore { } fn publish(&mut self, meta: EntryMeta, summary: RouteSummary, flags: u8) { - use obc_formats::obcr::{FLAG_ASSISTANT_CANDIDATE, FLAG_BUILT_DAY}; + use obc_formats::obcr::{FLAG_ASSISTANT_CANDIDATE, FLAG_BUILT_DAY, FLAG_TEMPORARY}; let candidate = flags & FLAG_ASSISTANT_CANDIDATE != 0; let built = flags & FLAG_BUILT_DAY != 0; let i = if let Some(i) = self.ids.iter().position(|&id| id == meta.id.0) { @@ -104,7 +104,7 @@ impl FlatRouteStore { if i < 64 { self.internal_routes &= !(1 << i); self.built_day &= !(1 << i); - if candidate || built { + if candidate || built || flags & FLAG_TEMPORARY != 0 { self.internal_routes |= 1 << i; } if built { @@ -189,7 +189,7 @@ impl RouteRepository for FlatRouteStore { .map_err(|_| obc_app::metadata::MetadataError::WriteFailed)? .map_err(|_| obc_app::metadata::MetadataError::WriteFailed)?; let candidate = flags & obc_formats::obcr::FLAG_ASSISTANT_CANDIDATE != 0; - if candidate || flags & obc_formats::obcr::FLAG_BUILT_DAY != 0 { + if candidate || flags & (obc_formats::obcr::FLAG_BUILT_DAY | obc_formats::obcr::FLAG_TEMPORARY) != 0 { internal_routes |= 1 << ids.len(); } if flags & obc_formats::obcr::FLAG_BUILT_DAY != 0 { diff --git a/host/obc-host-core/src/flat_routes/tests.rs b/host/obc-host-core/src/flat_routes/tests.rs index 16d081eb2..5eea0321f 100644 --- a/host/obc-host-core/src/flat_routes/tests.rs +++ b/host/obc-host-core/src/flat_routes/tests.rs @@ -175,3 +175,25 @@ fn a_built_day_replaces_the_one_before_it_and_stays_out_of_the_list() { assert_eq!(routes.internal_routes(), 1 << index, "and stays so after a catalog read"); assert_ne!(routes.publish_nav_route(ROUTE).unwrap().id, first.id, "a plain route is its own object"); } + +#[test] +fn temporary_navigation_stays_readable_but_hidden_after_catalog_reload() { + let owner = HostStore::memory().unwrap(); + let mut routes = FlatRouteStore::new(owner.clone(), &[ROUTE]).unwrap(); + let original = routes.ids()[0]; + let mut temporary = ROUTE.to_vec(); + temporary[5] |= obc_formats::obcr::FLAG_TEMPORARY; + let publication = routes.publish_nav_route(&temporary).unwrap(); + for pass in 0..3 { + let index = routes.ids().iter().position(|&id| id == publication.id).unwrap(); + assert_eq!(routes.internal_routes(), 1 << index); + assert_eq!(routes.unaccepted_routes(), 0, "a temporary ride is not an abandoned review"); + assert_eq!(bytes(&routes.source(publication.id).unwrap()), temporary); + assert_eq!(bytes(&routes.source(original).unwrap()), ROUTE); + if pass == 0 { + routes.refresh_metadata().unwrap(); + } else { + routes = FlatRouteStore::new(owner.clone(), &[]).unwrap(); + } + } +} From 260a72326150e7354874437963b4ed30b3a2f303 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:02:58 +0200 Subject: [PATCH 04/13] docs: define temporary navigation route flag --- specs/OBCR_Spec.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/specs/OBCR_Spec.md b/specs/OBCR_Spec.md index e3ddcf993..d52faa0c6 100644 --- a/specs/OBCR_Spec.md +++ b/specs/OBCR_Spec.md @@ -42,7 +42,7 @@ streamed out (§5). | :-- | :-- | :-- | :-- | :-- | | 0 | Magic | 4 | `char[4]` | Must be `b"OBCR"` | | 4 | Version | 1 | `uint8` | `0x05`; readers reject anything else | -| 5 | Flags | 1 | `uint8` | bit 0 unresolved avoidance; bit 1 at least one valid elevation; bit 2 attribution-map identity present; bit 3 Assistant candidate; bit 4 built trip day; other bits zero | +| 5 | Flags | 1 | `uint8` | bit 0 unresolved avoidance; bit 1 at least one valid elevation; bit 2 attribution-map identity present; bit 3 Assistant candidate; bit 4 built trip day; bit 5 temporary navigation route; other bits zero | | 6 | Name Len | 1 | `uint8` | Used bytes of the Name field (≤ 48) | | 7 | Bike Type | 1 | `uint8` | §1.2; readers reject values above `3` | | 8 | Min Lon | 4 | `int32` | Global bbox, microdegrees | @@ -99,6 +99,9 @@ A built trip day is the rest of one trip day spliced in front of the next day. A for one ride. Route lists do not show it. A card holds at most one: the next build replaces it in place, with the same object ID and the next revision. +A temporary navigation route contains generated directions, including connections and detours. +Saved-route lists do not show it. The device can store it to follow or resume the ride. + ### 1.2 Bike type and estimated time Every route has one of four fixed bike types. The value is also the index of the map's routing From 52670740f08bedec113bea86768830cede6f7af1 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:02:58 +0200 Subject: [PATCH 05/13] test: follow explicit start fallback in UI scenarios --- apps/obc-web-demo/src/demo.rs | 7 +++- firmware/ui-frames.toml | 72 ++++++++++++++++++----------------- firmware/ui-snapshots.sha256 | 18 ++++----- 3 files changed, 52 insertions(+), 45 deletions(-) diff --git a/apps/obc-web-demo/src/demo.rs b/apps/obc-web-demo/src/demo.rs index 0d6cb0c0d..2cdaf4eb3 100644 --- a/apps/obc-web-demo/src/demo.rs +++ b/apps/obc-web-demo/src/demo.rs @@ -918,7 +918,12 @@ mod tests { drive(&mut d, &mut now, "step:1", "StartAway"); drive(&mut d, &mut now, "press", "Map"); assert!(d.app.recording(), "Join nearest begins the session before the next chapter"); - assert!(d.app.progress_m() > 1_000, "the ride joins the route mid-climb"); + d.cmd("play"); + for _ in 0..4 { + now += 250.0; + d.tick(now); + } + assert!(d.app.progress_m() > 1_000, "the first riding fix joins the route mid-climb"); } #[test] diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 496f21d7d..d22306016 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -40,6 +40,8 @@ peaks = "{repo}/apps/obc-sim/assets/grimsel-demo.obcm" wptgpx = "{repo}/fixtures/sources/vector/vector-loop-replay.gpx" [script] +# Start a stored route outside map coverage after the connection fails. +start_unguided = "p*4 f p f p" # Menu navigation: Home's press (and back-hold) opens the compass Menu — the single door into the # app — so the Route menu is `p p` from boot (open Menu, then press the Routes station, which the # menu starts on). The compass menu is Routes / Rides / Map / Peaks / Settings, so Settings is one @@ -316,7 +318,7 @@ expect = "RideDetail" name = "ride-detail-recording" map = "{grimsel}" env = ["routes", "tracks"] -script = "p*4 f d p B d w p p p H" +script = "{start_unguided} B d w p p p H" expect = "RideDetail" args = ["--gpx", "{gpx}", "--at", "30"] @@ -766,7 +768,7 @@ expect = "Firmware" name = "firmware-recording" map = "{grimsel}" env = ["routes", "tracks"] -script = "p*4 f d p B u p d*4 p d d d p" +script = "{start_unguided} B u p d*4 p d d d p" expect = "Firmware" args = ["--gpx", "{gpx}", "--at", "30"] @@ -1006,7 +1008,7 @@ args = ["--gpx", "{gpx}", "--script-at", "5611", "--at", "5611"] name = "map" map = "{grimsel}" env = "routes" -script = "p*4 f d p" +script = "{start_unguided}" expect = "Map" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] @@ -1082,7 +1084,7 @@ boot = false name = "statistics" map = "{grimsel}" env = "routes" -script = "p*4 f d p b" +script = "{start_unguided} b" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "30"] @@ -1092,7 +1094,7 @@ args = ["--gpx", "{gpx}", "--at", "30"] name = "statistics-pan" map = "{grimsel}" env = "routes" -script = "p*4 f d p b h w" +script = "{start_unguided} b h w" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "30"] @@ -1100,7 +1102,7 @@ args = ["--gpx", "{gpx}", "--at", "30"] name = "statistics-zoom" map = "{grimsel}" env = "routes" -script = "p*4 f d p b h p d d d w" +script = "{start_unguided} b h p d d d w" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "30"] @@ -1108,7 +1110,7 @@ args = ["--gpx", "{gpx}", "--at", "30"] name = "statistics-pan-zoomed" map = "{grimsel}" env = "routes" -script = "p*4 f d p b h p d d d p d d w" +script = "{start_unguided} b h p d d d p d d w" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "30"] @@ -1121,7 +1123,7 @@ args = ["--gpx", "{gpx}", "--at", "30"] name = "statistics-sensors" map = "{grimsel}" env = "routes" -script = "p*4 f d p b" +script = "{start_unguided} b" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "30", "--sensors", "demo"] @@ -1182,7 +1184,7 @@ expect = "RouteOverview" name = "map-lowbatt" map = "{grimsel}" env = "routes" -script = "p*4 f d p" +script = "{start_unguided}" expect = "Map" args = ["--clock", "2025-06-29T14:40", "--battery", "5", "--gpx", "{gpx}", "--at", "30"] @@ -1236,7 +1238,7 @@ args = ["--gpx", "{gpx}", "--script-at", "30", "--at", "30"] name = "ridecontrol" map = "{grimsel}" env = "routes" -script = "p*4 f d p p" +script = "{start_unguided} p" expect = "RideControl" args = ["--gpx", "{gpx}", "--at", "30"] @@ -1256,7 +1258,7 @@ args = ["--gpx", "{gpx}", "--at", "30"] name = "map-context" map = "{grimsel}" env = "routes" -script = "p*4 f d p C" +script = "{start_unguided} C" expect = "ContextDrawer" args = ["--gpx", "{gpx}", "--at", "30"] @@ -1268,7 +1270,7 @@ args = ["--gpx", "{gpx}", "--at", "30"] name = "map-display-sheet" map = "{grimsel}" env = "routes" -script = "p*4 f d p C u p" +script = "{start_unguided} C u p" expect = "ContextDrawer" args = ["--gpx", "{gpx}", "--at", "30"] @@ -1281,7 +1283,7 @@ args = ["--gpx", "{gpx}", "--at", "30"] name = "map-display-clock-off" map = "{grimsel}" env = "routes" -script = "p*4 f d p C u p p" +script = "{start_unguided} C u p p" expect = "ContextDrawer" args = ["--gpx", "{gpx}", "--at", "30"] @@ -1767,7 +1769,7 @@ expect = "Home" name = "quick-root" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q" +script = "{start_unguided} Q" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] @@ -1775,7 +1777,7 @@ args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] name = "quick-assistant" map = "{grimsel}" env = "routes" -script = "p*4 f d p A w" +script = "{start_unguided} A w" expect = "Assistant" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] @@ -1783,7 +1785,7 @@ args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] name = "quick-bluetooth-off" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q d p w" +script = "{start_unguided} Q d p w" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] @@ -1791,7 +1793,7 @@ args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] name = "quick-brightness" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q p w" +script = "{start_unguided} Q p w" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] @@ -1799,7 +1801,7 @@ args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] name = "quick-power-confirm" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q d d d p w" +script = "{start_unguided} Q d d d p w" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] @@ -1807,7 +1809,7 @@ args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] name = "quick-power-hold" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q d d d p w H" +script = "{start_unguided} Q d d d p w H" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] @@ -1820,7 +1822,7 @@ args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] name = "quick-root-no-backlight" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q" +script = "{start_unguided} Q" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30", "--no-backlight"] @@ -1893,7 +1895,7 @@ langs = ["de", "fr", "es"] name = "statistics" map = "{grimsel}" env = "routes" -script = "p*4 f d p b" +script = "{start_unguided} b" expect = "Statistics" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -1902,7 +1904,7 @@ langs = ["de", "fr", "es"] name = "map" map = "{grimsel}" env = "routes" -script = "p*4 f d p" +script = "{start_unguided}" expect = "Map" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2104,7 +2106,7 @@ langs = ["de", "fr", "es"] name = "map-context" map = "{grimsel}" env = "routes" -script = "p*4 f d p C" +script = "{start_unguided} C" expect = "ContextDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2113,7 +2115,7 @@ langs = ["de", "fr", "es"] name = "map-display-sheet" map = "{grimsel}" env = "routes" -script = "p*4 f d p C u p" +script = "{start_unguided} C u p" expect = "ContextDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2124,7 +2126,7 @@ langs = ["de", "fr", "es"] name = "up-ahead-context" map = "{grimsel}" env = "routes" -script = "p*4 f d p A d p f p f C" +script = "{start_unguided} A d p f p f C" expect = "ContextDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2133,7 +2135,7 @@ langs = ["de", "fr", "es"] name = "up-ahead-filter-editor" map = "{grimsel}" env = "routes" -script = "p*4 f d p A d p f p f C p w d d" +script = "{start_unguided} A d p f p f C p w d d" expect = "ContextDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2153,7 +2155,7 @@ langs = ["de", "fr", "es"] name = "quick-root" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q" +script = "{start_unguided} Q" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2162,7 +2164,7 @@ langs = ["de", "fr", "es"] name = "quick-assistant" map = "{grimsel}" env = "routes" -script = "p*4 f d p A w" +script = "{start_unguided} A w" expect = "Assistant" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2171,7 +2173,7 @@ langs = ["de", "fr", "es"] name = "quick-bluetooth-off" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q d p w" +script = "{start_unguided} Q d p w" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2180,7 +2182,7 @@ langs = ["de", "fr", "es"] name = "quick-brightness" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q p w" +script = "{start_unguided} Q p w" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2189,7 +2191,7 @@ langs = ["de", "fr", "es"] name = "quick-power-confirm" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q d d d p w" +script = "{start_unguided} Q d d d p w" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2198,7 +2200,7 @@ langs = ["de", "fr", "es"] name = "quick-power-hold" map = "{grimsel}" env = "routes" -script = "p*4 f d p Q d d d p w H" +script = "{start_unguided} Q d d d p w H" expect = "QuickDrawer" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] langs = ["de", "fr", "es"] @@ -2311,7 +2313,7 @@ expect = "RouteOverview" name = "dark-map" map = "{grimsel}" env = "routes" -script = "{theme_dark} p*4 f d p" +script = "{theme_dark} {start_unguided}" expect = "Map" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] @@ -2319,7 +2321,7 @@ args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] name = "dark-statistics" map = "{grimsel}" env = "routes" -script = "{theme_dark} p*4 f d p b" +script = "{theme_dark} {start_unguided} b" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "30"] @@ -2327,7 +2329,7 @@ args = ["--gpx", "{gpx}", "--at", "30"] name = "dark-map-context" map = "{grimsel}" env = "routes" -script = "{theme_dark} p*4 f d p C" +script = "{theme_dark} {start_unguided} C" expect = "ContextDrawer" args = ["--gpx", "{gpx}", "--at", "30"] diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index bd3cc9d7b..dea780a71 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -43,7 +43,7 @@ e1d0e716bb82fb849b40d0dea76942909541a7992e30b21284f632d82c10f98f dark-rides.png c4c7409a9dce7389887dca5001f1c7e9fcc5645b18440890c6509b9097168e9f dark-routeoverview-delete.png a992db26619d5b7915ce6d4463d331d52494ab2f3d284d2b436ee30f11487a5c dark-routeoverview.png bc56224dfc18b2016bca6e5c0fb303b582639d831cb97a5d9da915578c431b9e dark-settings.png -de2bd24c6e8aaa6647b5597498ebfcff05539573ceb38a6b789244642e1cb42c dark-statistics.png +16a1864c9296ad5116fa731cfc5dc87538ea72d44b3a2855d1b66790d78b6286 dark-statistics.png 0695494495b4e202126dc2de004f8f52382e6f6c03818a67d61762d3e6749eb4 dark-up-ahead.png 080eb58b97a545641ece697febcf8c41ad5faa2a8226214e0433a74b7d66a038 dark-warning.png 43ae8945c33e2b54d3370b6b24ce231dc5a8513d192c6a55141f7d7a94e9f235 datetime-de.png @@ -291,19 +291,19 @@ def75c60c02b8a81143681b19ac76fa65f70b5065100633103a7b370e64c3018 sensors-scanni defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-noroute.png bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png aef19ed12372f568f146e1e06235d6b64c8720d5d7726d0ca5d13ffdaa11ac0d start-away.png -02bb24bca7a65ce20b231456d8ce665af323af4d5031ac90c5ad02aef1356567 statistics-de.png -c756c3340fa9499fa6fcff1f4c8dbcde791f724aed1118d594368691b57eea83 statistics-es.png +134131ba24b3957240757b8d3d78f6d268b333cee651afb77b80396401bca3c4 statistics-de.png +82056bf09cbc35f971af4667f6eeca660751bd6973b861d76ff6533c95a404d2 statistics-es.png c238326a4e35550116b83eabf8aabb64c475dc4dbd20d28a69d77a02bc1d79aa statistics-eta-flat.png b98d757e6e5f5acaa9aa1a0c4959c89e30e15f7ac75267606913921db5d5dad7 statistics-eta.png -6cc76f43ca90205f7eba411b84947598f9dd0963a32b091c1cfbbf69f4abe982 statistics-fr.png -2b551afbab7f8613d9ed922045f9988b809b6ce343584c2b1673941381bcc3f1 statistics-pan-zoomed.png -673b2234677761406daf3d0868562ab37e691ee75f022fe7feb874f11ed3b2a2 statistics-pan.png +29448d5b1343cd12e83e2e9a3181cbf09a97a7a630a0c8bcde6598c61b251184 statistics-fr.png +5fb3897a85ca5a71c150792f9e76f313f31d429626639bcd50aab0cf71a5693b statistics-pan-zoomed.png +fbec9d366ed503d37cf0216ba48686f78c25bf99fe0a29136772566a17c010bf statistics-pan.png 7f26aadeba326670827eced49b831f48a4d2afc6be8e98e26ca7b9717f86f96b statistics-routeless.png -682c1e44f26bccc7f65e992fc687f3c8edf3600c6bc64c321c7d8fc14a225006 statistics-sensors.png +204e003333d181adae4465bcb7d9c1bbaa3b88ad07bfee81e4132551caf325a3 statistics-sensors.png 8c7bc2254494ba734583a4fb13b2f7d8a2a314ee0f3e814224e6b30e0520e3c1 statistics-trip-none.png d486be4581fca0b856eb22a7d23b2f6191f9b0be4dc06c226aa1ebea414b9f71 statistics-trip.png -624460c9e6e6fbcdc2d0db7b113ec0a274ef5a406ba4a28d8e15b6c8623c594c statistics-zoom.png -f6022de649c249da325d355b0cd57bb3c2fd76de7d4a59aa5c6a0dafaae59d2f statistics.png +99790c3c1cb2e7bb4a1c7838e84df8e96b976c6580f7f25638da1b462806e742 statistics-zoom.png +fbec9d366ed503d37cf0216ba48686f78c25bf99fe0a29136772566a17c010bf statistics.png fcc4713120d0c587b312f4b68a5dd73594b5b49b6d1a468e92e3bb2d00f5968c stats-next-category-empty.png ba75743ed70d59c82ba74f3c4bbd95ce3a5297091af06d5d45b2966202db8862 stats-next-category.png d597695661fad7c6920e1cf80bc786b65a742083737587e36a75d273652b11dd stats-wpt.png From 84b39aefaee943491f626fa30cd6fbfb7ee754db Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:13:23 +0200 Subject: [PATCH 06/13] fix: retire temporary directions after navigation releases them --- firmware/obc-app/src/app.rs | 4 + firmware/obc-app/src/catalog_state.rs | 18 ++--- firmware/obc-app/src/device_core/pass.rs | 1 + firmware/obc-app/src/find_place.rs | 1 - firmware/obc-app/src/navigator/review.rs | 80 ++++++++++++++++--- firmware/obc-formats/src/obcr.rs | 5 ++ firmware/obc-fw-nrf54l/src/flat_store.rs | 5 ++ firmware/obc-fw-nrf54l/src/ride.rs | 6 +- host/obc-host-core/src/dispatch.rs | 9 ++- host/obc-host-core/src/flat_routes.rs | 20 ++++- host/obc-host-core/src/flat_routes/tests.rs | 27 +++++++ host/obc-host-core/src/repo.rs | 7 +- .../tests/board_detour_support/visit_tests.rs | 6 +- .../tests/device_core_conformance.rs | 6 +- 14 files changed, 158 insertions(+), 37 deletions(-) diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 2446523bb..8572f9dde 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -959,6 +959,10 @@ impl App { self.ui.map_dirty = true; } } + /// Temporary directions can be retired after the ride and checkpoint release them. + pub fn set_temporary_routes(&mut self, mask: u64) { + self.navigator.set_temporary_routes(mask); + } pub fn set_unaccepted_routes(&mut self, mask: u64) { if self.navigator.unaccepted_routes() != mask { self.navigator.set_unaccepted_routes(mask); diff --git a/firmware/obc-app/src/catalog_state.rs b/firmware/obc-app/src/catalog_state.rs index a60a46944..af8963806 100644 --- a/firmware/obc-app/src/catalog_state.rs +++ b/firmware/obc-app/src/catalog_state.rs @@ -534,8 +534,8 @@ use crate::device_core::{CatalogTag, OperationToken, StoreRevision}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogIntent { - /// Remove unaccepted Assistant candidates no review holds, a commit's batch at a time. - RemoveOrphanReviews, + /// Remove unused temporary directions and abandoned candidates, one commit at a time. + RemoveOrphanRoutes, CleanupRoutes { before_utc: u32, store: crate::device_core::StoreIdentity, @@ -565,7 +565,7 @@ pub enum CatalogObjectKind { /// One bounded physical catalog operation, carrying the [`OperationToken`] the domain issued. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogEffect { - RemoveOrphanReviews { + RemoveOrphanRoutes { token: OperationToken, }, CleanupRoute { @@ -590,7 +590,7 @@ impl CatalogEffect { CatalogEffect::CleanupRoute { token, .. } | CatalogEffect::ReadCatalog { token } | CatalogEffect::RemoveObject { token, .. } - | CatalogEffect::RemoveOrphanReviews { token } => *token, + | CatalogEffect::RemoveOrphanRoutes { token } => *token, } } } @@ -609,7 +609,7 @@ pub enum CatalogError { /// The result of one [`CatalogEffect`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogOutcome { - OrphanReviewsRemoved { + OrphanRoutesRemoved { token: OperationToken, }, CleanupFinished { @@ -644,7 +644,7 @@ impl CatalogOutcome { CatalogOutcome::CleanupFinished { token } | CatalogOutcome::CatalogRead { token, .. } | CatalogOutcome::ObjectRemoved { token, .. } - | CatalogOutcome::OrphanReviewsRemoved { token } + | CatalogOutcome::OrphanRoutesRemoved { token } | CatalogOutcome::Failed { token, .. } | CatalogOutcome::Cancelled { token } => *token, } @@ -738,7 +738,7 @@ impl CatalogState { return Some(CatalogEffect::ReadCatalog { token: self.ops.issue() }); }; let effect = match intent { - CatalogIntent::RemoveOrphanReviews => CatalogEffect::RemoveOrphanReviews { token: self.ops.issue() }, + CatalogIntent::RemoveOrphanRoutes => CatalogEffect::RemoveOrphanRoutes { token: self.ops.issue() }, CatalogIntent::CleanupRoutes { before_utc, store } => { self.cleanup_running = true; self.pending = Some(intent); @@ -833,7 +833,7 @@ impl CatalogState { self.read_retry_at = None; None } - CatalogOutcome::OrphanReviewsRemoved { .. } => { + CatalogOutcome::OrphanRoutesRemoved { .. } => { self.loaded_scope = None; self.refresh_owed = true; None @@ -1156,7 +1156,7 @@ mod tests { for _ in 0..=steps.capacity() { let Some(effect) = catalogs.next_effect() else { break }; match effect { - CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanReviews { .. } => { + CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => { panic!("unexpected cleanup") } CatalogEffect::RemoveObject { token, object, .. } => { diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index f1c649308..318eb08ee 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -465,6 +465,7 @@ impl App { let _ = self.pass.connections.ui_catalog.try_put(full.rejected); } } + self.cleanup_orphan_routes(); // The store refuses a removal for an object the checkpoint names, and a refused removal is // not retried, so the rider's confirmed delete would vanish. Give up the checkpoint first // and leave the intent admitted: the admitted intent is what the domain would pull next, so diff --git a/firmware/obc-app/src/find_place.rs b/firmware/obc-app/src/find_place.rs index b6d4e3096..ce8b69947 100644 --- a/firmware/obc-app/src/find_place.rs +++ b/firmware/obc-app/src/find_place.rs @@ -580,7 +580,6 @@ impl crate::App { } } self.handle_find_exit(); - self.cleanup_orphan_reviews(); self.ui.find.review = self.assistant_review_status(); if self.assistant_review_status() == ReviewStatus::Accepted && self.active_visit() { if let Some(Screen::VisitReview(screen)) = self.ui.stack.last_mut() { diff --git a/firmware/obc-app/src/navigator/review.rs b/firmware/obc-app/src/navigator/review.rs index 67b7f3408..cc77d6ddb 100644 --- a/firmware/obc-app/src/navigator/review.rs +++ b/firmware/obc-app/src/navigator/review.rs @@ -183,6 +183,7 @@ pub(super) struct ReviewState { pub preview_index: Option, pub unaccepted: u64, pub internal_routes: u64, + pub temporary_routes: u64, pub checkpoint: Option, pub change: Option>, pub token: Option>, @@ -204,6 +205,7 @@ impl ReviewState { preview_index: None, unaccepted: 0, internal_routes: 0, + temporary_routes: 0, checkpoint: None, change: None, token: None, @@ -267,6 +269,9 @@ impl NavigatorMachine { pub(crate) fn set_internal_routes(&mut self, mask: u64) { self.review.internal_routes = mask; } + pub(crate) fn set_temporary_routes(&mut self, mask: u64) { + self.review.temporary_routes = mask; + } pub(crate) fn set_unaccepted_routes(&mut self, mask: u64) { self.review.unaccepted = mask; } @@ -314,7 +319,7 @@ impl NavigatorMachine { true } pub(crate) fn remap_review_keys(&mut self, remap: &dyn Fn(usize) -> Option) { - for mask in [&mut self.review.unaccepted, &mut self.review.internal_routes] { + for mask in [&mut self.review.unaccepted, &mut self.review.internal_routes, &mut self.review.temporary_routes] { let mut next = 0; for old in 0..64 { if *mask & (1 << old) != 0 { @@ -694,10 +699,10 @@ impl crate::App { pub(crate) fn assistant_measuring(&self) -> bool { self.navigator.review.measure } - /// Unaccepted candidates no review holds are what an interrupted review left on the card. + /// Retire abandoned candidates and temporary directions no ride or checkpoint holds. /// Each effect removes a commit's batch of them; a search in progress owns its candidate, so /// the removal waits for it and never the other way round. - pub(crate) fn cleanup_orphan_reviews(&mut self) { + pub(crate) fn cleanup_orphan_routes(&mut self) { if self.ui.find.owns_pages() || !self.catalogs.can_admit_intent() || !self.assistant_planner_released() @@ -711,20 +716,38 @@ impl crate::App { { return; } - if self.orphan_reviews().next().is_some() - && self.catalogs.admit_intent(crate::catalog_state::CatalogIntent::RemoveOrphanReviews).is_ok() + if self.orphan_routes().next().is_some() + && self.catalogs.admit_intent(crate::catalog_state::CatalogIntent::RemoveOrphanRoutes).is_ok() { self.ui.next_wake_ms = Some(1); } } - /// The unaccepted candidates the standing checkpoint does not name. - pub fn orphan_reviews(&self) -> impl Iterator + '_ { - let held = - self.assistant_checkpoint().map_or([None; 2], |c| [Some(c.route.object), c.original.map(|o| o.object)]); + /// Disposable routes outside the active ride and its recovery sources. + pub fn orphan_routes(&self) -> impl Iterator + '_ { + let available = self.assistant_planner_released() + && self.assistant_preview().is_none() + && self.navigator.review.change.is_none() + && !self.assistant_needs_recovery(); + let checkpoint = self.assistant_checkpoint(); + let held = [ + checkpoint.map(|c| c.route.object), + checkpoint.and_then(|c| c.original.map(|o| o.object)), + self.active_route_index().and_then(|i| self.route_ids().get(i).copied()), + self.navigator + .lead_in() + .filter(|lead| { + self.active_route_index().and_then(|i| self.route_ids().get(i).copied()) == Some(lead.splice) + }) + .map(|lead| lead.route), + ]; self.route_ids() .iter() .enumerate() - .filter(move |(index, id)| self.route_unaccepted(*index) && !held.contains(&Some(**id))) + .filter(move |(index, id)| { + available + && (self.route_unaccepted(*index) || self.navigator.review.temporary_routes & (1 << index) != 0) + && !held.contains(&Some(**id)) + }) .map(|(_, id)| *id) } pub fn accept_assistant(&mut self, origin: ReviewOrigin) { @@ -1548,6 +1571,43 @@ mod tests { } } + #[test] + fn temporary_cleanup_keeps_active_recovery_and_current_lead_in_sources() { + use obc_formats::io::SliceSource; + let (bytes, _) = ordinary_route(); + let summary = obc_route::RouteSummary::read(&SliceSource(&bytes)).unwrap(); + let mut app = mounted(&summary, 4); + app.navigator.offer_checkpoint(ordinary_store(), None); + app.set_routes_with_ids(&[summary.clone(), summary.clone(), summary.clone(), summary], &[4, 5, 6, 7]); + app.set_temporary_routes(0b1111); + app.activate_route(2); + app.navigator.lead_in = + Some(super::super::LeadIn { splice: 6, route: 7, lead_m: 100, join_m: 0, rest_from_m: None }); + app.navigator.review.checkpoint = Some(NavigatorCheckpoint { + route: source(5), + original: Some(source(4)), + progress_m: 100, + occurrence: 2, + lon: 8_000_000, + lat: 47_000_000, + phase: JourneyPhase::Outbound, + unresolved_avoidance: false, + selection: false, + lower_m: 0, + upper_m: 500, + }); + assert_eq!(app.orphan_routes().count(), 0); + app.navigator.review.checkpoint = None; + assert_eq!(app.orphan_routes().collect::>(), [4, 5]); + app.activate_route(0); + assert_eq!(app.orphan_routes().collect::>(), [5, 6, 7], "a stale lead-in holds nothing"); + app.cleanup_orphan_routes(); + assert!(matches!( + app.catalogs.next_effect(), + Some(crate::catalog_state::CatalogEffect::RemoveOrphanRoutes { .. }) + )); + } + #[test] fn reboot_only_offers_resume_and_requires_same_phase_occurrence() { let checkpoint = NavigatorCheckpoint { diff --git a/firmware/obc-formats/src/obcr.rs b/firmware/obc-formats/src/obcr.rs index 733451dd5..466354d86 100644 --- a/firmware/obc-formats/src/obcr.rs +++ b/firmware/obc-formats/src/obcr.rs @@ -28,6 +28,11 @@ pub const FLAG_ASSISTANT_CANDIDATE: u8 = 8; pub const FLAG_BUILT_DAY: u8 = 16; /// Generated navigation geometry. Saved-route lists do not show it. pub const FLAG_TEMPORARY: u8 = 32; + +/// Assistant candidates and built days have their own retention policy. +pub const fn disposable_navigation(flags: u8) -> bool { + flags & FLAG_TEMPORARY != 0 && flags & (FLAG_ASSISTANT_CANDIDATE | FLAG_BUILT_DAY) == 0 +} pub const WAYPOINT_PROVENANCE_OFF: usize = 44; pub const VISIT_DESCRIPTOR_VERSION: u8 = 1; pub const VISIT_DESCRIPTOR_LEN: usize = 80; diff --git a/firmware/obc-fw-nrf54l/src/flat_store.rs b/firmware/obc-fw-nrf54l/src/flat_store.rs index 1407630af..6a8cca164 100644 --- a/firmware/obc-fw-nrf54l/src/flat_store.rs +++ b/firmware/obc-fw-nrf54l/src/flat_store.rs @@ -1674,11 +1674,15 @@ pub(crate) fn load_routes(store: &'static FlatStore, app: &mut obc_app let mut ids: heapless::Vec = heapless::Vec::new(); let mut candidates = 0u64; let mut internal_routes = 0u64; + let mut temporary_routes = 0u64; for (index, entry) in heads.into_iter().enumerate() { match store .with_source(entry.id, Some(entry.revision), |source| obc_route::RouteSummary::read_with_flags(source)) { Ok(Ok((summary, flags))) => { + if obc_formats::obcr::disposable_navigation(flags) { + temporary_routes |= 1 << routes.len(); + } let candidate = flags & obc_formats::obcr::FLAG_ASSISTANT_CANDIDATE != 0; if candidate || flags & (obc_formats::obcr::FLAG_BUILT_DAY | obc_formats::obcr::FLAG_TEMPORARY) != 0 { internal_routes |= 1 << routes.len(); @@ -1706,6 +1710,7 @@ pub(crate) fn load_routes(store: &'static FlatStore, app: &mut obc_app } app.set_routes_with_ids(&routes, &ids); app.set_internal_routes(internal_routes); + app.set_temporary_routes(temporary_routes); app.set_unaccepted_routes(candidates); if app.assistant_needs_recovery() { if let Ok(checkpoint) = obc_storage::flat::metadata::read_checkpoint(store) { diff --git a/firmware/obc-fw-nrf54l/src/ride.rs b/firmware/obc-fw-nrf54l/src/ride.rs index 21f61ce04..183dae639 100644 --- a/firmware/obc-fw-nrf54l/src/ride.rs +++ b/firmware/obc-fw-nrf54l/src/ride.rs @@ -1111,8 +1111,8 @@ pub(crate) async fn run_app( if let Some(effect) = exec.effects.catalog.take() { use obc_app::catalog_state::{CatalogEffect, CatalogError, CatalogOutcome}; match effect { - CatalogEffect::RemoveOrphanReviews { token } => { - let heads = crate::flat_store::route_heads(flat, app.orphan_reviews()); + CatalogEffect::RemoveOrphanRoutes { token } => { + let heads = crate::flat_store::route_heads(flat, app.orphan_routes()); let result = if heads.is_empty() { Ok(()) } else if let Some(writer) = crate::flat_store::writer() { @@ -1124,7 +1124,7 @@ pub(crate) async fn run_app( Err(obc_storage::flat::StoreError::ReadOnly) }; let outcome = match result { - Ok(()) => CatalogOutcome::OrphanReviewsRemoved { token }, + Ok(()) => CatalogOutcome::OrphanRoutesRemoved { token }, Err(_) => CatalogOutcome::Failed { token, error: CatalogError::RemoveFailed }, }; RideExec::deliver(&mut exec.outcomes.catalog, outcome, "catalog"); diff --git a/host/obc-host-core/src/dispatch.rs b/host/obc-host-core/src/dispatch.rs index e2dd931d2..6a24a50f3 100644 --- a/host/obc-host-core/src/dispatch.rs +++ b/host/obc-host-core/src/dispatch.rs @@ -21,6 +21,7 @@ use crate::{ActiveRouteSession, NavPlan, RideRepository, RouteRepository, TrackR pub(crate) fn feed_routes(app: &mut App, routes: &dyn RouteRepository, trace: &mut dyn TraceSink) { app.set_routes_with_ids(routes.catalog(), routes.ids()); app.set_internal_routes(routes.internal_routes()); + app.set_temporary_routes(routes.temporary_routes()); app.set_unaccepted_routes(routes.unaccepted_routes()); trace.feeder(FeederCall::new(FeederKind::RouteCatalog, DataKey::from("host.routes"), routes.catalog().len())); } @@ -603,10 +604,10 @@ impl HostLoop { trips: &mut dyn TripCatalog, ) -> CatalogOutcome { match effect { - CatalogEffect::RemoveOrphanReviews { token } => { - let orphans: Vec<_> = app.orphan_reviews().collect(); - match routes.retract_reviews(&orphans) { - Ok(()) => CatalogOutcome::OrphanReviewsRemoved { token }, + CatalogEffect::RemoveOrphanRoutes { token } => { + let orphans: Vec<_> = app.orphan_routes().collect(); + match routes.retract_generated_routes(&orphans) { + Ok(()) => CatalogOutcome::OrphanRoutesRemoved { token }, Err(error) => CatalogOutcome::Failed { token, error }, } } diff --git a/host/obc-host-core/src/flat_routes.rs b/host/obc-host-core/src/flat_routes.rs index c76885cd5..965f109f5 100644 --- a/host/obc-host-core/src/flat_routes.rs +++ b/host/obc-host-core/src/flat_routes.rs @@ -18,6 +18,7 @@ pub struct FlatRouteStore { nav_id: Option, unaccepted: u64, internal_routes: u64, + temporary_routes: u64, /// The built trip day, as a catalog bit: at most one is set. built_day: u64, } @@ -38,6 +39,7 @@ impl FlatRouteStore { nav_id: None, unaccepted: 0, internal_routes: 0, + temporary_routes: 0, built_day: 0, }; for meta in repo.owner.entries()? { @@ -103,6 +105,10 @@ impl FlatRouteStore { }; if i < 64 { self.internal_routes &= !(1 << i); + self.temporary_routes &= !(1 << i); + if obc_formats::obcr::disposable_navigation(flags) { + self.temporary_routes |= 1 << i; + } self.built_day &= !(1 << i); if candidate || built || flags & FLAG_TEMPORARY != 0 { self.internal_routes |= 1 << i; @@ -152,6 +158,9 @@ impl RouteRepository for FlatRouteStore { fn internal_routes(&self) -> u64 { self.internal_routes } + fn temporary_routes(&self) -> u64 { + self.temporary_routes + } fn unaccepted_routes(&self) -> u64 { self.unaccepted } @@ -179,6 +188,7 @@ impl RouteRepository for FlatRouteStore { let mut revisions = Vec::new(); let mut unaccepted = 0u64; let mut internal_routes = 0u64; + let mut temporary_routes = 0u64; let mut built_day = 0u64; for entry in store.entries().filter(|entry| entry.kind == ObjectKind::Route && entry.flags.is_route_head()) { if ids.len() == obc_app::MAX_ROUTES { @@ -188,6 +198,9 @@ impl RouteRepository for FlatRouteStore { .with_source(entry.id, Some(entry.revision), |source| RouteSummary::read_with_flags(source)) .map_err(|_| obc_app::metadata::MetadataError::WriteFailed)? .map_err(|_| obc_app::metadata::MetadataError::WriteFailed)?; + if obc_formats::obcr::disposable_navigation(flags) { + temporary_routes |= 1 << ids.len(); + } let candidate = flags & obc_formats::obcr::FLAG_ASSISTANT_CANDIDATE != 0; if candidate || flags & (obc_formats::obcr::FLAG_BUILT_DAY | obc_formats::obcr::FLAG_TEMPORARY) != 0 { internal_routes |= 1 << ids.len(); @@ -210,6 +223,7 @@ impl RouteRepository for FlatRouteStore { self.revisions = revisions; self.unaccepted = unaccepted; self.internal_routes = internal_routes; + self.temporary_routes = temporary_routes; self.built_day = built_day; Ok(Some(start)) } @@ -247,7 +261,9 @@ impl RouteRepository for FlatRouteStore { self.ids.remove(i); self.catalog.remove(i); if i < 64 { - for mask in [&mut self.unaccepted, &mut self.internal_routes] { + for mask in + [&mut self.unaccepted, &mut self.internal_routes, &mut self.temporary_routes, &mut self.built_day] + { *mask = (*mask & ((1u64 << i) - 1)) | if i < 63 { (*mask >> (i + 1)) << i } else { 0 }; } } @@ -344,7 +360,7 @@ impl RouteRepository for FlatRouteStore { } } - fn retract_reviews(&mut self, ids: &[CatalogObjectId]) -> Result<(), CatalogError> { + fn retract_generated_routes(&mut self, ids: &[CatalogObjectId]) -> Result<(), CatalogError> { let heads: Vec<_> = ids .iter() .filter_map(|id| self.ids.iter().position(|candidate| candidate == id)) diff --git a/host/obc-host-core/src/flat_routes/tests.rs b/host/obc-host-core/src/flat_routes/tests.rs index 5eea0321f..481d0244e 100644 --- a/host/obc-host-core/src/flat_routes/tests.rs +++ b/host/obc-host-core/src/flat_routes/tests.rs @@ -187,6 +187,7 @@ fn temporary_navigation_stays_readable_but_hidden_after_catalog_reload() { for pass in 0..3 { let index = routes.ids().iter().position(|&id| id == publication.id).unwrap(); assert_eq!(routes.internal_routes(), 1 << index); + assert_eq!(routes.temporary_routes(), 1 << index); assert_eq!(routes.unaccepted_routes(), 0, "a temporary ride is not an abandoned review"); assert_eq!(bytes(&routes.source(publication.id).unwrap()), temporary); assert_eq!(bytes(&routes.source(original).unwrap()), ROUTE); @@ -197,3 +198,29 @@ fn temporary_navigation_stays_readable_but_hidden_after_catalog_reload() { } } } + +#[test] +fn completed_temporary_routes_release_catalog_capacity_without_removing_the_live_route() { + let mut routes = FlatRouteStore::from_bytes(&vec![ROUTE; obc_app::MAX_ROUTES - 2]).unwrap(); + let saved = routes.ids().to_vec(); + let mut temporary = ROUTE.to_vec(); + temporary[5] |= obc_formats::obcr::FLAG_TEMPORARY; + let mut app = obc_app::App::new_idle(obc_app::AppState::new(0, 0, 1.0)); + app.offer_assistant_checkpoint(routes.store_scope().unwrap().store, None); + for _ in 0..obc_app::MAX_ROUTES + 1 { + let publication = routes.publish_nav_route(&temporary).unwrap(); + crate::dispatch::feed_routes(&mut app, &routes, &mut crate::trace::NoTrace); + let index = routes.ids().iter().position(|&id| id == publication.id).unwrap(); + app.activate_route(index); + let retired: Vec<_> = app.orphan_routes().collect(); + assert!(!retired.contains(&publication.id)); + assert!(retired.iter().all(|id| !saved.contains(id))); + routes.retract_generated_routes(&retired).unwrap(); + assert_eq!(routes.ids().len(), saved.len() + 1); + assert_eq!(bytes(&routes.source(publication.id).unwrap()), temporary); + } + crate::dispatch::feed_routes(&mut app, &routes, &mut crate::trace::NoTrace); + app.activate_route(usize::MAX); + routes.retract_generated_routes(&app.orphan_routes().collect::>()).unwrap(); + assert_eq!(routes.ids(), saved); +} diff --git a/host/obc-host-core/src/repo.rs b/host/obc-host-core/src/repo.rs index 3eabd7707..654031ff6 100644 --- a/host/obc-host-core/src/repo.rs +++ b/host/obc-host-core/src/repo.rs @@ -79,6 +79,9 @@ pub trait RouteRepository { fn internal_routes(&self) -> u64 { 0 } + fn temporary_routes(&self) -> u64 { + 0 + } fn unaccepted_routes(&self) -> u64 { 0 } @@ -123,8 +126,8 @@ pub trait RouteRepository { fn retract_nav_route(&mut self, _publication: RoutePublication) -> Result<(), CatalogError> { Err(CatalogError::Unsupported) } - /// Remove as many of these unaccepted candidates as one commit carries. - fn retract_reviews(&mut self, _ids: &[CatalogObjectId]) -> Result<(), CatalogError> { + /// Remove unused generated routes, at most one commit per call. + fn retract_generated_routes(&mut self, _ids: &[CatalogObjectId]) -> Result<(), CatalogError> { Err(CatalogError::Unsupported) } /// Make the active route match `want`, (re)reading its bytes only on a change. **Returns whether diff --git a/host/obc-host-core/tests/board_detour_support/visit_tests.rs b/host/obc-host-core/tests/board_detour_support/visit_tests.rs index 5be3638d0..350dafbe8 100644 --- a/host/obc-host-core/tests/board_detour_support/visit_tests.rs +++ b/host/obc-host-core/tests/board_detour_support/visit_tests.rs @@ -96,8 +96,8 @@ impl VisitHarness { }), } } - CatalogEffect::RemoveOrphanReviews { token } => { - let heads = flat_store::route_heads(self.h.store, self.h.app.orphan_reviews()); + CatalogEffect::RemoveOrphanRoutes { token } => { + let heads = flat_store::route_heads(self.h.store, self.h.app.orphan_routes()); if !heads.is_empty() { let ticket = self .h @@ -107,7 +107,7 @@ impl VisitHarness { self.h.writer.complete(); assert!(self.h.writer.try_result(ticket, self.h.reply).unwrap().is_ok()); } - CatalogOutcome::OrphanReviewsRemoved { token } + CatalogOutcome::OrphanRoutesRemoved { token } } _ => panic!("unexpected Find catalog effect"), }; diff --git a/host/obc-host-core/tests/device_core_conformance.rs b/host/obc-host-core/tests/device_core_conformance.rs index c82c00c2d..505189ea7 100644 --- a/host/obc-host-core/tests/device_core_conformance.rs +++ b/host/obc-host-core/tests/device_core_conformance.rs @@ -393,10 +393,10 @@ impl CoreHarness { CatalogEffect::RemoveObject { kind, .. } => Some(kind), CatalogEffect::ReadCatalog { .. } | CatalogEffect::CleanupRoute { .. } - | CatalogEffect::RemoveOrphanReviews { .. } => None, + | CatalogEffect::RemoveOrphanRoutes { .. } => None, }; match effect { - CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanReviews { .. } => { + CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => { panic!("unexpected cleanup") } CatalogEffect::ReadCatalog { token } => { @@ -890,7 +890,7 @@ impl CoreHarness { for _ in 0..4 { let effect = self.next_catalog_effect(); match effect { - CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanReviews { .. } => { + CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => { panic!("unexpected cleanup") } CatalogEffect::RemoveObject { .. } => return effect, From fec1ae792ed3f52de444aeaa5b2cf4f0d9fcf584 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:13:23 +0200 Subject: [PATCH 07/13] docs: specify temporary direction cleanup --- specs/OBCR_Spec.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specs/OBCR_Spec.md b/specs/OBCR_Spec.md index d52faa0c6..b0a9e018f 100644 --- a/specs/OBCR_Spec.md +++ b/specs/OBCR_Spec.md @@ -101,6 +101,8 @@ place, with the same object ID and the next revision. A temporary navigation route contains generated directions, including connections and detours. Saved-route lists do not show it. The device can store it to follow or resume the ride. +The device removes temporary directions when no active route or recovery checkpoint needs them. +Assistant candidates and built trip days use their own retention rules. ### 1.2 Bike type and estimated time From 2f094d1ffd86b7256296c3df93b113847c5ad143 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:13:23 +0200 Subject: [PATCH 08/13] test: refresh route-start snapshots after UI merge --- firmware/ui-frames.toml | 12 ++++++------ firmware/ui-snapshots.sha256 | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index e6dffa55f..b7bf09534 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1057,7 +1057,7 @@ args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "30"] name = "map-effort" map = "{grimsel}" env = "routes" -script = "p*4 f d p" +script = "{start_unguided}" expect = "Map" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "330", "--sensors", "demo"] @@ -1182,7 +1182,7 @@ args = ["--gpx", "{gpx}", "--at", "30", "--sensors", "demo"] name = "statistics-effort-graphs" map = "{grimsel}" env = "routes" -script = "p*4 f d p b" +script = "{start_unguided} b" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "330", "--sensors", "demo", "--stat-fields", "hr-graph,power-graph,kj,cadence"] @@ -1191,7 +1191,7 @@ args = ["--gpx", "{gpx}", "--at", "330", "--sensors", "demo", "--stat-fields", " name = "statistics-effort-graphs" map = "{grimsel}" env = "routes" -script = "p*4 f d p b" +script = "{start_unguided} b" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "330", "--sensors", "demo", "--stat-fields", "hr-graph,power-graph,kj,cadence"] langs = ["de", "fr", "es"] @@ -2400,7 +2400,7 @@ args = ["--gpx", "{gpx}", "--at", "30"] name = "dark-map-effort" map = "{grimsel}" env = "routes" -script = "{theme_dark} p*4 f d p" +script = "{theme_dark} {start_unguided}" expect = "Map" args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "330", "--sensors", "demo"] @@ -2408,7 +2408,7 @@ args = ["--clock", "2025-06-29T14:40", "--gpx", "{gpx}", "--at", "330", "--senso name = "dark-statistics-sensors" map = "{grimsel}" env = "routes" -script = "{theme_dark} p*4 f d p b" +script = "{theme_dark} {start_unguided} b" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "30", "--sensors", "demo"] @@ -2416,7 +2416,7 @@ args = ["--gpx", "{gpx}", "--at", "30", "--sensors", "demo"] name = "dark-statistics-effort-graphs" map = "{grimsel}" env = "routes" -script = "{theme_dark} p*4 f d p b" +script = "{theme_dark} {start_unguided} b" expect = "Statistics" args = ["--gpx", "{gpx}", "--at", "330", "--sensors", "demo", "--stat-fields", "hr-graph,power-graph,kj,cadence"] diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index 52cf89ad4..f6d1fdf5e 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -49,8 +49,8 @@ e1d0e716bb82fb849b40d0dea76942909541a7992e30b21284f632d82c10f98f dark-rides.png cf8e3dc58047ac22b1657a578e4ee634b4f171add3e186748739737092dc1688 dark-routeoverview-delete.png 25242213ebee61bca9f85a76485e782cb279ca09bf779bd46f2ec68739acef86 dark-routeoverview.png bc56224dfc18b2016bca6e5c0fb303b582639d831cb97a5d9da915578c431b9e dark-settings.png -9a47cc36e284b4ac96603611939a0d08d02d105a0ec000ee458d8ccbdd6bc29a dark-statistics-effort-graphs.png -e38c47fb6eb0a7bc7416a11361a0eac88808337baa37b704d20e42aa3228d826 dark-statistics-sensors.png +76bc3291e1da37a18984ad0cca371aa42b6663d218806ffaa3960fdbaf7005f4 dark-statistics-effort-graphs.png +d3358111f120fc55c5b02af3f5551381985898502fc4be6bde189e973068eb12 dark-statistics-sensors.png 16a1864c9296ad5116fa731cfc5dc87538ea72d44b3a2855d1b66790d78b6286 dark-statistics.png 0695494495b4e202126dc2de004f8f52382e6f6c03818a67d61762d3e6749eb4 dark-up-ahead.png 080eb58b97a545641ece697febcf8c41ad5faa2a8226214e0433a74b7d66a038 dark-warning.png @@ -304,10 +304,10 @@ defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-nor bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png aef19ed12372f568f146e1e06235d6b64c8720d5d7726d0ca5d13ffdaa11ac0d start-away.png 134131ba24b3957240757b8d3d78f6d268b333cee651afb77b80396401bca3c4 statistics-de.png -f4979e2e454e6e41257ed12145cc8959900ccf89c3fd0e438d51bf9b31d3c484 statistics-effort-graphs-de.png -ea49ff868440a4cbcd0466bc09704e6d57f88e251c34d12bd8eae13bac0339b1 statistics-effort-graphs-es.png -41bdde43e38c0256781690ae01872ea01fe0caa2e2b35e2c985a3d69535ab485 statistics-effort-graphs-fr.png -519f3ae300238f3df758c03e3a8614e1b7ed4c79230ac16eee1cd81fa4f91e72 statistics-effort-graphs.png +edc5d77caeac927b38ea534f1c00f898d6aa55b642d146448d46415a702a84b5 statistics-effort-graphs-de.png +9805f03becfb3007efdd62988e782d60de2b92c1abb316d3d21612d74739e705 statistics-effort-graphs-es.png +446875160612f8b93078498e61a032c05473791de172587d5600de5334cd305f statistics-effort-graphs-fr.png +4985b87c4484f020bc15cc3d2f8deb4bf4a50bbcb260ea7554affb7e30370106 statistics-effort-graphs.png 82056bf09cbc35f971af4667f6eeca660751bd6973b861d76ff6533c95a404d2 statistics-es.png c238326a4e35550116b83eabf8aabb64c475dc4dbd20d28a69d77a02bc1d79aa statistics-eta-flat.png b98d757e6e5f5acaa9aa1a0c4959c89e30e15f7ac75267606913921db5d5dad7 statistics-eta.png @@ -315,7 +315,7 @@ b98d757e6e5f5acaa9aa1a0c4959c89e30e15f7ac75267606913921db5d5dad7 statistics-eta 5fb3897a85ca5a71c150792f9e76f313f31d429626639bcd50aab0cf71a5693b statistics-pan-zoomed.png fbec9d366ed503d37cf0216ba48686f78c25bf99fe0a29136772566a17c010bf statistics-pan.png 7f26aadeba326670827eced49b831f48a4d2afc6be8e98e26ca7b9717f86f96b statistics-routeless.png -204e003333d181adae4465bcb7d9c1bbaa3b88ad07bfee81e4132551caf325a3 statistics-sensors.png +0a92fe69416d05a1ba97e73e1b9283be08b0bdbf0ed17025d9efd3527fd21a5b statistics-sensors.png 8c7bc2254494ba734583a4fb13b2f7d8a2a314ee0f3e814224e6b30e0520e3c1 statistics-trip-none.png d486be4581fca0b856eb22a7d23b2f6191f9b0be4dc06c226aa1ebea414b9f71 statistics-trip.png 99790c3c1cb2e7bb4a1c7838e84df8e96b976c6580f7f25638da1b462806e742 statistics-zoom.png From c50e81fc187a8b8bc163b80b112758cacbcc74c3 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:15:28 +0200 Subject: [PATCH 09/13] fix: retain temporary routes until ride save completes --- firmware/obc-app/src/navigator/review.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/firmware/obc-app/src/navigator/review.rs b/firmware/obc-app/src/navigator/review.rs index cc77d6ddb..6429b8934 100644 --- a/firmware/obc-app/src/navigator/review.rs +++ b/firmware/obc-app/src/navigator/review.rs @@ -729,15 +729,16 @@ impl crate::App { && self.navigator.review.change.is_none() && !self.assistant_needs_recovery(); let checkpoint = self.assistant_checkpoint(); + let active = self.active_route_index().and_then(|i| self.route_ids().get(i).copied()); + let ending = self.navigator.ride_end.and_then(|end| self.route_ids().get(end.route).copied()); let held = [ checkpoint.map(|c| c.route.object), checkpoint.and_then(|c| c.original.map(|o| o.object)), - self.active_route_index().and_then(|i| self.route_ids().get(i).copied()), + active, + ending, self.navigator .lead_in() - .filter(|lead| { - self.active_route_index().and_then(|i| self.route_ids().get(i).copied()) == Some(lead.splice) - }) + .filter(|lead| Some(lead.splice) == active || Some(lead.splice) == ending) .map(|lead| lead.route), ]; self.route_ids() @@ -1599,6 +1600,11 @@ mod tests { assert_eq!(app.orphan_routes().count(), 0); app.navigator.review.checkpoint = None; assert_eq!(app.orphan_routes().collect::>(), [4, 5]); + app.navigator.note_ride_end(); + app.activate_route(usize::MAX); + assert_eq!(app.orphan_routes().collect::>(), [4, 5], "a pending ride save keeps its sources"); + app.navigator.take_ride_end(); + assert_eq!(app.orphan_routes().count(), 4, "save completion releases the finished route"); app.activate_route(0); assert_eq!(app.orphan_routes().collect::>(), [5, 6, 7], "a stale lead-in holds nothing"); app.cleanup_orphan_routes(); From f1430de614e5bd16c52691ae13589f6e0f976122 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:20:16 +0200 Subject: [PATCH 10/13] fix: render missing elevation with supported dashes --- firmware/obc-app/src/screen/statistics.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/firmware/obc-app/src/screen/statistics.rs b/firmware/obc-app/src/screen/statistics.rs index e6347bf60..1d7c28efc 100644 --- a/firmware/obc-app/src/screen/statistics.rs +++ b/firmware/obc-app/src/screen/statistics.rs @@ -19,7 +19,7 @@ use crate::Msg; use super::vocab::band::ElevationBand; use super::vocab::chrome::title_frame; -use super::vocab::fmt::write_distance_coarse; +use super::vocab::fmt::{dashes, write_distance_coarse}; use super::vocab::tiles::{category_tile, graph_tile, tile, waypoint_panel, zone_tile}; use super::{palette, ClimbScreen, Ctx, MapScreen, Render, Screen, ScreenTick, Transition}; @@ -251,7 +251,7 @@ impl StatisticsScreen { if let Some(grade) = stat_fields::grade_at(profile, total, cursor_frac) { let _ = write!(readout, "{}{}%", rx.t(Msg::StatsGrade), grade); } else { - let _ = readout.push_str("—"); + let _ = readout.push_str(&dashes()); } } title_frame(cv, w, h, rx.t(Msg::StatsTitle), &readout); @@ -278,7 +278,7 @@ impl StatisticsScreen { if cursor_band.0 <= cursor_band.1 { let _ = write!(ele_s, "{} {}", units.elev(cur_ele as f32) as i32, units.elev_label()); } else { - let _ = ele_s.push_str("—"); + let _ = ele_s.push_str(&dashes()); } let peak_x = band.frac_to_x(profile.peak_frac()); let near_peak = (chart_x..chart_x + chart_w).contains(&peak_x) && (cursor_x - peak_x).abs() < PEAK_NEAR_PX; From bad3a5a6225e6a226bd70e0d902f683e141f238f Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:27:00 +0200 Subject: [PATCH 11/13] test: account for temporary flag in captured route digests --- firmware/obc-route/tests/nav_fixtures.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/obc-route/tests/nav_fixtures.rs b/firmware/obc-route/tests/nav_fixtures.rs index f674e7940..482a1257b 100644 --- a/firmware/obc-route/tests/nav_fixtures.rs +++ b/firmware/obc-route/tests/nav_fixtures.rs @@ -136,4 +136,4 @@ fn the_registered_grimsel_fixture_routes_byte_identically_on_every_profile() { } const GRIMSEL_ROUTE_DIGESTS: [u64; 4] = - [0x5639_c196_df1b_155e, 0xbfba_332e_7b41_62f9, 0x2ec7_9079_bccc_5063, 0x2bd9_961b_436c_d426]; + [0xc6d5_83fc_9f79_2ebe, 0x92d0_478b_d94e_49d9, 0x2aff_ca27_d99b_8983, 0xaa76_be78_6ee8_3fc6]; From 8745116632c9fffa467999539580434ef33a1cfe Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:29:39 +0200 Subject: [PATCH 12/13] chore: record navigation state allocation from CI --- firmware/tools/resource_baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index 40005f8db..fdf778540 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -25,7 +25,7 @@ "compile_time_allocations": { "framebuffer": 76800, "row_diff": 1284, - "app": 60984, + "app": 61000, "map_cache": 37084, "map_tables": 7528, "route_cache": 9260, @@ -65,7 +65,7 @@ "changes": "MapTables +3088 B for the second authored style table, backdrop and marker. All RAM, stack and flash ceilings are unchanged.", "verification": "The same CI build measured map_tables at 7528 B. Board RAM, stack and flash guards passed before the allocation inventory check. No base rebuild." }, - "_app_note": "60,984 B on head d8755449b in CI run 35893128960, embedded resource-report build. The persisted theme takes 8 B of it, and the effort state (zones, the 10 s power window and the 5-minute history) with the recorder energy total takes 168 B. RAM, stack and flash ceilings stay unchanged.", + "_app_note": "61,000 B on head f1430de61 in CI run 35901763046, embedded resource-report build. Route tracking and temporary-route ownership add 16 B. RAM, stack and flash ceilings stay unchanged.", "_flat_catalog_uploads_note": "The entry measures `size_of::>>()`. It was 737 B while the loss bit was a separate 1-byte `AtomicBool` beside a 736 B queue container. The bit now lives inside `UploadFacts`, so it pays the queue's alignment instead of packing beside it: 740 B, measured on the head `--features resource-report` link. The 3 B buys the eviction that raises the bit and the drain that clears it one owner, which a host test can reach.", "full_frame_sized_writable_count": 2, "task_frame_measured": 1100, From 00ad8e8d2abfc318513e31ecf192b7a87ff73fe6 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 23 Sep 2026 20:33:19 +0200 Subject: [PATCH 13/13] chore: record merged navigation allocation --- firmware/tools/resource_baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index 1f0f467fa..e0736540c 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": 57904, + "app": 57920, "map_cache": 37084, "map_tables": 7528, "route_cache": 9260, @@ -66,7 +66,7 @@ "changes": "MapTables +3088 B for the second authored style table, backdrop and marker. All RAM, stack and flash ceilings are unchanged.", "verification": "The same CI build measured map_tables at 7528 B. Board RAM, stack and flash guards passed before the allocation inventory check. No base rebuild." }, - "_app_note": "57,904 B on codex/firmware-ram-efficiency, measured with the resource-report head build on rustc 1.96.0, aarch64-apple-darwin. Removing the matcher decode buffer and profile option saves 3,080 B against the recorded 60,984 B allocation. The resident band uses its separate CI shipping measurement. Stack and flash ceilings stay unchanged.", + "_app_note": "57,920 B on head c3b83990c, measured with the resource-report build on rustc 1.96.0, aarch64-apple-darwin. Route tracking and temporary-route ownership add 16 B. RAM, stack and flash ceilings stay unchanged.", "_flat_catalog_uploads_note": "The entry measures `size_of::>>()`. It was 737 B while the loss bit was a separate 1-byte `AtomicBool` beside a 736 B queue container. The bit now lives inside `UploadFacts`, so it pays the queue's alignment instead of packing beside it: 740 B, measured on the head `--features resource-report` link. The 3 B buys the eviction that raises the bit and the drain that clears it one owner, which a host test can reach.", "full_frame_sized_writable_count": 2, "task_frame_measured": 1100,