From 07f53d6787d57ba03ce3deae84aad89c484e892a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:49:39 +0000 Subject: [PATCH 01/37] =?UTF-8?q?docs:=20name=20the=20Forget=20phone=20pat?= =?UTF-8?q?h=20Settings=20=E2=96=B8=20Connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device lists Forget phone on the Connections page. The spec and the board README called it Settings ▸ Bluetooth. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-fw-nrf54l/README.md | 2 +- specs/obc-ble-interface-spec.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/firmware/obc-fw-nrf54l/README.md b/firmware/obc-fw-nrf54l/README.md index 171d3a45a..58db95543 100644 --- a/firmware/obc-fw-nrf54l/README.md +++ b/firmware/obc-fw-nrf54l/README.md @@ -192,7 +192,7 @@ The radio's contract is canonical in - **Config and the bond live in the RRAM SETTINGS carve** (`src/settings.rs`), which survives a power cycle and a reflash, because it sits above the app image. There is one bond slot; while it is occupied the device rejects every new pairing. The hold-guarded **Forget phone** in - Settings ▸ Bluetooth is the only device-side clear. + Settings ▸ Connections is the only device-side clear. - **DIS Firmware Revision is the installed OBCU container's version**, falling back to the bare git short hash `build.rs` emits. A probe-flashed board reports a hash, not a version, so no host offers it an auto-update. diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index 302a50577..2850ca483 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -414,7 +414,7 @@ Days count from 0 in the object; the rider sees Day 1 for day 0. - **Single-peer policy — reject-when-bonded**: exactly one bond slot, and **while it is occupied the device refuses every new pairing attempt** — from a stranger and from a peer claiming the bonded identity alike. A stored bond can only be cleared by the rider: the hold-guarded **Forget phone** - action in Settings ▸ Bluetooth zeroes the bond slot, removes the peer from the host's bond table + action in Settings ▸ Connections zeroes the bond slot, removes the peer from the host's bond table and resolving list, and drops the connection if that peer is connected. After Forget, the next pairing is open again. Physical possession guards the *clear* step, so a stranger who can see the screen cannot silently evict the rider's phone by pairing. From 2ef2b4616999958e275d8dcbc7d9c43b88126fd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:26:47 +0000 Subject: [PATCH 02/37] onboarding: boot a new device into setup with the Hello screen A factory-fresh device now opens first-use setup instead of Home. The settings blob persists the setup step (VERSION 26, one byte at offset 125), so setup resumes at its step after a power loss. The declared default is Done: an older blob and every Settings::default() caller stay set up. Settings::FACTORY puts setup at its first step. The board, the sim window and the iOS host boot it when the store holds no valid blob, and the BLE config write starts from it on a blank store. App::set_settings opens the step's screen over Home, unless a recovered-ride decision is already on top. A factory reset writes FACTORY, and its next press goes to setup. The first step is Hello: the brand signpost on Home's contour backdrop, "OpenBikeComputer", a greeting in the four UI languages, and one amber OK. Press ends setup on Home. Setup refuses the escape and the drawers. The sim gets --fresh, and the sweep gets the setup-hello frame. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- apps/obc-ios-host/src/lib.rs | 4 +- apps/obc-ios-host/src/tests.rs | 9 +- apps/obc-sim/src/gui.rs | 6 +- apps/obc-sim/src/main.rs | 16 ++- firmware/obc-app/i18n/de.toml | 4 + firmware/obc-app/i18n/en.toml | 4 + firmware/obc-app/i18n/es.toml | 4 + firmware/obc-app/i18n/fr.toml | 4 + firmware/obc-app/src/app.rs | 34 +++++- firmware/obc-app/src/harness/copy_fit.rs | 1 + firmware/obc-app/src/screen/home.rs | 2 +- firmware/obc-app/src/screen/mod.rs | 5 + firmware/obc-app/src/screen/settings/reset.rs | 12 +- firmware/obc-app/src/screen/setup.rs | 115 ++++++++++++++++++ firmware/obc-app/src/settings.rs | 66 +++++++++- firmware/obc-fw-nrf54l/src/link_control.rs | 3 +- firmware/obc-fw-nrf54l/src/ride.rs | 6 +- firmware/ui-frames.toml | 7 ++ firmware/ui-snapshots.sha256 | 1 + 19 files changed, 274 insertions(+), 29 deletions(-) create mode 100644 firmware/obc-app/src/screen/setup.rs diff --git a/apps/obc-ios-host/src/lib.rs b/apps/obc-ios-host/src/lib.rs index 1d1f2617c..644407508 100644 --- a/apps/obc-ios-host/src/lib.rs +++ b/apps/obc-ios-host/src/lib.rs @@ -160,7 +160,7 @@ impl Host { let recorder = FlatRideRecorder::new(owner.clone()).map_err(|error| format!("ride recovery: {error:?}"))?; let tracks = TrackStore::new(recorder, owner, exports); let mut settings_store = FileSettingsStore::open(settings); - let boot_settings = settings_store.load().unwrap_or_default(); + let boot_settings = settings_store.load().unwrap_or(Settings::FACTORY); // Absent or unreadable terrain is not fatal; routes stay flat. let elevation: Box = match obc_host_core::terrain::FlatElevation::open(&map) { Ok(Some(terrain)) => terrain, @@ -178,7 +178,7 @@ impl Host { app.set_map_nav_graph(map.tables().has_nav_graph()); app.set_routes_with_ids(routes.catalog(), routes.ids()); app.set_rides(rides.catalog(), rides.trip_names()); - // The phone runs the settings a rider runs: whatever was saved, or the defaults. + // The phone runs the settings a rider runs: whatever was saved, or a factory-fresh device. app.set_settings(boot_settings); app.set_sound_available(true); tracks.offer_recovery(&mut app); diff --git a/apps/obc-ios-host/src/tests.rs b/apps/obc-ios-host/src/tests.rs index 591c55136..4fdd2a9f7 100644 --- a/apps/obc-ios-host/src/tests.rs +++ b/apps/obc-ios-host/src/tests.rs @@ -15,8 +15,13 @@ fn card(tag: &str) -> (PathBuf, PathBuf) { (card, directory) } +/// Open the card as a device that finished first-use setup. fn open(card: &Path, directory: &Path) -> Box { - Host::open(card, &directory.join("settings"), &directory.join("exports")).expect("the card opens") + let settings = directory.join("settings"); + if !settings.exists() { + FileSettingsStore::open(&settings).save(&Settings::default()).expect("the settings file is written"); + } + Host::open(card, &settings, &directory.join("exports")).expect("the card opens") } /// A fix in the middle of the demo map's extract. @@ -200,7 +205,7 @@ fn the_c_surface_opens_a_card_it_imported_and_takes_a_null_host_as_nothing() { let length = (obc_ios_frame_width() * obc_ios_frame_height() * 4) as usize; let pixels = std::slice::from_raw_parts(frame, length); assert!(pixels.iter().skip(3).step_by(4).all(|&alpha| alpha == 0xFF), "opaque alpha for the CGImage"); - assert_eq!(CStr::from_ptr(obc_ios_screen(host)).to_str().unwrap(), "Map"); + assert_eq!(CStr::from_ptr(obc_ios_screen(host)).to_str().unwrap(), "Hello", "a new phone starts setup"); // A stationary fix with no stamp: NaN and zero are how C spells an absent value. obc_ios_push_fix(host, GRIMSEL.lat, GRIMSEL.lon, f32::NAN, f32::NAN, 0); diff --git a/apps/obc-sim/src/gui.rs b/apps/obc-sim/src/gui.rs index 136ba0d96..cc46e84a6 100644 --- a/apps/obc-sim/src/gui.rs +++ b/apps/obc-sim/src/gui.rs @@ -370,10 +370,10 @@ impl SimGui { app.tick(obc_ports::RideClock(0), obc_ports::Sensors::new(&mut loc), None); } tracks.offer_recovery(&mut app); - // Seed the live settings from the persisted store, falling back to defaults on a first run - // or an unreadable file, as the device's boot path does. + // Seed the live settings from the persisted store, falling back to a factory-fresh device + // on a first run or an unreadable file, as the device's boot path does. let mut settings_store = FileSettingsStore::open(args.settings_path()); - let boot_settings = settings_store.load().unwrap_or_default(); + let boot_settings = settings_store.load().unwrap_or(obc_app::Settings::FACTORY); app.set_settings(boot_settings); args.stamp_initial_clock(&mut app); app.set_map_nav_graph(map_tables.has_nav_graph()); diff --git a/apps/obc-sim/src/main.rs b/apps/obc-sim/src/main.rs index c53887237..2c39effb4 100644 --- a/apps/obc-sim/src/main.rs +++ b/apps/obc-sim/src/main.rs @@ -132,6 +132,8 @@ struct Args { /// Headless `--png` only: render from the device's real power-on state (Home / Idle, /// no route) instead of straight from the map. boot: bool, + /// Headless `--png` only: boot as a factory-fresh device, which opens first-use setup. + fresh: bool, /// One-time route/trip fixture import directory; defaults to `routes/`. routes_dir: Option, /// A progress record for the first imported trip: `(day, metres, last finished day)`. @@ -206,6 +208,7 @@ impl Default for Args { no_sound: false, expect_screen: None, boot: false, + fresh: false, routes_dir: None, trip_progress: None, card: None, @@ -547,6 +550,7 @@ fn parse_args_from(args: impl IntoIterator) -> Result a.script_after = Some(it.next().ok_or("--script-after needs a token string")?), "--expect-screen" => a.expect_screen = Some(it.next().ok_or("--expect-screen needs a screen name")?), "--boot" => a.boot = true, + "--fresh" => a.fresh = true, "--card" => a.card = Some(it.next().ok_or("--card needs a path")?), "--create-card" => a.create_card = Some(it.next().ok_or("--create-card needs a path")?), "--routes-dir" => a.routes_dir = Some(it.next().ok_or("--routes-dir needs a path")?), @@ -1021,6 +1025,7 @@ Device state: --route-cleanup Show the storage cleanup dialog --no-card Simulate an absent storage card --boot Start headless rendering at the power-on Home screen + --fresh Boot a factory-fresh device, which opens first-use setup --battery PCT Initial battery charge, 0..=100 --clock DATE Trusted UTC time, YYYY-MM-DDTHH:MM (local offset defaults to 0) --clock-after-script DATE Set trusted UTC time after the script (same format and offset) @@ -1265,8 +1270,14 @@ fn main() { } // Explicit headless settings are applied before the script. Without an explicit clock the // device's boot time stays untrusted. - if args.clock.is_some() || args.lang.is_some() || args.stat_fields.is_some() || args.sensors.is_some() { - let mut settings = obc_app::settings::Settings::default(); + if args.fresh + || args.clock.is_some() + || args.lang.is_some() + || args.stat_fields.is_some() + || args.sensors.is_some() + { + let mut settings = + if args.fresh { obc_app::settings::Settings::FACTORY } else { obc_app::settings::Settings::default() }; if let Some(clock) = args.clock { settings.clock = clock; } @@ -1986,6 +1997,7 @@ mod cli_tests { "--script-after", "--expect-screen", "--boot", + "--fresh", "--routes-dir", "--tracks-dir", "--import", diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index c459dfb5e..eae9b248a 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -527,6 +527,10 @@ tomorrow = "MORGEN" est = "gesch." ok = "OK" +# --- Ersteinrichtung --- +[setup] +ok = "OK" + # --- Wiederhergestellte Fahrt beim Start --- [ride_recovery] title = "FAHRT GERETTET" diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 162af4716..2befa37d7 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -561,6 +561,10 @@ tomorrow = "TOMORROW" est = "est." ok = "OK" +# --- First-use setup --- +[setup] +ok = "OK" + # --- Boot-time recovered ride --- [ride_recovery] title = "RIDE RECOVERED" diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 57ba054ca..c28ecf521 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -526,6 +526,10 @@ tomorrow = "MAÑANA" est = "est." ok = "OK" +# --- Configuración inicial --- +[setup] +ok = "OK" + # --- Salida recuperada al arrancar --- [ride_recovery] title = "RECUPERACIÓN" diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 4f99569eb..7e0277434 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -526,6 +526,10 @@ tomorrow = "DEMAIN" est = "est." ok = "OK" +# --- Première configuration --- +[setup] +ok = "OK" + # --- Sortie récupérée au démarrage --- [ride_recovery] title = "RÉCUPÉRATION" diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 55b4bb948..cc2a48196 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -2347,6 +2347,13 @@ impl App { // The value came from the store, so it is already persisted: reset the handshake to // Clean. A pending edit is discarded, because seeding is a boot operation, not an edit. self.settings_ops.note_seeded(); + // An unfinished setup opens at its step. A recovered-ride decision already on top stays + // there, and setup opens on the next boot instead. + let decision_open = self.ui.stack.last().is_some_and(|top| top.caps().blocks_escape); + if let Some(step) = screen::setup::screen(self.settings.setup).filter(|_| !decision_open) { + screen::apply(&mut self.ui.stack, screen::Transition::Root(step)); + self.ui.map_dirty = true; + } } /// Merge the BLE-owned fields (units and device name) of a phone Config write into the live @@ -4516,15 +4523,16 @@ mod tests { } assert!(!settings_dirty(&mut app), "{name}: the save is held while the screen is on top"); - // Back out to the Home root, closing any open field on the way. + // Back out of the subtree, closing any open field on the way. It ends on the Home root, + // or in first-use setup after a reset. for _ in 0..MAX_DEPTH_BACKOUT { - if app.ui.stack.len() == 1 { + if !app.ui.top_is_settings() { break; } assert!(!settings_dirty(&mut app), "{name}: still inside the settings subtree — save held"); app.apply_gesture(Gesture::Back); } - assert_eq!(app.ui.stack.len(), 1, "{name}: backed out to the Home root"); + assert!(!app.ui.top_is_settings(), "{name}: backed out of the settings subtree"); assert!(settings_dirty(&mut app), "{name}: leaving the settings subtree flushes the pending save"); assert!(!settings_dirty(&mut app), "{name}: the flag drains — exactly one save"); } @@ -6073,6 +6081,26 @@ mod tests { assert_eq!(drain_persist(&mut app), None, "a seeded boot value is already persisted"); } + /// A factory-fresh boot opens setup, and nothing but Hello's press leaves it. The press ends + /// setup on Home and owes the save that makes the next boot skip it. + #[test] + fn a_factory_boot_runs_setup_until_hello_is_pressed() { + use crate::settings::SetupStep; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings::FACTORY); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::Hello(_)])); + + for g in [Gesture::Back, Gesture::BackHold, Gesture::Hold] { + app.apply_gesture(g); + } + assert!(matches!(app.ui.stack.last(), Some(Screen::Hello(_))), "setup cannot be escaped"); + + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)])); + assert_eq!(app.settings().setup, SetupStep::Done); + assert_eq!(drain_persist(&mut app), Some(1), "the finished setup is saved"); + } + /// A cancel posted while the plan request is still undrained annihilates it: the rider's net /// intent is "no plan", so the host cannot execute a dismissed plan. #[test] diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 5022c881d..c5298675d 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -116,6 +116,7 @@ fn seeds(language: Language) -> Vec { Screen::RouteUpdated(RouteUpdatedScreen::new(0, 0)), Screen::TripReceived(TripReceivedScreen::new(7, 0)), Screen::Passkey(PasskeyScreen::new(123_456)), + Screen::Hello(HelloScreen), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Receiving { received_kib: 1_024, total_kib: 65_536 })), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Installed)), Screen::Settings(SettingsPage::hub()), diff --git a/firmware/obc-app/src/screen/home.rs b/firmware/obc-app/src/screen/home.rs index 3422e35b7..244024032 100644 --- a/firmware/obc-app/src/screen/home.rs +++ b/firmware/obc-app/src/screen/home.rs @@ -197,7 +197,7 @@ fn jitter(seed: u32, i: usize) -> (f32, f32) { /// Trace [`field`] into `LEVELS` iso-lines by marching squares. One pass over the grid keeping two /// rolling sample rows (no full-grid buffer). `seed` jitters the bump centres for this open. -fn contours(cv: &mut impl Surface, w: i32, h: i32, seed: u32) { +pub(super) fn contours(cv: &mut impl Surface, w: i32, h: i32, seed: u32) { let step = w as f32 / COLS as f32; let rows = ((h as f32 / step + 0.5) as usize).max(1); // rounded, to keep the cells square let stepy = h as f32 / rows as f32; diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index 7d69583e4..aab4fc695 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -58,6 +58,7 @@ mod route_overview; mod route_received; mod route_swap; pub(crate) mod settings; +pub(crate) mod setup; mod start_away; mod statistics; mod trip_delete; @@ -116,6 +117,7 @@ pub use settings::{ AboutScreen, AddFieldScreen, LanguageScreen, ResetScreen, SensorScanScreen, SensorsScreen, SettingsPage, StatFieldsScreen, }; +pub use setup::HelloScreen; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; pub use trip_delete::TripDeleteScreen; @@ -903,6 +905,9 @@ screens! { /// The one-shot boot decision for a durable recording recovered after reset. Back cannot /// dismiss it; Continue preserves restored totals, while Discard is hold-guarded. RideRecovery(RideRecoveryScreen) => Caps::modal().blocks_escape(), + /// First-use setup's greeting in the four UI languages. Setup refuses the escape and the + /// drawers: it ends only when its last step is done. + Hello(HelloScreen) => Caps::modal().blocking(), /// The card after Finish on a trip day: today's ledger, then tomorrow's day or the trip's /// totals. OK returns Home. DayDone(DayDoneScreen) => Caps::modal(), diff --git a/firmware/obc-app/src/screen/settings/reset.rs b/firmware/obc-app/src/screen/settings/reset.rs index 3ed5cdf4b..3a9281b60 100644 --- a/firmware/obc-app/src/screen/settings/reset.rs +++ b/firmware/obc-app/src/screen/settings/reset.rs @@ -35,9 +35,9 @@ impl ResetScreen { pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { if self.done { - // Any key clears back to Home. The device would reboot here. + // Any key starts setup again. The device would reboot here. return match g { - Gesture::Press | Gesture::Back => Transition::Home, + Gesture::Press | Gesture::Back => crate::screen::setup::go_to(cx.settings.setup), _ => Transition::None, }; } @@ -48,7 +48,7 @@ impl ResetScreen { } // `apply_gesture` sees the change and flags the host to persist the cleared settings. Gesture::Hold if self.armed => { - *cx.settings = Settings::default(); + *cx.settings = Settings::FACTORY; self.done = true; Transition::None } @@ -114,7 +114,7 @@ mod tests { } #[test] - fn arm_then_hold_resets_to_defaults() { + fn arm_then_hold_resets_to_factory_and_starts_setup() { let mut s = Settings { units: Units::Imperial, power_saver: true, fix_interval_s: 30, ..Settings::default() }; let before = s; let mut scr = ResetScreen::new(); @@ -127,9 +127,9 @@ mod tests { assert!(scr.armed && !scr.done); let t = run(&mut scr, &mut s, Gesture::Hold); assert!(matches!(t, Transition::None), "stays to show the done message"); - assert_eq!(s, Settings::default(), "settings were cleared to factory defaults"); + assert_eq!(s, Settings::FACTORY, "settings were cleared to factory defaults"); assert!(scr.done); - assert!(matches!(run(&mut scr, &mut s, Gesture::Press), Transition::Home)); + assert!(matches!(run(&mut scr, &mut s, Gesture::Press), Transition::Root(crate::Screen::Hello(_)))); } #[test] diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs new file mode 100644 index 000000000..70520dcff --- /dev/null +++ b/firmware/obc-app/src/screen/setup.rs @@ -0,0 +1,115 @@ +//! First-use setup. A new device, and one after a factory reset, boots into it instead of Home. +//! The step is persisted in [`Settings::setup`](crate::Settings), so setup resumes at its step +//! after a power loss. Each step is a screen: [`screen`] maps a step to it, and the screen calls +//! [`finish`] when its step is done. A new step is a [`SetupStep`] variant, its place in +//! [`SetupStep::next`], its screen, and its arm in [`screen`]. +//! +//! The first step is Hello: a greeting in the four UI languages, because the language is not chosen +//! yet. + +use embedded_graphics::prelude::Point; +use obc_render::{ + rect, + text::{text_width, Font, TextAlign}, + Surface, +}; + +use crate::input::Gesture; +use crate::settings::SetupStep; +use crate::Msg; + +use super::home::contours; +use super::{palette, Ctx, Render, Screen, Transition}; + +/// The screen of setup step `step`, or `None` once setup is done. +pub(crate) fn screen(step: SetupStep) -> Option { + match step { + SetupStep::Hello => Some(Screen::Hello(HelloScreen)), + SetupStep::Done => None, + } +} + +/// Go to setup step `step`: its screen over Home, or Home once setup is done. +pub(crate) fn go_to(step: SetupStep) -> Transition { + screen(step).map_or(Transition::Home, Transition::Root) +} + +/// End setup step `step`: persist the next step and go to it. +fn finish(step: SetupStep, cx: &mut Ctx) -> Transition { + cx.settings.setup = step.next(); + go_to(cx.settings.setup) +} + +/// The greeting, two languages a line. The language is not chosen yet, so it is not a catalog +/// string. +const GREETING: [(&str, &str); 2] = [("Hello", "Hallo"), ("Bonjour", "Hola")]; + +/// The contour backdrop's seed: Home's massif, drifted so that the signpost stands on its summit. +const BACKDROP_SEED: u32 = 20; + +#[derive(Debug)] +pub struct HelloScreen; + +impl HelloScreen { + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + match g { + Gesture::Press => finish(SetupStep::Hello, cx), + _ => Transition::None, + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + use palette::*; + let (w, h) = (rx.w, rx.h); + cv.clear(HUD); + contours(cv, w, h, BACKDROP_SEED); + + signpost(cv, w / 2, 14); + cv.text("OpenBikeComputer", Point::new(w / 2, 112), Font::Body, TextAlign::Center, PARCHMENT); + + // Each pair is centred as one group round a drawn dot. A ` · ` in text takes three Display + // cells, and "Bonjour · Hola" then fills the panel edge to edge. + let gap = 26; + for (i, (a, b)) in GREETING.into_iter().enumerate() { + let y = 158 + i as i32 * (Font::Display.line_height() as i32 + 4); + let (wa, wb) = (text_width(a, Font::Display) as i32, text_width(b, Font::Display) as i32); + let left = (w - wa - gap - wb) / 2; + cv.text(a, Point::new(left, y), Font::Display, TextAlign::Left, PARCHMENT); + cv.disc(Point::new(left + wa + gap / 2, y + Font::Display.cap_mid() as i32), 3, AMBER); + cv.text(b, Point::new(left + wa + gap, y), Font::Display, TextAlign::Left, PARCHMENT); + } + + let label = rx.t(Msg::SetupOk); + let (bw, bh) = (136, 44); + let by = h - 20 - bh; + cv.round(rect(w / 2 - bw / 2, by, bw, bh), 8, AMBER); + cv.text_vcentered(label, w / 2, (by, bh), Font::Body, TextAlign::Center, ON_ACCENT); + } +} + +/// The brand signpost from `assets/brand/signpost.svg`, its 100-unit view box drawn at one pixel +/// a unit with the top-left at `(cx - 50, top)`: an olive post under two amber arrow boards, each +/// tilted 4° about its own centre line. +fn signpost(cv: &mut impl Surface, cx: i32, top: i32) { + use palette::*; + let (x0, y0) = (cx - 50, top); + cv.fill(rect(x0 + 44, y0 + 10, 12, 81), SUBTEXT); + // `sin` and `cos` of 4°, because `core` has no trigonometry. + const S: f32 = 0.069_756; + const C: f32 = 0.997_564; + let board = |pts: &[(f32, f32)], pivot: (f32, f32), sin: f32| -> [Point; 5] { + core::array::from_fn(|i| { + let (dx, dy) = (pts[i].0 - pivot.0, pts[i].1 - pivot.1); + let (x, y) = (pivot.0 + dx * C - dy * sin, pivot.1 + dx * sin + dy * C); + Point::new(x0 + (x + 0.5) as i32, y0 + (y + 0.5) as i32) + }) + }; + // Each board is a five-point arrow: the tip, then the body's corners in order round the outline. + let left = board(&[(13.0, 29.0), (27.0, 18.0), (78.0, 18.0), (78.0, 40.0), (27.0, 40.0)], (50.0, 29.0), S); + let right = board(&[(88.0, 63.0), (74.0, 74.0), (22.0, 74.0), (22.0, 52.0), (74.0, 52.0)], (50.0, 63.0), -S); + for [tip, a, b, c, d] in [left, right] { + cv.triangle(tip, a, d, AMBER); + cv.triangle(a, b, c, AMBER); + cv.triangle(a, c, d, AMBER); + } +} diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index 83b1a9e1d..e3d3c273c 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -3,7 +3,7 @@ //! [`Settings`] is `Copy + PartialEq`, so a single comparison detects a rider edit and flags a //! save. The codec ([`encode`]/[`decode`]) is a versioned, CRC-checked, fixed-length blob shared by //! the sim file store and the firmware RRAM store; a blank or corrupt read falls back to -//! [`Settings::default`]. +//! [`Settings::FACTORY`]. use crate::i18n::{t, Msg}; use crate::screen::BRIGHTNESS_MAX; @@ -203,6 +203,36 @@ impl Units { } } +/// How far first-use setup has come. It is persisted, so setup resumes at its step after a power +/// loss. `Done` is the default, so a blob written before setup existed decodes as a device that is +/// set up; a blank store boots [`Settings::FACTORY`] instead. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum SetupStep { + #[default] + Done = 0, + Hello = 1, +} + +impl SetupStep { + /// The step after this one. Setup ends at `Done`. + pub const fn next(self) -> Self { + match self { + SetupStep::Hello | SetupStep::Done => SetupStep::Done, + } + } + + /// An unknown byte reads as `Done`, so a corrupt blob never traps the rider in setup. + const fn from_byte(b: u8) -> Self { + match b { + 1 => SetupStep::Hello, + _ => SetupStep::Done, + } + } +} + +crate::settings_table::setting_enum_codec!(SetupStep); + setting_enum! { /// The colour theme used by every device screen. pub enum Theme { @@ -482,6 +512,7 @@ settings_table! { sound: SoundLevel = SoundLevel::Loud, since(25); /// A click on every button gesture. key_tones: bool = false, since(25); + setup: SetupStep = SetupStep::Done, since(26); } pub const DEFAULT; @@ -501,7 +532,7 @@ settings_table! { /// declared is read, and the fields appended after it take their declared defaults, so a /// firmware update that appends a setting keeps the rider's values. /// - /// `None` — the host then falls back to [`Settings::default`] — if the version is outside + /// `None` — the host then falls back to [`Settings::FACTORY`] — if the version is outside /// [`MIN_SUPPORTED`]`..=`[`VERSION`], if the blob is shorter than that version's /// [`encoded_len`], or if the CRC over that version's payload fails. Bytes past its encoded /// length are ignored, which is what makes the board's fixed-`SLOT_LEN` read work after a bump. @@ -511,9 +542,13 @@ settings_table! { /// The in-memory footprint, pinned. [`Settings`] is copied whole into the live `App`, the board's /// Config cache and the `.rodata` [`DEFAULT`](Settings::DEFAULT) image, so a field that widens the /// struct widens every one of those. -const _: () = assert!(core::mem::size_of::() == 124, "Settings grew — was that deliberate?"); +const _: () = assert!(core::mem::size_of::() == 126, "Settings grew — was that deliberate?"); impl Settings { + /// A factory-fresh device: the defaults, with setup at its first step. Hosts boot it when the + /// store holds no valid blob, and a factory reset writes it. + pub const FACTORY: Settings = Settings { setup: SetupStep::Hello, ..Settings::DEFAULT }; + pub(crate) fn find_hours_filter(&self) -> obc_reader::reader::places::HoursFilter { use obc_reader::reader::places::HoursFilter; if self.find_hide_closed { @@ -535,9 +570,9 @@ impl Settings { } } -pub const VERSION: u8 = 25; +pub const VERSION: u8 = 26; -/// The oldest layout [`decode`] accepts. An older blob resets to defaults. +/// The oldest layout [`decode`] accepts. An older blob resets to [`Settings::FACTORY`]. pub const MIN_SUPPORTED: u8 = 19; /// The encoded length of a `payload`-byte payload: the CRC-covered bytes plus a 2-byte CRC, rounded @@ -585,7 +620,8 @@ const _: () = { assert!(off::ftp_w == 121); assert!(off::sound == 123); assert!(off::key_tones == 124); - assert!(PAYLOAD_LEN == 125, "the CRC moved"); + assert!(off::setup == 125); + assert!(PAYLOAD_LEN == 126, "the CRC moved"); assert!(ENCODED_LEN == 128, "the blob is no longer 8 RRAM lines"); }; @@ -613,6 +649,7 @@ mod tests { assert_eq!(d.find_results, FindResults::default()); assert_eq!(d.theme, Theme::default()); assert_eq!(d.sound, SoundLevel::default()); + assert_eq!(d.setup, SetupStep::default()); // And the whole const is its type's `Default` — the property the field list guards. assert_eq!(d, Settings::default()); @@ -660,6 +697,7 @@ mod tests { ftp_w: 250, sound: SoundLevel::Quiet, key_tones: true, + setup: SetupStep::Hello, brightness: 1, } @@ -683,6 +721,7 @@ mod tests { let mut expected = every_field_set(); (expected.theme, expected.max_hr, expected.ftp_w) = (Theme::Light, 0, 0); (expected.sound, expected.key_tones) = (SoundLevel::Loud, false); + expected.setup = SetupStep::Done; let mut old = encode(&expected); old[0] = 22; @@ -697,6 +736,7 @@ mod tests { fn version_24_blob_keeps_existing_values_and_defaults_the_sound_rows() { let mut expected = every_field_set(); (expected.sound, expected.key_tones) = (SoundLevel::Loud, false); + expected.setup = SetupStep::Done; let mut old = encode(&expected); old[0] = 24; @@ -707,6 +747,20 @@ mod tests { assert_eq!(decode(&old), Some(expected)); } + /// A device updated from before setup existed is set up already. + #[test] + fn version_25_blob_keeps_existing_values_and_reads_setup_as_done() { + let expected = Settings { setup: SetupStep::Done, ..every_field_set() }; + + let mut old = encode(&expected); + old[0] = 25; + let old_payload_len = off::setup; + let crc = crate::crc16::crc16(&old[..old_payload_len]); + old[old_payload_len..old_payload_len + 2].copy_from_slice(&crc.to_le_bytes()); + + assert_eq!(decode(&old), Some(expected)); + } + /// One table over every declared field: the fixture moves every row off its default, that /// value round-trips through the codec, and `adopt_ble_fields` pulls across exactly the /// `ble_writable` rows. The per-field walk is generated from the table itself, so a new row is diff --git a/firmware/obc-fw-nrf54l/src/link_control.rs b/firmware/obc-fw-nrf54l/src/link_control.rs index 546eab707..92186e404 100644 --- a/firmware/obc-fw-nrf54l/src/link_control.rs +++ b/firmware/obc-fw-nrf54l/src/link_control.rs @@ -144,7 +144,8 @@ impl LinkControl { /// `App` copy before its next save and the phone's write cannot be clobbered. pub fn apply_config(&mut self, shared: &mut SharedSettings, name: &str, units: u8) { // Start from the current persisted truth so an on-device edit racing this write isn't dropped. - self.settings = shared.settings.load().unwrap_or_default(); + // A blank store is a factory-fresh device, so a phone write cannot skip first-use setup. + self.settings = shared.settings.load().unwrap_or(obc_app::Settings::FACTORY); self.settings.device_name = DeviceName::from_str_lossy(name); self.settings.units = if units == 1 { obc_app::Units::Imperial } else { obc_app::Units::Metric }; diff --git a/firmware/obc-fw-nrf54l/src/ride.rs b/firmware/obc-fw-nrf54l/src/ride.rs index 58eec2969..49d969df6 100644 --- a/firmware/obc-fw-nrf54l/src/ride.rs +++ b/firmware/obc-fw-nrf54l/src/ride.rs @@ -766,11 +766,11 @@ pub(crate) async fn run_app( // pulsing the manager's work edge every pass. let mut sensor_scan_rearm_ms: u32 = 0; - // Seed the app from the persistent RRAM store at boot; a blank or corrupt page decodes to the - // defaults. One brief lock, released at once. + // Seed the app from the persistent RRAM store at boot; a blank or corrupt page boots a + // factory-fresh device. One brief lock, released at once. app.set_settings({ let mut store = shared.lock().await; - store.settings.load().unwrap_or_default() + store.settings.load().unwrap_or(obc_app::Settings::FACTORY) }); // The brightness in that seed reaches the panel here, before the first frame is drawn. The // per-pass apply at the end of the loop would otherwise leave the light at the level diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 3c6caecc4..f3be45137 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1767,6 +1767,13 @@ map = "{grimsel}" expect = "Passkey" args = ["--ble", "passkey=42"] +# First-use setup: a factory-fresh device boots into the Hello greeting. +[[frame]] +name = "setup-hello" +map = "{grimsel}" +expect = "Hello" +args = ["--fresh"] + # Route-upload popups (#451), all three variants. `--inject upload[-replace]=ID` raises the upload # event after the script, exactly as the control panel's inject buttons do. specs/vectors holds # two routes: id 0 = route-plain, id 1 = route-waypoints (filename order). diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index 80a657296..7c0f91284 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -306,6 +306,7 @@ e45d66a5ad980acf5ec2891d0ae89b517466fae60b1caf61b814f988be56a65d settings-es.pn 49286bcd4069b29c6094ebeadb8b35dc3cf2b3c1fc6dc70da98ab5cd7be810ac settings-fr.png 460cddf4695830c5cdececc764d507b4ee2a13fb3660dd66ca6ef20dedb84f37 settings-ride-waypoints.png c1a9b35839d97d9a06da9885587cc2f8b74dff61561d93b5fac55088148f94e5 settings.png +fa7017db43b71eb7675c622608c8ac99e93e86a9e26ec39567091b1aa0d5450b setup-hello.png d44c085c61a1672c4487b36a4eac869cd5f345a784e744a91d7eb89f546c57fa sound.png defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-noroute.png bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png From e5ae72f6f7befa516a55ab0fb8954190aaca22c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:26:47 +0000 Subject: [PATCH 03/37] docs: first-use setup refuses the global escape Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- docs/content/software/ui.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/content/software/ui.md b/docs/content/software/ui.md index 9a1ec5d73..d1f4a7599 100644 --- a/docs/content/software/ui.md +++ b/docs/content/software/ui.md @@ -248,8 +248,9 @@ a hold. A hold fires at the threshold, not on release, so the rider feels the mo `BackHold` is the global escape. The application answers it above the screen stack, so it never reaches a screen: it closes any drawer and opens the main menu from anywhere, and returns to a menu -already on the stack instead of adding a second one. Three states refuse it, because the rider must -finish them first: a blocking card, the recovered-ride card, and a confirmed shutdown. +already on the stack instead of adding a second one. Four states refuse it, because the rider must +finish them first: first-use setup, a blocking card, the recovered-ride card, and a confirmed +shutdown. ### Chords From 462db4b704732827b4ea264ebc8bb3d1d3d48f9c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:42:10 +0000 Subject: [PATCH 04/37] onboarding: add the language step after Hello Setup now runs Hello, then Language, then Home. The language step is the Settings Language pick list, wrapped: Select commits the language under the cursor and ends the step, and Back returns to Hello. A step moves only the cursor. The tick stays on the committed language. SetupStep gets Language (byte 2) and an ORDER list. next() and prev() come from that list, so a new step is one entry there. The blob layout and VERSION stay. Back persists the step before, so a power loss resumes on the step the rider sees. Each titled setup page has a controls hint at its foot: the Up and Down glyphs with "Choose", and a small amber OK like the one on Hello. setup::screen takes the settings, so a step opens on the value the rider already has. The sweep gets setup-language in four languages and setup-language-cursor. The copy-fit gate seeds the new screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 1 + firmware/obc-app/i18n/en.toml | 2 + firmware/obc-app/i18n/es.toml | 1 + firmware/obc-app/i18n/fr.toml | 1 + firmware/obc-app/src/app.rs | 26 ++++-- firmware/obc-app/src/harness/copy_fit.rs | 1 + firmware/obc-app/src/screen/mod.rs | 5 +- .../obc-app/src/screen/settings/language.rs | 7 +- firmware/obc-app/src/screen/settings/reset.rs | 2 +- firmware/obc-app/src/screen/setup.rs | 92 ++++++++++++++++--- firmware/obc-app/src/settings.rs | 27 ++++-- firmware/ui-frames.toml | 25 +++++ firmware/ui-snapshots.sha256 | 5 + 13 files changed, 164 insertions(+), 31 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index eae9b248a..ec715b52a 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -530,6 +530,7 @@ ok = "OK" # --- Ersteinrichtung --- [setup] ok = "OK" +choose = "Wählen" # --- Wiederhergestellte Fahrt beim Start --- [ride_recovery] diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 2befa37d7..9c72fcb4e 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -564,6 +564,8 @@ ok = "OK" # --- First-use setup --- [setup] ok = "OK" +# The controls hint beside the Up and Down glyphs. +choose = "Choose" # --- Boot-time recovered ride --- [ride_recovery] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index c28ecf521..6f9b40ea4 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -529,6 +529,7 @@ ok = "OK" # --- Configuración inicial --- [setup] ok = "OK" +choose = "Elegir" # --- Salida recuperada al arrancar --- [ride_recovery] diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 7e0277434..7f3cc7dc4 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -529,6 +529,7 @@ ok = "OK" # --- Première configuration --- [setup] ok = "OK" +choose = "Choisir" # --- Sortie récupérée au démarrage --- [ride_recovery] diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index cc2a48196..f12a51cb4 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -2350,7 +2350,7 @@ impl App { // An unfinished setup opens at its step. A recovered-ride decision already on top stays // there, and setup opens on the next boot instead. let decision_open = self.ui.stack.last().is_some_and(|top| top.caps().blocks_escape); - if let Some(step) = screen::setup::screen(self.settings.setup).filter(|_| !decision_open) { + if let Some(step) = screen::setup::screen(&self.settings).filter(|_| !decision_open) { screen::apply(&mut self.ui.stack, screen::Transition::Root(step)); self.ui.map_dirty = true; } @@ -6081,11 +6081,12 @@ mod tests { assert_eq!(drain_persist(&mut app), None, "a seeded boot value is already persisted"); } - /// A factory-fresh boot opens setup, and nothing but Hello's press leaves it. The press ends - /// setup on Home and owes the save that makes the next boot skip it. + /// A factory-fresh boot opens setup, and only its own pages leave it. Hello's press opens the + /// language step, and Back there returns to Hello. Select on a language ends setup on Home in + /// that language, and owes the save that makes the next boot skip setup. #[test] - fn a_factory_boot_runs_setup_until_hello_is_pressed() { - use crate::settings::SetupStep; + fn a_factory_boot_runs_setup_through_hello_and_the_language_step() { + use crate::settings::{Language, SetupStep}; let mut app = App::new_idle(AppState::new(0, 0, 1.0)); app.set_settings(Settings::FACTORY); assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::Hello(_)])); @@ -6095,10 +6096,21 @@ mod tests { } assert!(matches!(app.ui.stack.last(), Some(Screen::Hello(_))), "setup cannot be escaped"); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupLanguage(_)])); + app.apply_gesture(Gesture::BackHold); + assert!(matches!(app.ui.stack.last(), Some(Screen::SetupLanguage(_))), "setup cannot be escaped"); + app.apply_gesture(Gesture::Back); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::Hello(_)]), "Back returns to Hello"); + assert_eq!(app.settings().setup, SetupStep::Hello, "a power loss resumes on the step shown"); + + app.apply_gesture(Gesture::Press); + app.apply_gesture(Gesture::Step(1)); + assert_eq!(app.settings().language, Language::En, "the cursor commits nothing"); app.apply_gesture(Gesture::Press); assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)])); - assert_eq!(app.settings().setup, SetupStep::Done); - assert_eq!(drain_persist(&mut app), Some(1), "the finished setup is saved"); + assert_eq!((app.settings().setup, app.settings().language), (SetupStep::Done, Language::De)); + assert!(drain_persist(&mut app).is_some(), "the finished setup is saved"); } /// A cancel posted while the plan request is still undrained annihilates it: the rider's net diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index c5298675d..039519a33 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -117,6 +117,7 @@ fn seeds(language: Language) -> Vec { Screen::TripReceived(TripReceivedScreen::new(7, 0)), Screen::Passkey(PasskeyScreen::new(123_456)), Screen::Hello(HelloScreen), + Screen::SetupLanguage(SetupLanguageScreen::new(language)), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Receiving { received_kib: 1_024, total_kib: 65_536 })), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Installed)), Screen::Settings(SettingsPage::hub()), diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index aab4fc695..a6ed2c865 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -117,7 +117,7 @@ pub use settings::{ AboutScreen, AddFieldScreen, LanguageScreen, ResetScreen, SensorScanScreen, SensorsScreen, SettingsPage, StatFieldsScreen, }; -pub use setup::HelloScreen; +pub use setup::{HelloScreen, SetupLanguageScreen}; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; pub use trip_delete::TripDeleteScreen; @@ -908,6 +908,9 @@ screens! { /// First-use setup's greeting in the four UI languages. Setup refuses the escape and the /// drawers: it ends only when its last step is done. Hello(HelloScreen) => Caps::modal().blocking(), + /// Setup's language step: the Language pick list, where Select ends the step and Back returns + /// to Hello. + SetupLanguage(SetupLanguageScreen) => Caps::modal().blocking(), /// The card after Finish on a trip day: today's ledger, then tomorrow's day or the trip's /// totals. OK returns Home. DayDone(DayDoneScreen) => Caps::modal(), diff --git a/firmware/obc-app/src/screen/settings/language.rs b/firmware/obc-app/src/screen/settings/language.rs index b7bf2b14c..478841825 100644 --- a/firmware/obc-app/src/screen/settings/language.rs +++ b/firmware/obc-app/src/screen/settings/language.rs @@ -26,6 +26,11 @@ impl LanguageScreen { LanguageScreen { selected: current as usize } } + /// The language under the cursor. + pub(crate) fn cursor(&self) -> Language { + Language::ALL[self.selected] + } + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { match g { Gesture::Step(n) => { @@ -33,7 +38,7 @@ impl LanguageScreen { Transition::None } Gesture::Press => { - cx.settings.language = Language::ALL[self.selected]; + cx.settings.language = self.cursor(); Transition::Pop } Gesture::Back => Transition::Pop, diff --git a/firmware/obc-app/src/screen/settings/reset.rs b/firmware/obc-app/src/screen/settings/reset.rs index 3a9281b60..8cbb6042f 100644 --- a/firmware/obc-app/src/screen/settings/reset.rs +++ b/firmware/obc-app/src/screen/settings/reset.rs @@ -37,7 +37,7 @@ impl ResetScreen { if self.done { // Any key starts setup again. The device would reboot here. return match g { - Gesture::Press | Gesture::Back => crate::screen::setup::go_to(cx.settings.setup), + Gesture::Press | Gesture::Back => crate::screen::setup::go_to(cx.settings), _ => Transition::None, }; } diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index 70520dcff..8cc2c439b 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -1,11 +1,11 @@ //! First-use setup. A new device, and one after a factory reset, boots into it instead of Home. //! The step is persisted in [`Settings::setup`](crate::Settings), so setup resumes at its step -//! after a power loss. Each step is a screen: [`screen`] maps a step to it, and the screen calls -//! [`finish`] when its step is done. A new step is a [`SetupStep`] variant, its place in -//! [`SetupStep::next`], its screen, and its arm in [`screen`]. +//! after a power loss. Each step is a screen: [`screen`] maps a step to it. Select ends the step +//! through [`finish`], and Back returns to the step before through [`back`]. A new step is a +//! [`SetupStep`] variant, its place in `SetupStep::ORDER`, its screen, and its arm in [`screen`]. //! //! The first step is Hello: a greeting in the four UI languages, because the language is not chosen -//! yet. +//! yet. Every step after it is a titled page with the [`hint`] at its foot. use embedded_graphics::prelude::Point; use obc_render::{ @@ -15,29 +15,39 @@ use obc_render::{ }; use crate::input::Gesture; -use crate::settings::SetupStep; +use crate::settings::{Language, Settings, SetupStep}; use crate::Msg; use super::home::contours; +use super::settings::LanguageScreen; +use super::vocab::rows::ROW_X; use super::{palette, Ctx, Render, Screen, Transition}; -/// The screen of setup step `step`, or `None` once setup is done. -pub(crate) fn screen(step: SetupStep) -> Option { - match step { +/// The screen of the current setup step, or `None` once setup is done. +pub(crate) fn screen(s: &Settings) -> Option { + match s.setup { SetupStep::Hello => Some(Screen::Hello(HelloScreen)), + SetupStep::Language => Some(Screen::SetupLanguage(SetupLanguageScreen::new(s.language))), SetupStep::Done => None, } } -/// Go to setup step `step`: its screen over Home, or Home once setup is done. -pub(crate) fn go_to(step: SetupStep) -> Transition { - screen(step).map_or(Transition::Home, Transition::Root) +/// Go to the current setup step: its screen over Home, or Home once setup is done. +pub(crate) fn go_to(s: &Settings) -> Transition { + screen(s).map_or(Transition::Home, Transition::Root) } /// End setup step `step`: persist the next step and go to it. fn finish(step: SetupStep, cx: &mut Ctx) -> Transition { cx.settings.setup = step.next(); - go_to(cx.settings.setup) + go_to(cx.settings) +} + +/// Leave setup step `step` for the one before it. The persisted step follows, so a power loss +/// resumes on the step the rider sees. +fn back(step: SetupStep, cx: &mut Ctx) -> Transition { + cx.settings.setup = step.prev(); + go_to(cx.settings) } /// The greeting, two languages a line. The language is not chosen yet, so it is not a catalog @@ -113,3 +123,61 @@ fn signpost(cv: &mut impl Surface, cx: i32, top: i32) { cv.triangle(a, c, d, AMBER); } } + +/// The language step: the Settings pick list, where Select also ends the step. +#[derive(Debug)] +pub struct SetupLanguageScreen(LanguageScreen); + +impl SetupLanguageScreen { + pub fn new(current: Language) -> Self { + SetupLanguageScreen(LanguageScreen::new(current)) + } + + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + match g { + Gesture::Press => { + cx.settings.language = self.0.cursor(); + finish(SetupStep::Language, cx) + } + Gesture::Back => back(SetupStep::Language, cx), + _ => self.0.handle(g, cx), + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + self.0.draw(cv, rx); + hint(cv, rx.w, rx.h, rx.t(Msg::SetupChoose), rx.t(Msg::SetupOk)); + } +} + +/// The height of the [`hint`] band. +const HINT_H: i32 = 40; + +/// The controls hint at the foot of a setup page: Up and Down choose, Select confirms. The amber OK +/// is Hello's button in small, so it names the press the rider has already made. +fn hint(cv: &mut impl Surface, w: i32, h: i32, choose: &str, ok: &str) { + use palette::*; + let font = Font::Label; + let top = h - 8 - HINT_H; + cv.hline(ROW_X, top, w - 2 * ROW_X, RULE); + let span = (top, HINT_H); + let cy = top + HINT_H / 2; + + // Up and Down side by side, each `2k` wide. + let k = 5; + let arrows_w = 4 * k + 3; + let (choose_w, ok_w) = (text_width(choose, font) as i32, text_width(ok, font) as i32); + let pill_w = ok_w + 16; + let left = (w - (arrows_w + 8 + choose_w + 24 + pill_w)) / 2; + + let x = left; + cv.triangle(Point::new(x + k, cy - k), Point::new(x, cy + k), Point::new(x + 2 * k, cy + k), INK); + let x = left + arrows_w - 2 * k; + cv.triangle(Point::new(x, cy - k), Point::new(x + 2 * k, cy - k), Point::new(x + k, cy + k), INK); + let x = left + arrows_w + 8; + cv.text_vcentered(choose, x, span, font, TextAlign::Left, SUBTEXT); + + let px = x + choose_w + 24; + cv.round(rect(px, cy - 12, pill_w, 24), 6, AMBER); + cv.text_vcentered(ok, px + pill_w / 2, span, font, TextAlign::Center, ON_ACCENT); +} diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index e3d3c273c..c89d5459e 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -212,22 +212,31 @@ pub enum SetupStep { #[default] Done = 0, Hello = 1, + Language = 2, } impl SetupStep { + /// The steps in the order the rider meets them. The byte values are persisted, so a new step + /// takes a new value and its place here. + const ORDER: [SetupStep; 2] = [SetupStep::Hello, SetupStep::Language]; + + fn index(self) -> Option { + Self::ORDER.iter().position(|&s| s == self) + } + /// The step after this one. Setup ends at `Done`. - pub const fn next(self) -> Self { - match self { - SetupStep::Hello | SetupStep::Done => SetupStep::Done, - } + pub fn next(self) -> Self { + self.index().and_then(|i| Self::ORDER.get(i + 1)).copied().unwrap_or(SetupStep::Done) + } + + /// The step before this one. The first step has none, so it stays. + pub fn prev(self) -> Self { + self.index().and_then(|i| i.checked_sub(1)).map_or(self, |i| Self::ORDER[i]) } /// An unknown byte reads as `Done`, so a corrupt blob never traps the rider in setup. - const fn from_byte(b: u8) -> Self { - match b { - 1 => SetupStep::Hello, - _ => SetupStep::Done, - } + fn from_byte(b: u8) -> Self { + Self::ORDER.into_iter().find(|&s| s as u8 == b).unwrap_or(SetupStep::Done) } } diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index f3be45137..30f5d553c 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1774,6 +1774,31 @@ map = "{grimsel}" expect = "Hello" args = ["--fresh"] +# Setup's language step, reached from Hello: the Language pick list with the controls hint at its +# foot. It shows in the language the device has, so the hint is swept in all four. +[[frame]] +name = "setup-language" +map = "{grimsel}" +script = "p" +expect = "SetupLanguage" +args = ["--fresh"] + +[[frame]] +name = "setup-language" +map = "{grimsel}" +script = "p" +expect = "SetupLanguage" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +# The cursor moved to Deutsch: the tick stays on the committed language until Select. +[[frame]] +name = "setup-language-cursor" +map = "{grimsel}" +script = "p d" +expect = "SetupLanguage" +args = ["--fresh"] + # Route-upload popups (#451), all three variants. `--inject upload[-replace]=ID` raises the upload # event after the script, exactly as the control panel's inject buttons do. specs/vectors holds # two routes: id 0 = route-plain, id 1 = route-waypoints (filename order). diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index 7c0f91284..dcdcce7b9 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -307,6 +307,11 @@ e45d66a5ad980acf5ec2891d0ae89b517466fae60b1caf61b814f988be56a65d settings-es.pn 460cddf4695830c5cdececc764d507b4ee2a13fb3660dd66ca6ef20dedb84f37 settings-ride-waypoints.png c1a9b35839d97d9a06da9885587cc2f8b74dff61561d93b5fac55088148f94e5 settings.png fa7017db43b71eb7675c622608c8ac99e93e86a9e26ec39567091b1aa0d5450b setup-hello.png +b95d0853653b90a9e7d7c7df0f2881816e26992db60af4ab40a7c024622b7bcd setup-language-cursor.png +786e3e8cb639eb0d9bed5b6a15e3f32622ad3bd8989b4de82c71aad69e138495 setup-language-de.png +cd45930c9fd72a44d790a30b33f43c6d199fa964ff865b052bddad6a325e91d7 setup-language-es.png +15ada0d6dcd5efa14acdc340b0ac7737b2e4a79c6ddcfc519979252ae0727890 setup-language-fr.png +aed180639f39b22340377e67be745d7ba3c25cfb93039e2411d40c1c02fae3ad setup-language.png d44c085c61a1672c4487b36a4eac869cd5f345a784e744a91d7eb89f546c57fa sound.png defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-noroute.png bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png From a4e471cf3de9980a8f3212fc4e8ad256d55cb771 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:11:15 +0000 Subject: [PATCH 05/37] onboarding: add the units and theme steps after Language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup now runs Hello, Language, Units, Theme, then Home. SetupStep gets Units (byte 4) and Theme (byte 5) and their places in ORDER. The units step lists Metric and Imperial, each with its symbols (km · m, mi · ft) under the name. The theme step lists Light and Dark, each with a swatch of its page in the flag slot. Both steps show two ride tiles over the controls hint: a speed and a distance, in the units under the cursor on the units step. The tiles stay at one place, so the theme step recolours them in place. The theme step previews the theme under its cursor. App::theme names the theme a frame draws in: the cursor's theme while the theme step is on top, else the setting. The frame, the overlays, the map style and the render key read it, so the whole page redraws in the preview. A cursor move and Back save nothing. Select commits, as on every step. The Language list's row becomes rows::choice_row, and its tick rows::row_tick, so the three pick lists share one row. The sweep is unchanged outside the new frames. The sweep gets setup-units in four languages, setup-units-imperial, setup-theme in four languages and setup-theme-dark. The copy-fit gate seeds both new screens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 2 + firmware/obc-app/i18n/en.toml | 3 + firmware/obc-app/i18n/es.toml | 2 + firmware/obc-app/i18n/fr.toml | 2 + firmware/obc-app/src/app.rs | 58 +++++++-- firmware/obc-app/src/harness/copy_fit.rs | 2 + firmware/obc-app/src/render_key.rs | 2 +- firmware/obc-app/src/screen/mod.rs | 6 +- .../obc-app/src/screen/settings/language.rs | 39 ++---- firmware/obc-app/src/screen/setup.rs | 117 +++++++++++++++++- firmware/obc-app/src/screen/vocab/rows.rs | 29 +++++ firmware/obc-app/src/settings.rs | 4 +- firmware/ui-frames.toml | 48 +++++++ firmware/ui-snapshots.sha256 | 10 ++ 14 files changed, 281 insertions(+), 43 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index ec715b52a..f0eee8696 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -531,6 +531,8 @@ ok = "OK" [setup] ok = "OK" choose = "Wählen" +units = "EINHEITEN" +theme = "FARBSCHEMA" # --- Wiederhergestellte Fahrt beim Start --- [ride_recovery] diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 9c72fcb4e..511595a57 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -566,6 +566,9 @@ ok = "OK" ok = "OK" # The controls hint beside the Up and Down glyphs. choose = "Choose" +# The titles of the units and theme steps. +units = "UNITS" +theme = "THEME" # --- Boot-time recovered ride --- [ride_recovery] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 6f9b40ea4..4d48a45c2 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -530,6 +530,8 @@ ok = "OK" [setup] ok = "OK" choose = "Elegir" +units = "UNIDADES" +theme = "TEMA" # --- Salida recuperada al arrancar --- [ride_recovery] diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 7f3cc7dc4..8d5f0400f 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -530,6 +530,8 @@ ok = "OK" [setup] ok = "OK" choose = "Choisir" +units = "UNITÉS" +theme = "THÈME" # --- Sortie récupérée au démarrage --- [ride_recovery] diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index f12a51cb4..cbb095570 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -2372,6 +2372,15 @@ impl App { self.settings.theme = theme; } + /// The theme the frame draws in. Setup's theme step previews the theme under its cursor, and + /// the setting changes only when the rider commits it. + pub(crate) fn theme(&self) -> crate::settings::Theme { + match self.ui.stack.last() { + Some(Screen::SetupTheme(step)) => step.0, + _ => self.settings.theme, + } + } + pub fn settings(&self) -> &Settings { &self.settings } @@ -2996,7 +3005,8 @@ impl App { D: DrawTarget, F: Fn(u16) -> D::Color, { - let style_set = match self.settings.theme { + let theme = self.theme(); + let style_set = match theme { crate::settings::Theme::Light => MapStyleSet::Light, crate::settings::Theme::Dark => MapStyleSet::Dark, }; @@ -3182,7 +3192,6 @@ impl App { let covered = ui.base_frozen(); let recessed = covered && ui.stack.get(base).is_none_or(|s| s.caps().recess); let recess = core::cell::Cell::new(recessed); - let theme = settings.theme; let theme_policy_enabled = core::cell::Cell::new(true); let policy = |c: u16| { let themed = if theme_policy_enabled.get() { screen::palette::resolve(theme, c) } else { c }; @@ -3254,7 +3263,7 @@ impl App { D: DrawTarget, F: Fn(u16) -> D::Color, { - let themed = |color| color_fn(crate::screen::palette::resolve(self.settings.theme, color)); + let themed = |color| color_fn(crate::screen::palette::resolve(self.theme(), color)); self.ui.input.render_overlay(target, w, h, themed); self.render_planning_banner(target, w, h, color_fn); } @@ -3267,7 +3276,7 @@ impl App { { if let Some(message) = self.planning_banner() { let text = crate::i18n::t(message, self.settings.language); - let themed = |color| color_fn(crate::screen::palette::resolve(self.settings.theme, color)); + let themed = |color| color_fn(crate::screen::palette::resolve(self.theme(), color)); crate::screen::vocab::chrome::recalculating_banner( target, &themed, @@ -6082,8 +6091,8 @@ mod tests { } /// A factory-fresh boot opens setup, and only its own pages leave it. Hello's press opens the - /// language step, and Back there returns to Hello. Select on a language ends setup on Home in - /// that language, and owes the save that makes the next boot skip setup. + /// language step, and Back there returns to Hello. Select on a language commits it and opens + /// the next step. #[test] fn a_factory_boot_runs_setup_through_hello_and_the_language_step() { use crate::settings::{Language, SetupStep}; @@ -6108,9 +6117,42 @@ mod tests { app.apply_gesture(Gesture::Step(1)); assert_eq!(app.settings().language, Language::En, "the cursor commits nothing"); app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupUnits(_)])); + assert_eq!((app.settings().setup, app.settings().language), (SetupStep::Units, Language::De)); + } + + /// The units and theme steps commit on Select only. The theme step draws the frame in the + /// theme under its cursor, and that preview is never saved: a step leaves the setting alone, + /// and Back returns to the units step in the committed theme. Select on a theme ends setup. + #[test] + fn the_units_and_theme_steps_commit_on_select_and_the_theme_preview_saves_nothing() { + use crate::settings::{SetupStep, Theme, Units}; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings { setup: SetupStep::Units, ..Settings::FACTORY }); + let mut host = SettingsHost::default(); + + app.apply_gesture(Gesture::Step(1)); + assert_eq!(app.settings().units, Units::Metric, "the cursor commits nothing"); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupTheme(_)])); + assert_eq!((app.settings().setup, app.settings().units), (SetupStep::Theme, Units::Imperial)); + let revision = host.drain(&mut app).expect("the step is saved"); + host.ack(&mut app, revision); + + app.apply_gesture(Gesture::Step(1)); + assert_eq!((app.theme(), app.settings().theme), (Theme::Dark, Theme::Light), "the page previews Dark"); + assert_eq!(host.drain(&mut app), None, "a preview is not saved"); + app.apply_gesture(Gesture::Back); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupUnits(_)])); + assert_eq!((app.settings().setup, app.theme()), (SetupStep::Units, Theme::Light), "Back drops the preview"); + + app.apply_gesture(Gesture::Press); + app.apply_gesture(Gesture::Step(1)); + app.apply_gesture(Gesture::Press); assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)])); - assert_eq!((app.settings().setup, app.settings().language), (SetupStep::Done, Language::De)); - assert!(drain_persist(&mut app).is_some(), "the finished setup is saved"); + let s = app.settings(); + assert_eq!((s.setup, s.units, s.theme), (SetupStep::Done, Units::Imperial, Theme::Dark)); + assert!(host.drain(&mut app).is_some(), "the finished setup is saved"); } /// A cancel posted while the plan request is still undrained annihilates it: the rider's net diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 039519a33..a07be5c25 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -118,6 +118,8 @@ fn seeds(language: Language) -> Vec { Screen::Passkey(PasskeyScreen::new(123_456)), Screen::Hello(HelloScreen), Screen::SetupLanguage(SetupLanguageScreen::new(language)), + Screen::SetupUnits(SetupUnitsScreen(crate::settings::Units::Metric)), + Screen::SetupTheme(SetupThemeScreen(crate::settings::Theme::Light)), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Receiving { received_kib: 1_024, total_kib: 65_536 })), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Installed)), Screen::Settings(SettingsPage::hub()), diff --git a/firmware/obc-app/src/render_key.rs b/firmware/obc-app/src/render_key.rs index 544cc4f16..bbf182418 100644 --- a/firmware/obc-app/src/render_key.rs +++ b/firmware/obc-app/src/render_key.rs @@ -256,7 +256,7 @@ impl App { let _ = key.shape.push(screen.row()); } if self.ui.stack.iter().skip(base).any(|screen| !matches!(screen, Screen::Home(_))) { - key.theme = Some(self.settings().theme); + key.theme = Some(self.theme()); } if let Some(drawer) = self.drawer_key() { key.drawer = Some(drawer); diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index a6ed2c865..ab46baf3a 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -117,7 +117,7 @@ pub use settings::{ AboutScreen, AddFieldScreen, LanguageScreen, ResetScreen, SensorScanScreen, SensorsScreen, SettingsPage, StatFieldsScreen, }; -pub use setup::{HelloScreen, SetupLanguageScreen}; +pub use setup::{HelloScreen, SetupLanguageScreen, SetupThemeScreen, SetupUnitsScreen}; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; pub use trip_delete::TripDeleteScreen; @@ -911,6 +911,10 @@ screens! { /// Setup's language step: the Language pick list, where Select ends the step and Back returns /// to Hello. SetupLanguage(SetupLanguageScreen) => Caps::modal().blocking(), + /// Setup's units step: Metric or Imperial over a preview of the ride tiles. + SetupUnits(SetupUnitsScreen) => Caps::modal().blocking(), + /// Setup's theme step: Light or Dark. The frame draws in the theme under its cursor. + SetupTheme(SetupThemeScreen) => Caps::modal().blocking(), /// The card after Finish on a trip day: today's ledger, then tomorrow's day or the trip's /// totals. OK returns Home. DayDone(DayDoneScreen) => Caps::modal(), diff --git a/firmware/obc-app/src/screen/settings/language.rs b/firmware/obc-app/src/screen/settings/language.rs index 478841825..b4e21777f 100644 --- a/firmware/obc-app/src/screen/settings/language.rs +++ b/firmware/obc-app/src/screen/settings/language.rs @@ -1,17 +1,13 @@ //! The Language screen: a pick list of the four languages, each by its own name and flag, so it //! reads to a speaker who cannot read the current UI language. A press commits and returns. -use obc_render::{ - text::{Font, TextAlign}, - Surface, -}; +use obc_render::Surface; use crate::input::Gesture; use crate::screen::vocab::chrome::{title_frame, LIST_TOP}; -use crate::screen::vocab::flags::{draw_flag, FLAG_H}; -use crate::screen::vocab::rows::{row_cursor, row_rect, ROW_GAP, ROW_ONE}; -use crate::screen::vocab::sheet::committed_tick; -use crate::screen::{palette, Ctx, Render, Transition}; +use crate::screen::vocab::flags::draw_flag; +use crate::screen::vocab::rows::{choice_row, row_rect, ROW_GAP, ROW_ONE}; +use crate::screen::{Ctx, Render, Transition}; use crate::settings::Language; use crate::Msg; @@ -50,28 +46,11 @@ impl LanguageScreen { let (w, h) = (rx.w, rx.h); title_frame(cv, w, h, rx.t(Msg::LanguageTitle), ""); let committed = rx.settings.language; - for (i, lang) in Language::ALL.iter().enumerate() { - let y = LIST_TOP + i as i32 * (ROW_ONE + ROW_GAP); - let area = row_rect(y, w, ROW_ONE); - row_cursor(cv, area, i == self.selected, false); - let x = area.top_left.x + 10; - let cy = y + ROW_ONE / 2; - draw_flag(cv, x + 1, cy - FLAG_H / 2, *lang); - cv.text_vcentered( - lang.name(), - x + 30, - (y, ROW_ONE), - Font::Body, - TextAlign::Left, - if i == self.selected { palette::ON_ACCENT } else { palette::INK }, - ); - if *lang == committed { - let tx = area.top_left.x + area.size.width as i32 - 22; - // The committed tick of the drawer editor, at row scale. - for k in 0..2 { - committed_tick(cv, tx + k, cy - k, palette::WOOD); - } - } + for (i, &lang) in Language::ALL.iter().enumerate() { + let area = row_rect(LIST_TOP + i as i32 * (ROW_ONE + ROW_GAP), w, ROW_ONE); + choice_row(cv, area, lang.name(), i == self.selected, lang == committed, |cv, x, y| { + draw_flag(cv, x, y, lang) + }); } } } diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index 8cc2c439b..a51c3f158 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -7,6 +7,8 @@ //! The first step is Hello: a greeting in the four UI languages, because the language is not chosen //! yet. Every step after it is a titled page with the [`hint`] at its foot. +use core::fmt::Write as _; + use embedded_graphics::prelude::Point; use obc_render::{ rect, @@ -15,12 +17,15 @@ use obc_render::{ }; use crate::input::Gesture; -use crate::settings::{Language, Settings, SetupStep}; +use crate::settings::{Language, Settings, SetupStep, Theme, Units}; use crate::Msg; use super::home::contours; use super::settings::LanguageScreen; -use super::vocab::rows::ROW_X; +use super::vocab::chrome::{title_frame, LIST_TOP}; +use super::vocab::flags::{FLAG_H, FLAG_W}; +use super::vocab::rows::{choice_row, nav_row, row_rect, row_tick, Line2, ROW_GAP, ROW_ONE, ROW_TWO, ROW_X}; +use super::vocab::tiles::tile; use super::{palette, Ctx, Render, Screen, Transition}; /// The screen of the current setup step, or `None` once setup is done. @@ -28,6 +33,8 @@ pub(crate) fn screen(s: &Settings) -> Option { match s.setup { SetupStep::Hello => Some(Screen::Hello(HelloScreen)), SetupStep::Language => Some(Screen::SetupLanguage(SetupLanguageScreen::new(s.language))), + SetupStep::Units => Some(Screen::SetupUnits(SetupUnitsScreen(s.units))), + SetupStep::Theme => Some(Screen::SetupTheme(SetupThemeScreen(s.theme))), SetupStep::Done => None, } } @@ -150,6 +157,112 @@ impl SetupLanguageScreen { } } +/// The units step: Metric or Imperial, each with the symbols it reads in, over the ride tiles in +/// the units under the cursor. +#[derive(Debug)] +pub struct SetupUnitsScreen(pub(crate) Units); + +impl SetupUnitsScreen { + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + match g { + Gesture::Step(n) => { + self.0 = self.0.stepped(n); + Transition::None + } + Gesture::Press => { + cx.settings.units = self.0; + finish(SetupStep::Units, cx) + } + Gesture::Back => back(SetupStep::Units, cx), + Gesture::Hold | Gesture::BackHold => Transition::None, + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + let (w, h) = (rx.w, rx.h); + title_frame(cv, w, h, rx.t(Msg::SetupUnits), ""); + for (i, units) in Units::ALL.into_iter().enumerate() { + let area = row_rect(LIST_TOP + i as i32 * (ROW_TWO + ROW_GAP), w, ROW_TWO); + let symbols = if units.is_imperial() { "mi \u{b7} ft" } else { "km \u{b7} m" }; + let name = units.name(rx.settings.language); + nav_row(cv, area, name, Some(Line2::text(symbols)), units == self.0, true, false); + if units == rx.settings.units { + row_tick(cv, area); + } + } + ride_preview(cv, rx, self.0); + hint(cv, w, h, rx.t(Msg::SetupChoose), rx.t(Msg::SetupOk)); + } +} + +/// The theme step: Light or Dark. The whole page draws in the theme under the cursor (see +/// [`App::theme`](crate::App::theme)), and only Select commits it. +#[derive(Debug)] +pub struct SetupThemeScreen(pub(crate) Theme); + +impl SetupThemeScreen { + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + match g { + Gesture::Step(n) => { + self.0 = self.0.stepped(n); + Transition::None + } + Gesture::Press => { + cx.settings.theme = self.0; + finish(SetupStep::Theme, cx) + } + Gesture::Back => back(SetupStep::Theme, cx), + Gesture::Hold | Gesture::BackHold => Transition::None, + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + let (w, h) = (rx.w, rx.h); + title_frame(cv, w, h, rx.t(Msg::SetupTheme), ""); + for (i, theme) in Theme::ALL.into_iter().enumerate() { + let area = row_rect(LIST_TOP + i as i32 * (ROW_ONE + ROW_GAP), w, ROW_ONE); + let name = theme.name(rx.settings.language); + let committed = theme == rx.settings.theme; + choice_row(cv, area, name, theme == self.0, committed, |cv, x, y| swatch(cv, x, y, theme)); + } + ride_preview(cv, rx, rx.settings.units); + hint(cv, w, h, rx.t(Msg::SetupChoose), rx.t(Msg::SetupOk)); + } +} + +/// A theme's page in the flag slot: its paper, its ink round the edge, and two lines of text. The +/// colours are ones the theme mapping keeps, so each swatch shows its own theme on either page. +fn swatch(cv: &mut impl Surface, x: i32, y: i32, theme: Theme) { + use palette::*; + let (paper, ink) = match theme { + Theme::Light => (ART_WHITE, ON_ACCENT), + Theme::Dark => (ON_ACCENT, ART_WHITE), + }; + cv.fill(rect(x, y, FLAG_W, FLAG_H), ink); + cv.fill(rect(x + 1, y + 1, FLAG_W - 2, FLAG_H - 2), paper); + cv.fill(rect(x + 4, y + 3, FLAG_W - 8, 2), ink); + cv.fill(rect(x + 4, y + 7, FLAG_W - 12, 2), ink); +} + +/// The sample ride the preview shows: a speed in km/h and a distance in km. +const SAMPLE: (f32, f32) = (24.5, 86.4); + +/// Two ride tiles over the [`hint`], so a step shows what it changes on the ride screens. They sit +/// at one place on every step, so a step changes them in place. +fn ride_preview(cv: &mut impl Surface, rx: &Render, units: Units) { + use palette::*; + let (gap, tile_h) = (6, 54); + let tile_w = (rx.w - 2 * ROW_X - gap) / 2; + let y = rx.h - 8 - HINT_H - 12 - tile_h; + let (mut speed, mut dist) = (heapless::String::<8>::new(), heapless::String::<8>::new()); + let _ = write!(speed, "{:.1}", units.speed(SAMPLE.0)); + let _ = write!(dist, "{:.1}", units.dist(SAMPLE.1)); + for (i, (caption, value)) in [(units.speed_label(), speed), (units.dist_label(), dist)].into_iter().enumerate() { + let area = rect(ROW_X + i as i32 * (tile_w + gap), y, tile_w, tile_h); + tile(cv, area, &rx.marquee, caption, &value, false, TextAlign::Left, PARCHMENT_SHADE, SUBTEXT, INK); + } +} + /// The height of the [`hint`] band. const HINT_H: i32 = 40; diff --git a/firmware/obc-app/src/screen/vocab/rows.rs b/firmware/obc-app/src/screen/vocab/rows.rs index 0b5fbb566..9c424e51e 100644 --- a/firmware/obc-app/src/screen/vocab/rows.rs +++ b/firmware/obc-app/src/screen/vocab/rows.rs @@ -168,6 +168,35 @@ pub(crate) fn nav_row( } } +/// One choice of a pick list: a glyph in the flag slot, then the name. `glyph` draws at the +/// slot's top-left. The tick marks the committed choice, which the cursor does not move. +pub(crate) fn choice_row( + cv: &mut S, + area: Rectangle, + name: &str, + selected: bool, + committed: bool, + glyph: impl FnOnce(&mut S, i32, i32), +) { + row_cursor(cv, area, selected, false); + let (x, y, h) = (area.top_left.x + TEXT_DX, area.top_left.y, area.size.height as i32); + glyph(cv, x + 1, y + (h - super::flags::FLAG_H) / 2); + let ink = if selected { palette::ON_ACCENT } else { palette::INK }; + cv.text_vcentered(name, x + 30, (y, h), Font::Body, TextAlign::Left, ink); + if committed { + row_tick(cv, area); + } +} + +/// The committed tick of the drawer editor, at row scale, at the right of `area`. +pub(crate) fn row_tick(cv: &mut impl Surface, area: Rectangle) { + let tx = area.top_left.x + area.size.width as i32 - 22; + let cy = area.top_left.y + area.size.height as i32 / 2; + for k in 0..2 { + super::sheet::committed_tick(cv, tx + k, cy - k, palette::WOOD); + } +} + /// A door onto a destructive page, lettered in warning red until the cursor lands on it. A press /// opens it like any door; the confirm and its hold live on the page. pub(crate) fn danger_door_row(cv: &mut impl Surface, area: Rectangle, label: &str, selected: bool) { diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index c89d5459e..c6ac708a6 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -213,12 +213,14 @@ pub enum SetupStep { Done = 0, Hello = 1, Language = 2, + Units = 4, + Theme = 5, } impl SetupStep { /// The steps in the order the rider meets them. The byte values are persisted, so a new step /// takes a new value and its place here. - const ORDER: [SetupStep; 2] = [SetupStep::Hello, SetupStep::Language]; + const ORDER: [SetupStep; 4] = [SetupStep::Hello, SetupStep::Language, SetupStep::Units, SetupStep::Theme]; fn index(self) -> Option { Self::ORDER.iter().position(|&s| s == self) diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 30f5d553c..fed50ec50 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1799,6 +1799,54 @@ script = "p d" expect = "SetupLanguage" args = ["--fresh"] +# Setup's units step, after Select on the language: Metric and Imperial over the ride tiles. +[[frame]] +name = "setup-units" +map = "{grimsel}" +script = "p p" +expect = "SetupUnits" +args = ["--fresh"] + +[[frame]] +name = "setup-units" +map = "{grimsel}" +script = "p p" +expect = "SetupUnits" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +# The cursor on Imperial: the tiles read in miles, and the tick stays on Metric. +[[frame]] +name = "setup-units-imperial" +map = "{grimsel}" +script = "p p d" +expect = "SetupUnits" +args = ["--fresh"] + +# Setup's theme step on Light, the committed theme. +[[frame]] +name = "setup-theme" +map = "{grimsel}" +script = "p p p" +expect = "SetupTheme" +args = ["--fresh"] + +[[frame]] +name = "setup-theme" +map = "{grimsel}" +script = "p p p" +expect = "SetupTheme" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +# The cursor on Dark: the whole page previews Dark, and the tick stays on Light. +[[frame]] +name = "setup-theme-dark" +map = "{grimsel}" +script = "p p p d" +expect = "SetupTheme" +args = ["--fresh"] + # Route-upload popups (#451), all three variants. `--inject upload[-replace]=ID` raises the upload # event after the script, exactly as the control panel's inject buttons do. specs/vectors holds # two routes: id 0 = route-plain, id 1 = route-waypoints (filename order). diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index dcdcce7b9..1320aa9c2 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -312,6 +312,16 @@ b95d0853653b90a9e7d7c7df0f2881816e26992db60af4ab40a7c024622b7bcd setup-language cd45930c9fd72a44d790a30b33f43c6d199fa964ff865b052bddad6a325e91d7 setup-language-es.png 15ada0d6dcd5efa14acdc340b0ac7737b2e4a79c6ddcfc519979252ae0727890 setup-language-fr.png aed180639f39b22340377e67be745d7ba3c25cfb93039e2411d40c1c02fae3ad setup-language.png +86f7b57b7c2f09f3f26d7d43d73b59b5db662e9957798634ad746f28362410ad setup-theme-dark.png +ece9422fe8db990aae0b5b23202065f2baf81397fb312682c48755721fc7c7f0 setup-theme-de.png +ace5c48ba65ee4547020fe00bb0cfce0448ea96c28308298086c3c687fa337c7 setup-theme-es.png +3b9365aacc9b7d8de4514f906ad5a19f33e3bd285c8c5e82ac00581a175cc37b setup-theme-fr.png +c84fbb1a1586c49dfcc1484695121d6ab989c9d3001a00af446783467df05d96 setup-theme.png +d22d807ac4c702dfe85f734625775cc39c5d81d2f9a02ada362b870e0379e027 setup-units-de.png +483f992fda4d704e1396d623daeed063f6c08a096135d56600ea5084130fb819 setup-units-es.png +29002bf1cd9451b0501f6368705e37e842fcf9f5aff9fae90b3f53c7317f01b5 setup-units-fr.png +613fdb7efe225668f07739da62a57891cf3268913deb6c4325ce97d0e77679ae setup-units-imperial.png +6b22cea04f7dbcc379dba65b5d3031518767662ae371e1592d2b5cf62342ce4a setup-units.png d44c085c61a1672c4487b36a4eac869cd5f345a784e744a91d7eb89f546c57fa sound.png defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-noroute.png bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png From 4b367fe6270fa74eeb53b6e1d35a53701eb3b2fc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:15:15 +0000 Subject: [PATCH 06/37] onboarding: add the button lesson and count the setup steps Setup now runs Hello, Language, Buttons, then Home. The language step speaks the language under the cursor: its title and its hint follow the cursor. The tick stays on the committed language, and only Select commits and saves it. Each titled setup page draws its head through setup::title_bar, which puts the rider's place in the title bar's right slot ("1/2"). The count comes from SetupStep::ORDER and starts at the first titled step, so a new step gets it with no other change. The button lesson (SetupStep::Buttons, byte 3) puts a signpost board beside each button, pointing at its flank: Up and Down on the left, Select and Back on the right. A press fills its board amber. Until all four are filled, every press only fills, Back and Select included. Then the page acts as every setup page: Select continues and Back returns to the language step. The page also teaches one gesture: hold Back opens the menu from anywhere. Setup still refuses that escape, so the lesson never triggers it. The foot hint reads "Press each button", then "Continue" with the OK pill. The sweep gets setup-buttons and setup-buttons-done in four languages and setup-buttons-partial. The setup-language frames change for the step count and the preview. The copy-fit gate walks the lesson to its filled state. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 9 + firmware/obc-app/i18n/en.toml | 10 + firmware/obc-app/i18n/es.toml | 9 + firmware/obc-app/i18n/fr.toml | 9 + firmware/obc-app/src/app.rs | 20 +- firmware/obc-app/src/harness/copy_fit.rs | 3 + firmware/obc-app/src/screen/mod.rs | 5 +- .../obc-app/src/screen/settings/language.rs | 9 +- firmware/obc-app/src/screen/setup.rs | 199 ++++++++++++++++-- firmware/obc-app/src/settings.rs | 3 +- firmware/ui-frames.toml | 42 +++- firmware/ui-snapshots.sha256 | 19 +- 12 files changed, 300 insertions(+), 37 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index ec715b52a..0662bfbe4 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -531,6 +531,15 @@ ok = "OK" [setup] ok = "OK" choose = "Wählen" +buttons_title = "TASTEN" +up = "HOCH" +down = "RUNTER" +select = "WÄHLEN" +back = "ZURÜCK" +press_each = "Drücke jede Taste" +hold_back = "ZURÜCK halten" +menu_anywhere = "Menü, von überall" +continue = "Weiter" # --- Wiederhergestellte Fahrt beim Start --- [ride_recovery] diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 9c72fcb4e..45751c599 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -566,6 +566,16 @@ ok = "OK" ok = "OK" # The controls hint beside the Up and Down glyphs. choose = "Choose" +# The button lesson. The four names label the buttons on the device, at most seven characters. +buttons_title = "BUTTONS" +up = "UP" +down = "DOWN" +select = "SELECT" +back = "BACK" +press_each = "Press each button" +hold_back = "Hold BACK" +menu_anywhere = "Menu from anywhere" +continue = "Continue" # --- Boot-time recovered ride --- [ride_recovery] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 6f9b40ea4..b155cccec 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -530,6 +530,15 @@ ok = "OK" [setup] ok = "OK" choose = "Elegir" +buttons_title = "BOTONES" +up = "ARRIBA" +down = "ABAJO" +select = "ELEGIR" +back = "ATRÁS" +press_each = "Pulsa cada botón" +hold_back = "Mantén ATRÁS" +menu_anywhere = "El menú, siempre" +continue = "Continuar" # --- Salida recuperada al arrancar --- [ride_recovery] diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 7f3cc7dc4..df6d113f7 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -530,6 +530,15 @@ ok = "OK" [setup] ok = "OK" choose = "Choisir" +buttons_title = "BOUTONS" +up = "HAUT" +down = "BAS" +select = "CHOISIR" +back = "RETOUR" +press_each = "Essayez chacun" +hold_back = "Maintenir RETOUR" +menu_anywhere = "Le menu, partout" +continue = "Continuer" # --- Sortie récupérée au démarrage --- [ride_recovery] diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index f12a51cb4..365ec4988 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -6082,10 +6082,11 @@ mod tests { } /// A factory-fresh boot opens setup, and only its own pages leave it. Hello's press opens the - /// language step, and Back there returns to Hello. Select on a language ends setup on Home in - /// that language, and owes the save that makes the next boot skip setup. + /// language step, and Back there returns to Hello. Select on a language commits it and opens the + /// button lesson. Once each button is pressed there, Select ends setup on Home, and owes the + /// save that makes the next boot skip setup. #[test] - fn a_factory_boot_runs_setup_through_hello_and_the_language_step() { + fn a_factory_boot_runs_setup_through_its_steps() { use crate::settings::{Language, SetupStep}; let mut app = App::new_idle(AppState::new(0, 0, 1.0)); app.set_settings(Settings::FACTORY); @@ -6105,11 +6106,20 @@ mod tests { assert_eq!(app.settings().setup, SetupStep::Hello, "a power loss resumes on the step shown"); app.apply_gesture(Gesture::Press); + let before = *app.settings(); app.apply_gesture(Gesture::Step(1)); - assert_eq!(app.settings().language, Language::En, "the cursor commits nothing"); + assert_eq!(*app.settings(), before, "the page previews the cursor's language and saves nothing"); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupButtons(_)])); + assert_eq!((app.settings().setup, app.settings().language), (SetupStep::Buttons, Language::De)); + + for g in [Gesture::BackHold, Gesture::Step(-1), Gesture::Step(1), Gesture::Back, Gesture::Press] { + app.apply_gesture(g); + } + assert!(matches!(app.ui.stack.last(), Some(Screen::SetupButtons(_))), "the lesson's presses stay on it"); app.apply_gesture(Gesture::Press); assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)])); - assert_eq!((app.settings().setup, app.settings().language), (SetupStep::Done, Language::De)); + assert_eq!(app.settings().setup, SetupStep::Done); assert!(drain_persist(&mut app).is_some(), "the finished setup is saved"); } diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 039519a33..13fac1e53 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -189,6 +189,9 @@ fn seeds(language: Language) -> Vec { .map(|why| Screen::DfuFailed(DfuFailedScreen::new(why, Some("1.5.0")))) .into(), )); + // The button lesson changes its foot hint once all four buttons are pressed. + let lesson = vec![Gesture::Step(-1), Gesture::Step(1), Gesture::Back, Gesture::Press]; + v.push((Screen::SetupButtons(SetupButtonsScreen::default()), lesson)); // The About page is taller than the panel, so the lines under the fold are drawn only after it // scrolls. One step per line reaches every one of them; the offset clamps at the end. v.push((Screen::About(AboutScreen::new()), vec![Gesture::Step(1); 24])); diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index a6ed2c865..4982eb1ec 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -117,7 +117,7 @@ pub use settings::{ AboutScreen, AddFieldScreen, LanguageScreen, ResetScreen, SensorScanScreen, SensorsScreen, SettingsPage, StatFieldsScreen, }; -pub use setup::{HelloScreen, SetupLanguageScreen}; +pub use setup::{HelloScreen, SetupButtonsScreen, SetupLanguageScreen}; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; pub use trip_delete::TripDeleteScreen; @@ -911,6 +911,9 @@ screens! { /// Setup's language step: the Language pick list, where Select ends the step and Back returns /// to Hello. SetupLanguage(SetupLanguageScreen) => Caps::modal().blocking(), + /// Setup's button lesson: each press fills its button's board. Once all four are filled, + /// Select ends the step and Back returns to the language step. + SetupButtons(SetupButtonsScreen) => Caps::modal().blocking(), /// The card after Finish on a trip day: today's ledger, then tomorrow's day or the trip's /// totals. OK returns Home. DayDone(DayDoneScreen) => Caps::modal(), diff --git a/firmware/obc-app/src/screen/settings/language.rs b/firmware/obc-app/src/screen/settings/language.rs index 478841825..0be64b802 100644 --- a/firmware/obc-app/src/screen/settings/language.rs +++ b/firmware/obc-app/src/screen/settings/language.rs @@ -47,8 +47,13 @@ impl LanguageScreen { } pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { - let (w, h) = (rx.w, rx.h); - title_frame(cv, w, h, rx.t(Msg::LanguageTitle), ""); + title_frame(cv, rx.w, rx.h, rx.t(Msg::LanguageTitle), ""); + self.draw_list(cv, rx); + } + + /// The pick list under the title bar. + pub(crate) fn draw_list(&self, cv: &mut impl Surface, rx: &Render) { + let w = rx.w; let committed = rx.settings.language; for (i, lang) in Language::ALL.iter().enumerate() { let y = LIST_TOP + i as i32 * (ROW_ONE + ROW_GAP); diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index 8cc2c439b..14f6666d2 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -5,7 +5,10 @@ //! [`SetupStep`] variant, its place in `SetupStep::ORDER`, its screen, and its arm in [`screen`]. //! //! The first step is Hello: a greeting in the four UI languages, because the language is not chosen -//! yet. Every step after it is a titled page with the [`hint`] at its foot. +//! yet. Every step after it is a titled page: [`title_bar`] at its head and the [`hint`] at its +//! foot. + +use core::fmt::Write; use embedded_graphics::prelude::Point; use obc_render::{ @@ -14,12 +17,14 @@ use obc_render::{ Surface, }; +use crate::i18n::t; use crate::input::Gesture; use crate::settings::{Language, Settings, SetupStep}; use crate::Msg; use super::home::contours; use super::settings::LanguageScreen; +use super::vocab::chrome::{copy_w, title_frame, wrapped}; use super::vocab::rows::ROW_X; use super::{palette, Ctx, Render, Screen, Transition}; @@ -28,6 +33,7 @@ pub(crate) fn screen(s: &Settings) -> Option { match s.setup { SetupStep::Hello => Some(Screen::Hello(HelloScreen)), SetupStep::Language => Some(Screen::SetupLanguage(SetupLanguageScreen::new(s.language))), + SetupStep::Buttons => Some(Screen::SetupButtons(SetupButtonsScreen::default())), SetupStep::Done => None, } } @@ -124,7 +130,8 @@ fn signpost(cv: &mut impl Surface, cx: i32, top: i32) { } } -/// The language step: the Settings pick list, where Select also ends the step. +/// The language step: the Settings pick list, where Select also ends the step. The page speaks the +/// language under the cursor, so a rider finds their own by reading it. Only Select commits it. #[derive(Debug)] pub struct SetupLanguageScreen(LanguageScreen); @@ -145,17 +152,127 @@ impl SetupLanguageScreen { } pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { - self.0.draw(cv, rx); - hint(cv, rx.w, rx.h, rx.t(Msg::SetupChoose), rx.t(Msg::SetupOk)); + let lang = self.0.cursor(); + title_bar(cv, rx.w, rx.h, SetupStep::Language, t(Msg::LanguageTitle, lang)); + self.0.draw_list(cv, rx); + hint(cv, rx.w, rx.h, true, t(Msg::SetupChoose, lang), Some(t(Msg::SetupOk, lang))); + } +} + +/// The lesson's buttons, in the order of [`SetupButtonsScreen::pressed`]: the left flank top to +/// bottom, then the right. +const BUTTON_NAMES: [Msg; 4] = [Msg::SetupUp, Msg::SetupDown, Msg::SetupSelect, Msg::SetupBack]; + +/// The button lesson: a signpost board beside each button, pointing at it, and the one gesture +/// worth knowing before the first ride. A press fills its board. Until all four are filled every +/// press only fills, Back included, so the lesson cannot be left by a press it asks for. Then the +/// page acts as every setup page: Select continues and Back returns to the language step. +#[derive(Debug, Default)] +pub struct SetupButtonsScreen { + /// Up, Down, Select and Back. + pressed: [bool; 4], +} + +impl SetupButtonsScreen { + fn learnt(&self) -> bool { + self.pressed == [true; 4] + } + + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + if self.learnt() { + return match g { + Gesture::Press => finish(SetupStep::Buttons, cx), + Gesture::Back => back(SetupStep::Buttons, cx), + _ => Transition::None, + }; + } + // Back-hold never arrives: the app answers it, and setup refuses it. + let button = match g { + Gesture::Step(n) if n < 0 => 0, + Gesture::Step(_) => 1, + Gesture::Press | Gesture::Hold => 2, + Gesture::Back | Gesture::BackHold => 3, + }; + self.pressed[button] = true; + Transition::None + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + use palette::*; + let (w, h) = (rx.w, rx.h); + title_bar(cv, w, h, SetupStep::Buttons, rx.t(Msg::SetupButtonsTitle)); + for (i, name) in BUTTON_NAMES.into_iter().enumerate() { + board(cv, w, BOARD_ROWS[i % 2], i >= 2, rx.t(name), self.pressed[i]); + } + let y = wrapped(cv, rx.t(Msg::SetupHoldBack), w / 2, TIP_TOP, copy_w(w), Font::Label, INK); + wrapped(cv, rx.t(Msg::SetupMenuAnywhere), w / 2, y, copy_w(w), Font::Label, SUBTEXT); + if self.learnt() { + hint(cv, w, h, false, rx.t(Msg::SetupContinue), Some(rx.t(Msg::SetupOk))); + } else { + hint(cv, w, h, false, rx.t(Msg::SetupPressEach), None); + } + } +} + +/// The tops of the upper and the lower row of boards. Each row stands near the height of its +/// buttons, which sit on the flanks below the panel's middle. +const BOARD_ROWS: [i32; 2] = [130, 194]; +const BOARD_H: i32 = 40; +const TIP_TOP: i32 = 64; + +/// One lesson board, Hello's signpost board laid flat: its tip points at the flank the button is +/// on, left or `right`. A pressed board is filled amber like the brand signpost's. +fn board(cv: &mut impl Surface, w: i32, top: i32, right: bool, name: &str, pressed: bool) { + use palette::*; + // The board as `(tip, base, end)` x values, mirrored for the right flank, and its half-height. + let (tip, base, end) = if right { (w - 10, w - 22, w / 2 + 3) } else { (10, 22, w / 2 - 3) }; + let (cy, r) = (top + BOARD_H / 2, BOARD_H / 2); + if pressed { + arrow(cv, (tip, base, end), cy, r, AMBER); + } else { + // A 2 px wood outline: the inner board's edges run parallel to the outer ones. + let s = if right { -1 } else { 1 }; + arrow(cv, (tip, base, end), cy, r, WOOD); + arrow(cv, (tip + 3 * s, base + s, end - 2 * s), cy, r - 2, PARCHMENT); } + let color = if pressed { ON_ACCENT } else { INK }; + cv.text_vcentered(name, (base + end) / 2, (top, BOARD_H), Font::Label, TextAlign::Center, color); +} + +/// Fill an arrow board centred on `cy`, `r` its half-height: the tip at x `tip`, the body from +/// `base` to `end`. It is three triangles, as the signpost's boards are. +fn arrow(cv: &mut impl Surface, (tip, base, end): (i32, i32, i32), cy: i32, r: i32, color: u16) { + let t = Point::new(tip, cy); + let (a, b) = (Point::new(base, cy - r), Point::new(end, cy - r)); + let (c, d) = (Point::new(end, cy + r), Point::new(base, cy + r)); + cv.triangle(t, a, d, color); + cv.triangle(a, b, c, color); + cv.triangle(a, c, d, color); +} + +/// The rider's place among the titled steps, as `(n, of)`. Hello, the first step, has no title +/// bar, so the count starts at the step after it. +fn place(step: SetupStep) -> (usize, usize) { + let titled = &SetupStep::ORDER[1..]; + (titled.iter().position(|&s| s == step).map_or(0, |i| i + 1), titled.len()) +} + +/// The head of a titled setup page: the framed title bar, with the rider's [`place`] in its right +/// slot. +fn title_bar(cv: &mut impl Surface, w: i32, h: i32, step: SetupStep, title: &str) { + let (n, of) = place(step); + let mut right = heapless::String::<8>::new(); + let _ = write!(right, "{n}/{of}"); + title_frame(cv, w, h, title, &right); } /// The height of the [`hint`] band. const HINT_H: i32 = 40; -/// The controls hint at the foot of a setup page: Up and Down choose, Select confirms. The amber OK -/// is Hello's button in small, so it names the press the rider has already made. -fn hint(cv: &mut impl Surface, w: i32, h: i32, choose: &str, ok: &str) { +/// The controls hint at the foot of a titled setup page: the Up and Down glyphs when they choose, +/// the `text`, and the amber `ok` when Select confirms. The amber OK is Hello's button in small, so +/// it names the press the rider has already made. +fn hint(cv: &mut impl Surface, w: i32, h: i32, arrows: bool, text: &str, ok: Option<&str>) { use palette::*; let font = Font::Label; let top = h - 8 - HINT_H; @@ -165,19 +282,57 @@ fn hint(cv: &mut impl Surface, w: i32, h: i32, choose: &str, ok: &str) { // Up and Down side by side, each `2k` wide. let k = 5; - let arrows_w = 4 * k + 3; - let (choose_w, ok_w) = (text_width(choose, font) as i32, text_width(ok, font) as i32); - let pill_w = ok_w + 16; - let left = (w - (arrows_w + 8 + choose_w + 24 + pill_w)) / 2; - - let x = left; - cv.triangle(Point::new(x + k, cy - k), Point::new(x, cy + k), Point::new(x + 2 * k, cy + k), INK); - let x = left + arrows_w - 2 * k; - cv.triangle(Point::new(x, cy - k), Point::new(x + 2 * k, cy - k), Point::new(x + k, cy + k), INK); - let x = left + arrows_w + 8; - cv.text_vcentered(choose, x, span, font, TextAlign::Left, SUBTEXT); - - let px = x + choose_w + 24; - cv.round(rect(px, cy - 12, pill_w, 24), 6, AMBER); - cv.text_vcentered(ok, px + pill_w / 2, span, font, TextAlign::Center, ON_ACCENT); + let arrows_w = if arrows { 4 * k + 3 + 8 } else { 0 }; + let pill_w = ok.map_or(0, |ok| text_width(ok, font) as i32 + 16); + let text_w = text_width(text, font) as i32; + let left = (w - (arrows_w + text_w + if ok.is_some() { 24 + pill_w } else { 0 })) / 2; + + if arrows { + let x = left; + cv.triangle(Point::new(x + k, cy - k), Point::new(x, cy + k), Point::new(x + 2 * k, cy + k), INK); + let x = left + arrows_w - 8 - 2 * k; + cv.triangle(Point::new(x, cy - k), Point::new(x + 2 * k, cy - k), Point::new(x + k, cy + k), INK); + } + let x = left + arrows_w; + cv.text_vcentered(text, x, span, font, TextAlign::Left, SUBTEXT); + + if let Some(ok) = ok { + let px = x + text_w + 24; + cv.round(rect(px, cy - 12, pill_w, 24), 6, AMBER); + cv.text_vcentered(ok, px + pill_w / 2, span, font, TextAlign::Center, ON_ACCENT); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::activity::Activity; + use crate::screen::test_ctx; + use crate::{AppState, Mode}; + + /// Every press only fills its board until all four are filled, Back and Select included. Then + /// Back returns to the language step, and the persisted step follows. + #[test] + fn the_lesson_takes_every_button_before_back_or_select_act() { + let (mut st, mut act) = (AppState::new(0, 0, 1.0), Activity::new(Mode::Idle)); + let mut s = Settings { setup: SetupStep::Buttons, ..Settings::default() }; + let mut cx = test_ctx(&mut st, &mut act, &mut s); + let mut lesson = SetupButtonsScreen::default(); + for g in [Gesture::Back, Gesture::Press, Gesture::Back, Gesture::Step(-2)] { + assert!(matches!(lesson.handle(g, &mut cx), Transition::None), "{g:?} only fills its board"); + } + assert_eq!(lesson.pressed, [true, false, true, true]); + assert!(matches!(lesson.handle(Gesture::Step(1), &mut cx), Transition::None), "the last press fills"); + assert!(matches!(lesson.handle(Gesture::Back, &mut cx), Transition::Root(Screen::SetupLanguage(_)))); + assert_eq!(cx.settings.setup, SetupStep::Language); + } + + /// The count starts at the first titled step and ends at the last step. + #[test] + fn the_title_bar_counts_the_titled_steps() { + let of = SetupStep::ORDER.len() - 1; + assert_eq!(place(SetupStep::Language), (1, of)); + assert_eq!(place(SetupStep::Buttons), (2, of)); + assert_eq!(place(SetupStep::ORDER[of]), (of, of)); + } } diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index c89d5459e..531433989 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -213,12 +213,13 @@ pub enum SetupStep { Done = 0, Hello = 1, Language = 2, + Buttons = 3, } impl SetupStep { /// The steps in the order the rider meets them. The byte values are persisted, so a new step /// takes a new value and its place here. - const ORDER: [SetupStep; 2] = [SetupStep::Hello, SetupStep::Language]; + pub(crate) const ORDER: [SetupStep; 3] = [SetupStep::Hello, SetupStep::Language, SetupStep::Buttons]; fn index(self) -> Option { Self::ORDER.iter().position(|&s| s == self) diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 30f5d553c..cab5829a2 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1791,7 +1791,8 @@ expect = "SetupLanguage" args = ["--fresh"] langs = ["de", "fr", "es"] -# The cursor moved to Deutsch: the tick stays on the committed language until Select. +# The cursor moved to Deutsch: the page speaks German, and the tick stays on the committed language +# until Select. [[frame]] name = "setup-language-cursor" map = "{grimsel}" @@ -1799,6 +1800,45 @@ script = "p d" expect = "SetupLanguage" args = ["--fresh"] +# Setup's button lesson, reached through Hello and the language step: untouched, with Up and Back +# pressed, and with all four pressed, when Select continues. +[[frame]] +name = "setup-buttons" +map = "{grimsel}" +script = "p p" +expect = "SetupButtons" +args = ["--fresh"] + +[[frame]] +name = "setup-buttons" +map = "{grimsel}" +script = "p p" +expect = "SetupButtons" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +[[frame]] +name = "setup-buttons-partial" +map = "{grimsel}" +script = "p p u b" +expect = "SetupButtons" +args = ["--fresh"] + +[[frame]] +name = "setup-buttons-done" +map = "{grimsel}" +script = "p p u d b p" +expect = "SetupButtons" +args = ["--fresh"] + +[[frame]] +name = "setup-buttons-done" +map = "{grimsel}" +script = "p p u d b p" +expect = "SetupButtons" +args = ["--fresh"] +langs = ["de", "fr", "es"] + # Route-upload popups (#451), all three variants. `--inject upload[-replace]=ID` raises the upload # event after the script, exactly as the control panel's inject buttons do. specs/vectors holds # two routes: id 0 = route-plain, id 1 = route-waypoints (filename order). diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index dcdcce7b9..584997c14 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -306,12 +306,21 @@ e45d66a5ad980acf5ec2891d0ae89b517466fae60b1caf61b814f988be56a65d settings-es.pn 49286bcd4069b29c6094ebeadb8b35dc3cf2b3c1fc6dc70da98ab5cd7be810ac settings-fr.png 460cddf4695830c5cdececc764d507b4ee2a13fb3660dd66ca6ef20dedb84f37 settings-ride-waypoints.png c1a9b35839d97d9a06da9885587cc2f8b74dff61561d93b5fac55088148f94e5 settings.png +9a898f59f3b98a080ffa93801d885068a632a96a53a0303660f4bdf95dc438c5 setup-buttons-de.png +7a783a46d9cfa162be56a9b0ce46ae3ba8c9d7a17395e1410111db5f6cb24be8 setup-buttons-done-de.png +bcf0a121db95f58ae82d1e9ac9d736f10b3d0087d1a4cfcbab5ea3cdea5b5856 setup-buttons-done-es.png +47c337d94df517b2a77aae27d5587b1b3b5840946df628eb21139285a549a404 setup-buttons-done-fr.png +44b3a436227006e4bb4a19f01531f266993579998949da4fe6068401681e6991 setup-buttons-done.png +e89aeffa24bcd2e94bcd31d47fb27a4c86c7fc2bd9afef0ed7d6b83f830a28e6 setup-buttons-es.png +2c4181b05d027ca2a433076f1bff200e94fe85af7a7b2a1fee948200f847710c setup-buttons-fr.png +814dd4a1a3534534b073df95b80e5edba8979e9ce0dfdad8802c277417bde88c setup-buttons-partial.png +bdc7bc47f0230d568ecd2ff786c8ef4e4272e3ff1d9d2dae60d27bfb4146d05c setup-buttons.png fa7017db43b71eb7675c622608c8ac99e93e86a9e26ec39567091b1aa0d5450b setup-hello.png -b95d0853653b90a9e7d7c7df0f2881816e26992db60af4ab40a7c024622b7bcd setup-language-cursor.png -786e3e8cb639eb0d9bed5b6a15e3f32622ad3bd8989b4de82c71aad69e138495 setup-language-de.png -cd45930c9fd72a44d790a30b33f43c6d199fa964ff865b052bddad6a325e91d7 setup-language-es.png -15ada0d6dcd5efa14acdc340b0ac7737b2e4a79c6ddcfc519979252ae0727890 setup-language-fr.png -aed180639f39b22340377e67be745d7ba3c25cfb93039e2411d40c1c02fae3ad setup-language.png +9f89581b4a983fa0722bc875ebc1b198c7be0fde9f26d68e5e3584ecc70883ab setup-language-cursor.png +14bbd07e20b4c05cb01c175e692cefb3cbc8e15ab229d6d7097054cd50ebda5d setup-language-de.png +da96fb914547bb04712026ddff89b4d5911b3570521929808e716408bf91f622 setup-language-es.png +8529700b5b1717815457e5e42e2f22d9f7eb389bc80ee15afcc979b1182d8c7a setup-language-fr.png +9022c8aaa5454f72e99226967793e06bbe29468efa35c0cc4194167b12ccba88 setup-language.png d44c085c61a1672c4487b36a4eac869cd5f345a784e744a91d7eb89f546c57fa sound.png defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-noroute.png bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png From a9df1bf95bbaffb0d01764c61409f14078286802 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:25:11 +0000 Subject: [PATCH 07/37] onboarding: name the lesson's buttons in English in every language The four boards read UP, DOWN, SELECT and BACK, and the hold tip names BACK, in every UI language. The board names are literals, so their catalog keys go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 6 +----- firmware/obc-app/i18n/en.toml | 6 +----- firmware/obc-app/i18n/es.toml | 6 +----- firmware/obc-app/i18n/fr.toml | 6 +----- firmware/obc-app/src/screen/setup.rs | 6 +++--- firmware/ui-snapshots.sha256 | 12 ++++++------ 6 files changed, 13 insertions(+), 29 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index b3a6be480..417ed6c0b 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -532,12 +532,8 @@ ok = "OK" ok = "OK" choose = "Wählen" buttons_title = "TASTEN" -up = "HOCH" -down = "RUNTER" -select = "WÄHLEN" -back = "ZURÜCK" press_each = "Drücke jede Taste" -hold_back = "ZURÜCK halten" +hold_back = "BACK halten" menu_anywhere = "Menü, von überall" continue = "Weiter" units = "EINHEITEN" diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index d7ce2584d..d3081a224 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -566,12 +566,8 @@ ok = "OK" ok = "OK" # The controls hint beside the Up and Down glyphs. choose = "Choose" -# The button lesson. The four names label the buttons on the device, at most seven characters. +# The button lesson. It names the buttons in English in every language, so `hold_back` keeps "BACK". buttons_title = "BUTTONS" -up = "UP" -down = "DOWN" -select = "SELECT" -back = "BACK" press_each = "Press each button" hold_back = "Hold BACK" menu_anywhere = "Menu from anywhere" diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index f8fe7fdef..98c5ab943 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -531,12 +531,8 @@ ok = "OK" ok = "OK" choose = "Elegir" buttons_title = "BOTONES" -up = "ARRIBA" -down = "ABAJO" -select = "ELEGIR" -back = "ATRÁS" press_each = "Pulsa cada botón" -hold_back = "Mantén ATRÁS" +hold_back = "Mantén BACK" menu_anywhere = "El menú, siempre" continue = "Continuar" units = "UNIDADES" diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 189369685..490ebade6 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -531,12 +531,8 @@ ok = "OK" ok = "OK" choose = "Choisir" buttons_title = "BOUTONS" -up = "HAUT" -down = "BAS" -select = "CHOISIR" -back = "RETOUR" press_each = "Essayez chacun" -hold_back = "Maintenir RETOUR" +hold_back = "Maintenir BACK" menu_anywhere = "Le menu, partout" continue = "Continuer" units = "UNITÉS" diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index ef4fc3b69..72305afdd 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -164,8 +164,8 @@ impl SetupLanguageScreen { } /// The lesson's buttons, in the order of [`SetupButtonsScreen::pressed`]: the left flank top to -/// bottom, then the right. -const BUTTON_NAMES: [Msg; 4] = [Msg::SetupUp, Msg::SetupDown, Msg::SetupSelect, Msg::SetupBack]; +/// bottom, then the right. They are English in every language, so they are not catalog strings. +const BUTTON_NAMES: [&str; 4] = ["UP", "DOWN", "SELECT", "BACK"]; /// The button lesson: a signpost board beside each button, pointing at it, and the one gesture /// worth knowing before the first ride. A press fills its board. Until all four are filled every @@ -206,7 +206,7 @@ impl SetupButtonsScreen { let (w, h) = (rx.w, rx.h); title_bar(cv, w, h, SetupStep::Buttons, rx.t(Msg::SetupButtonsTitle)); for (i, name) in BUTTON_NAMES.into_iter().enumerate() { - board(cv, w, BOARD_ROWS[i % 2], i >= 2, rx.t(name), self.pressed[i]); + board(cv, w, BOARD_ROWS[i % 2], i >= 2, name, self.pressed[i]); } let y = wrapped(cv, rx.t(Msg::SetupHoldBack), w / 2, TIP_TOP, copy_w(w), Font::Label, INK); wrapped(cv, rx.t(Msg::SetupMenuAnywhere), w / 2, y, copy_w(w), Font::Label, SUBTEXT); diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index d4968f324..597095141 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -306,13 +306,13 @@ e45d66a5ad980acf5ec2891d0ae89b517466fae60b1caf61b814f988be56a65d settings-es.pn 49286bcd4069b29c6094ebeadb8b35dc3cf2b3c1fc6dc70da98ab5cd7be810ac settings-fr.png 460cddf4695830c5cdececc764d507b4ee2a13fb3660dd66ca6ef20dedb84f37 settings-ride-waypoints.png c1a9b35839d97d9a06da9885587cc2f8b74dff61561d93b5fac55088148f94e5 settings.png -fb5482dd81300d851de95f55b84256328700f9e301e6f497cba1a19d2c23fa82 setup-buttons-de.png -caaa8444ee4684a9ef60cd84963dbe7a224543d11fb1b2ee905eeb0ba65a5ca9 setup-buttons-done-de.png -bc6d6d1a92c80dfb57a500d9749f8793038e64b8b233625797b5d415ad6ff099 setup-buttons-done-es.png -f1899b7f00d25b4788c2c1d352dba28e2665b5ae811fd369d346793fe8af2f49 setup-buttons-done-fr.png +a9461474f77750513cd66ec6a86a2963e26a03785278c13592f9f11285b4bb3b setup-buttons-de.png +79c78445d53c2c084636f5407bb5f3424e765ac1447326396f2a000bb22b5666 setup-buttons-done-de.png +6bfd684117f1e4a4b2d808b97721109a10ff4933c706a4ca34c86a80c01e9a93 setup-buttons-done-es.png +62f28bf2832429d22170727d6eacb7bd2b0082df311935381a723c5c91e8a818 setup-buttons-done-fr.png 535c61344f3bf9b555c0f437ac5e1cd10f184119b9e85b858e4e57988f05846a setup-buttons-done.png -a8f551849fc0e1c9b7c3391a9dc67b085d2e33a89dc662432548f676466b01eb setup-buttons-es.png -506b9960b18ea1ef033adf7fd0b5b9c666687bb159e51761a8361649c65747a4 setup-buttons-fr.png +d3525572fe2f7dadab0dec8e8c40f3cb9e34db2cb2580c9cadfbfd49906345b5 setup-buttons-es.png +85499c5f3fd1df9c0a934c486192a46b2f8988ecc8ba4321aa5eb4c9c698ffe1 setup-buttons-fr.png 0c371e339b7b58f3008488ffe88c507820149b9fae299f262824290057fea012 setup-buttons-partial.png 211550a1c1c7eb1a71675af850949ea1952cdf27f46a3f07948fb18a5f7d9e35 setup-buttons.png fa7017db43b71eb7675c622608c8ac99e93e86a9e26ec39567091b1aa0d5450b setup-hello.png From 8391d875d05a96203786c337fb4bfa2bd3362a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:35:48 +0000 Subject: [PATCH 08/37] =?UTF-8?q?spec:=20define=20the=20QR=20pairing=20lin?= =?UTF-8?q?k=20(BLE=20spec=20=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-use setup screen shows a QR code with a universal link, https://openbikecomputer.com/pair/?s=<16 hex serial>. The app scans for the OBC Control service, filters on the factory name OBC-XXXX, reads the open DIS Serial Number String and pairs only on an exact serial match. The section also sets the QR symbol (version 4, level M, byte mode), the browser fallback page and the associated-domains file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- specs/obc-ble-interface-spec.md | 114 +++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index 2850ca483..c72864a84 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -2,7 +2,7 @@ The GATT surface the device serves to the companion app: the two SIG services, the custom OBC Control service, its live characteristics, and the payload layouts of the objects those -characteristics carry. Pairing and encryption are §8. +characteristics carry. Pairing and encryption are §8. The QR link that starts pairing is §9. Object transfers are **not** here. They are protocol v4, whose normative contract is [`FLAT_Store_Protocol.md`](FLAT_Store_Protocol.md); §5.1 of that document binds it to the @@ -430,3 +430,115 @@ Days count from 0 in the object; the rider sees Day 1 for day 0. device-side privacy. The phone stores that identity and reconnects on any advertising contact. Identifying the phone behind its rotating RPA uses the stored peer IRK in the controller resolving list, not a filter accept-list. + +## 9. Pairing link + +During first-use setup the device shows a QR code. The code holds a universal link. The phone +camera opens the app from the link, and the app finds the one device that shows the code. Pairing +then runs as §8 specifies. The link carries no secret: the passkey stays the only proof that the +rider holds the device. + +### 9.1 The link + +``` +https://openbikecomputer.com/pair/?s= +``` + +| Part | Bytes | +|---|---| +| scheme and host | `https://openbikecomputer.com`, lowercase | +| path | `/pair/` | +| query | one item, `s=` | +| `` | the Serial Number String of §3.1: 16 uppercase hex digits, `[0-9A-F]{16}` | + +Example: `https://openbikecomputer.com/pair/?s=0123456789ABCDEF`. The link is 53 ASCII bytes. It has +no fragment and no percent-encoding. + +**Why the serial.** The app can use only what it sees before it pairs: + +- The device address is not available. CoreBluetooth gives an app an opaque identifier per phone, + never the address. +- The factory name `OBC-XXXX` holds only the last four digits of the serial. Two devices can have + the same factory name. +- The serial is the full 64-bit factory device id, and the open DIS serves it before pairing (§8). + +The serial is not secret. The device gives it to every central in range. + +### 9.2 The QR code + +| Property | Value | +|---|---| +| Symbol | QR Code model 2 (ISO/IEC 18004) | +| Version | 4 (33 × 33 modules) | +| Error correction | level M | +| Segment | one byte-mode segment with the 53 link bytes | +| Mask | the mask that the standard penalty rule selects | +| Module | 5 × 5 px or larger | +| Quiet zone | 4 modules or more on each side | +| Colors | dark modules on a light background, in each theme | + +Version 4 at level M holds 62 bytes in byte mode. At 5 px per module the code and its quiet zone +use 205 × 205 px, which fits the 240 px panel width. + +### 9.3 Device rules + +- The device shows the code only while its bond slot is empty (§8). A bonded device refuses every + new pairing, so its code would be of no use. +- While the device shows the code, it advertises the OBC Control service UUID (§3.3) and its + factory name `OBC-XXXX`, where `XXXX` is the last four digits of the serial. First-use setup + follows a factory reset, which clears the name that the rider set. + +### 9.4 App rules + +1. **Validate.** The link is valid when the host is `openbikecomputer.com`, the path is `/pair/`, + and the query has exactly one `s` item that matches `[0-9A-F]{16}`. The app ignores query items + with other names. For an invalid link, the app tells the rider that the code is not an OBC + pairing code, and it does not scan. +2. **Scan.** The app scans for the OBC Control service UUID. A candidate is a device whose + advertised local name is `OBC-` followed by the last four digits of `s`. +3. **Match.** The app connects to a candidate and reads the Serial Number String (§3.1). It does no + gated operation first. When the value equals `s`, the app starts pairing (§8). When it does not, + the app disconnects and ignores that device for the rest of the scan. +4. **Not found.** When the scan window ends without a match, the app tells the rider that it did + not find the OBC and offers a new scan. + +The app never pairs with a device whose serial it did not match to `s`. After a match, a pairing +failure is as §8 specifies: a wrong passkey and a bonded device look the same to the app. + +### 9.5 When the app is not installed + +The phone opens the link in the browser. `https://openbikecomputer.com/pair/` serves one static page +for each query. The page: + +- tells the rider that the code is for the OBC companion app, and how to install the app; +- tells the rider to scan the code on the OBC again after the install, because iOS does not give + the link to an app that it installs later; +- does not store or send `s`. + +### 9.6 Associated domains + +A universal link opens the app only when the app and the domain each name the other. + +- **App entitlement.** `com.apple.developer.associated-domains` holds + `applinks:openbikecomputer.com`. +- **Domain file.** `https://openbikecomputer.com/.well-known/apple-app-site-association`, with no + file extension. The server sends it over HTTPS with a valid certificate, with status 200, with no + redirect, and with `Content-Type: application/json`. + +```json +{ + "applinks": { + "details": [ + { + "appIDs": [".com.openbikecomputer.companion"], + "components": [{ "/": "/pair/" }] + } + ] + } +} +``` + +`` is the Apple Developer team id that signs the release app. The component matches only +the path `/pair/`, so every other page of the site opens in the browser. The file does not limit +the query, because the app validates it (§9.4). iOS gets the file through the Apple CDN when it +installs or updates the app, so a change to the file does not reach a phone immediately. From 19930f7f11243593580b568522cd9e101a5808a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:48:00 +0000 Subject: [PATCH 09/37] onboarding: add the sensors step after Theme Setup now runs Hello, Language, Buttons, Units, Theme, Sensors, then Home. SetupStep gets Sensors (byte 6) and its place in ORDER, so the title bar counts out of 5. The sensors step lists the three slots of the Settings Sensors page: heart rate, power and cadence. An empty slot says how to wake its sensor ("Wear the strap", "Spin the cranks"). A saved slot shows its live status. Under the slots, a row reads Skip, or Continue once a sensor is saved. Select on a slot opens the Settings scan list for its kind. A pick there saves the sensor and returns to the step, as in Settings. Select on the last row ends setup, and Back returns to the theme step. A cursor move saves nothing. The scan list opens as SetupSensorScan, the same screen with blocking caps, so setup refuses the escape on it too. Its empty state now also says how to wake the sensor, in Settings as well. The sensor status and scan-hit feeds repaint every screen that declares the SensorSettings render key, not a fixed list. The sweep gets setup-sensors in four languages, setup-sensors-added and setup-sensors-scanning in four languages. The setup frames change for the step count, and sensors-scanning for the wake line. The copy-fit gate seeds both new screens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 3 + firmware/obc-app/i18n/en.toml | 4 ++ firmware/obc-app/i18n/es.toml | 3 + firmware/obc-app/i18n/fr.toml | 3 + firmware/obc-app/src/app.rs | 49 +++++++++++++-- firmware/obc-app/src/harness/copy_fit.rs | 2 + firmware/obc-app/src/render_key.rs | 2 + firmware/obc-app/src/screen/mod.rs | 9 ++- firmware/obc-app/src/screen/settings/mod.rs | 1 + .../obc-app/src/screen/settings/sensors.rs | 15 ++++- firmware/obc-app/src/screen/setup.rs | 60 +++++++++++++++++-- firmware/obc-app/src/settings.rs | 11 +++- firmware/obc-app/src/ui_runtime.rs | 4 +- firmware/ui-frames.toml | 41 +++++++++++++ firmware/ui-snapshots.sha256 | 59 ++++++++++-------- 15 files changed, 225 insertions(+), 41 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index 417ed6c0b..d03070485 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -311,6 +311,8 @@ connecting = "Verbinde" connected = "Verbunden" scanning = "Suche..." forget = "Entfernen" +wake_strap = "Gurt anlegen" +wake_cranks = "Kurbel drehen" # --- Language settings screen (the endonyms come from Language::name, uncatalogued) --- [language] @@ -536,6 +538,7 @@ press_each = "Drücke jede Taste" hold_back = "BACK halten" menu_anywhere = "Menü, von überall" continue = "Weiter" +skip = "Überspringen" units = "EINHEITEN" theme = "FARBSCHEMA" diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index d3081a224..c9f104552 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -338,6 +338,9 @@ connecting = "Connecting" connected = "Connected" scanning = "Searching..." forget = "Forget sensor" +# How to wake a sensor so a scan finds it. A row cuts it at 16 characters. +wake_strap = "Wear the strap" +wake_cranks = "Spin the cranks" # --- Language settings screen (the endonyms come from Language::name, uncatalogued) --- [language] @@ -572,6 +575,7 @@ press_each = "Press each button" hold_back = "Hold BACK" menu_anywhere = "Menu from anywhere" continue = "Continue" +skip = "Skip" # The titles of the units and theme steps. units = "UNITS" theme = "THEME" diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 98c5ab943..3e91371df 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -310,6 +310,8 @@ connecting = "Conectando" connected = "Conectado" scanning = "Buscando..." forget = "Olvidar sensor" +wake_strap = "Ponte la banda" +wake_cranks = "Pedalea un poco" # --- Language settings screen (the endonyms come from Language::name, uncatalogued) --- [language] @@ -535,6 +537,7 @@ press_each = "Pulsa cada botón" hold_back = "Mantén BACK" menu_anywhere = "El menú, siempre" continue = "Continuar" +skip = "Omitir" units = "UNIDADES" theme = "TEMA" diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 490ebade6..32ba3ba15 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -310,6 +310,8 @@ connecting = "Connexion" connected = "Connecté" scanning = "Recherche..." forget = "Oublier" +wake_strap = "Mettez la sangle" +wake_cranks = "Pédalez un peu" # --- Language settings screen (the endonyms come from Language::name, uncatalogued) --- [language] @@ -535,6 +537,7 @@ press_each = "Essayez chacun" hold_back = "Maintenir BACK" menu_anywhere = "Le menu, partout" continue = "Continuer" +skip = "Passer" units = "UNITÉS" theme = "THÈME" diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 4e5d76cae..fa4860391 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -6132,7 +6132,8 @@ mod tests { /// The units and theme steps commit on Select only. The theme step draws the frame in the /// theme under its cursor, and that preview is never saved: a step leaves the setting alone, - /// and Back returns to the units step in the committed theme. Select on a theme ends setup. + /// and Back returns to the units step in the committed theme. Select on a theme opens the + /// sensors step. #[test] fn the_units_and_theme_steps_commit_on_select_and_the_theme_preview_saves_nothing() { use crate::settings::{SetupStep, Theme, Units}; @@ -6158,10 +6159,50 @@ mod tests { app.apply_gesture(Gesture::Press); app.apply_gesture(Gesture::Step(1)); app.apply_gesture(Gesture::Press); - assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)])); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupSensors(_)])); let s = app.settings(); - assert_eq!((s.setup, s.units, s.theme), (SetupStep::Done, Units::Imperial, Theme::Dark)); - assert!(host.drain(&mut app).is_some(), "the finished setup is saved"); + assert_eq!((s.setup, s.units, s.theme), (SetupStep::Sensors, Units::Imperial, Theme::Dark)); + assert!(host.drain(&mut app).is_some(), "the step is saved"); + } + + /// The sensors step adds a sensor through the Settings scan list, which setup's escape refusal + /// covers too. A cursor move saves nothing, Back returns to the theme step, and the last row ends setup. + #[test] + fn the_sensors_step_adds_through_the_scan_list_and_its_last_row_ends_setup() { + use crate::settings::{SavedSensor, SetupStep}; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings { setup: SetupStep::Sensors, ..Settings::FACTORY }); + app.set_sensor_scan_hits(&[crate::sensors::SensorScanHit::new(1, 0, [1, 2, 3, 4, 5, 6], "Stages", -60)]); + let mut host = SettingsHost::default(); + + app.apply_gesture(Gesture::Step(1)); + assert_eq!(host.drain(&mut app), None, "a cursor move saves nothing"); + app.apply_gesture(Gesture::Press); + assert!(matches!( + app.ui.stack.as_slice(), + [Screen::Home(_), Screen::SetupSensors(_), Screen::SetupSensorScan(_)] + )); + assert!(app.sensor_scan_active()); + app.apply_gesture(Gesture::BackHold); + assert!(matches!(app.ui.stack.last(), Some(Screen::SetupSensorScan(_))), "setup cannot be escaped"); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupSensors(_)])); + assert_eq!( + app.settings().saved_sensors[1], + SavedSensor::saved(0, [1, 2, 3, 4, 5, 6]), + "the pick is the power meter" + ); + assert!(!app.sensor_scan_active()); + + app.apply_gesture(Gesture::Back); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupTheme(_)])); + assert_eq!(app.settings().setup, SetupStep::Theme); + app.apply_gesture(Gesture::Press); + app.apply_gesture(Gesture::Step(-1)); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)]), "Up from the first slot reaches Continue"); + assert_eq!(app.settings().setup, SetupStep::Done); + assert!(app.settings().saved_sensors[1].present, "the added sensor stays"); } /// A cancel posted while the plan request is still undrained annihilates it: the rider's net diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 8839f2b0a..e7eb0565c 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -120,6 +120,8 @@ fn seeds(language: Language) -> Vec { Screen::SetupLanguage(SetupLanguageScreen::new(language)), Screen::SetupUnits(SetupUnitsScreen(crate::settings::Units::Metric)), Screen::SetupTheme(SetupThemeScreen(crate::settings::Theme::Light)), + Screen::SetupSensors(SetupSensorsScreen::default()), + Screen::SetupSensorScan(SensorScanScreen::new(1)), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Receiving { received_kib: 1_024, total_kib: 65_536 })), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Installed)), Screen::Settings(SettingsPage::hub()), diff --git a/firmware/obc-app/src/render_key.rs b/firmware/obc-app/src/render_key.rs index bbf182418..af693a1d8 100644 --- a/firmware/obc-app/src/render_key.rs +++ b/firmware/obc-app/src/render_key.rs @@ -425,6 +425,8 @@ mod tests { ("Landmarks", RenderKeyKind::Map), ("Statistics", RenderKeyKind::Statistics), ("Climb", RenderKeyKind::Climb), + ("SetupSensors", RenderKeyKind::SensorSettings), + ("SetupSensorScan", RenderKeyKind::SensorSettings), ("PeakView", RenderKeyKind::Statistics), ("Detour", RenderKeyKind::Map), ("DetourPreview", RenderKeyKind::Map), diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index 442f25a27..055f2b720 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -117,7 +117,9 @@ pub use settings::{ AboutScreen, AddFieldScreen, LanguageScreen, ResetScreen, SensorScanScreen, SensorsScreen, SettingsPage, StatFieldsScreen, }; -pub use setup::{HelloScreen, SetupButtonsScreen, SetupLanguageScreen, SetupThemeScreen, SetupUnitsScreen}; +pub use setup::{ + HelloScreen, SetupButtonsScreen, SetupLanguageScreen, SetupSensorsScreen, SetupThemeScreen, SetupUnitsScreen, +}; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; pub use trip_delete::TripDeleteScreen; @@ -918,6 +920,11 @@ screens! { SetupUnits(SetupUnitsScreen) => Caps::modal().blocking(), /// Setup's theme step: Light or Dark. The frame draws in the theme under its cursor. SetupTheme(SetupThemeScreen) => Caps::modal().blocking(), + /// Setup's sensors step: the three sensor slots with their live status, then Skip or Continue. + SetupSensors(SetupSensorsScreen) => Caps::modal().blocking().key(RenderKeyKind::SensorSettings), + /// The Settings scan list for one slot, opened from the sensors step. It blocks the escape, + /// because the step it returns to does. + SetupSensorScan(SensorScanScreen) => Caps::modal().blocking().key(RenderKeyKind::SensorSettings), /// The card after Finish on a trip day: today's ledger, then tomorrow's day or the trip's /// totals. OK returns Home. DayDone(DayDoneScreen) => Caps::modal(), diff --git a/firmware/obc-app/src/screen/settings/mod.rs b/firmware/obc-app/src/screen/settings/mod.rs index 9ae4ea23b..88e2de8e8 100644 --- a/firmware/obc-app/src/screen/settings/mod.rs +++ b/firmware/obc-app/src/screen/settings/mod.rs @@ -23,6 +23,7 @@ pub use fields::StatFieldsScreen; pub use language::LanguageScreen; pub use page::SettingsPage; pub use reset::ResetScreen; +pub(crate) use sensors::{kind_msg, status_line, wake_msg}; pub use sensors::{SensorScanScreen, SensorsScreen}; /// The bottom-anchored guarded Forget row of the Sensors list: the destructive action row of the diff --git a/firmware/obc-app/src/screen/settings/sensors.rs b/firmware/obc-app/src/screen/settings/sensors.rs index fa14b2ece..cdd0ab6fc 100644 --- a/firmware/obc-app/src/screen/settings/sensors.rs +++ b/firmware/obc-app/src/screen/settings/sensors.rs @@ -19,7 +19,7 @@ use crate::settings::{Language, SavedSensor, SENSOR_SLOTS}; use crate::Msg; /// The label key for a slot. The slot index is the sensor kind. -fn kind_msg(slot: usize) -> Msg { +pub(crate) fn kind_msg(slot: usize) -> Msg { match slot { 0 => Msg::SensorsHeartRate, 1 => Msg::SensorsPower, @@ -27,6 +27,15 @@ fn kind_msg(slot: usize) -> Msg { } } +/// How the rider wakes a slot's sensor so a scan finds it: a strap wakes on the skin, a power or +/// cadence sensor on a crank turn. +pub(crate) fn wake_msg(slot: usize) -> Msg { + match slot { + 0 => Msg::SensorsWakeStrap, + _ => Msg::SensorsWakeCranks, + } +} + #[derive(Debug, Default)] pub struct SensorsScreen { selected: usize, @@ -85,7 +94,7 @@ impl SensorsScreen { /// Compose one row's status line into `buf`. A saved slot whose status snapshot is not yet current /// reads `Searching`, so the line does not contradict the armed Forget footer. -fn status_line(buf: &mut heapless::String<24>, present: bool, status: SensorStatus, lang: Language) { +pub(crate) fn status_line(buf: &mut heapless::String<24>, present: bool, status: SensorStatus, lang: Language) { if !present { let _ = buf.push_str(crate::t(Msg::SensorsNotSet, lang)); return; @@ -165,7 +174,7 @@ impl SensorScanScreen { let len = self.count(rx.sensor_scan_hits); if len == 0 { - empty_state(cv, w, h, rx.t(Msg::SensorsScanning), ""); + empty_state(cv, w, h, rx.t(Msg::SensorsScanning), rx.t(wake_msg(self.slot as usize))); return; } diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index 72305afdd..efed44bf6 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -19,13 +19,14 @@ use obc_render::{ use crate::i18n::t; use crate::input::Gesture; -use crate::settings::{Language, Settings, SetupStep, Theme, Units}; +use crate::settings::{Language, Settings, SetupStep, Theme, Units, SENSOR_SLOTS}; use crate::Msg; use super::home::contours; -use super::settings::LanguageScreen; +use super::settings::{kind_msg, status_line, wake_msg, LanguageScreen, SensorScanScreen}; use super::vocab::chrome::{copy_w, title_frame, wrapped, LIST_TOP}; use super::vocab::flags::{FLAG_H, FLAG_W}; +use super::vocab::list::on_step; use super::vocab::rows::{choice_row, nav_row, row_rect, row_tick, Line2, ROW_GAP, ROW_ONE, ROW_TWO, ROW_X}; use super::vocab::tiles::tile; use super::{palette, Ctx, Render, Screen, Transition}; @@ -38,6 +39,7 @@ pub(crate) fn screen(s: &Settings) -> Option { SetupStep::Buttons => Some(Screen::SetupButtons(SetupButtonsScreen::default())), SetupStep::Units => Some(Screen::SetupUnits(SetupUnitsScreen(s.units))), SetupStep::Theme => Some(Screen::SetupTheme(SetupThemeScreen(s.theme))), + SetupStep::Sensors => Some(Screen::SetupSensors(SetupSensorsScreen::default())), SetupStep::Done => None, } } @@ -327,6 +329,56 @@ impl SetupThemeScreen { } } +/// The sensors step: the three slots of Settings' Sensors page over Skip, which reads Continue +/// once a sensor is saved. Select on a slot opens the Settings scan list for its kind, where a pick +/// saves the sensor and returns here. An empty slot says how to wake its sensor, and a saved one +/// shows its live status. +#[derive(Debug, Default)] +pub struct SetupSensorsScreen { + /// A slot, or [`SKIP`]. + selected: usize, +} + +/// The Skip row's index, after the slots. +const SKIP: usize = SENSOR_SLOTS; + +impl SetupSensorsScreen { + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + match g { + Gesture::Step(n) => on_step(&mut self.selected, n, SKIP + 1), + Gesture::Press if self.selected == SKIP => finish(SetupStep::Sensors, cx), + // Scan mode makes the host run a discovery scan. The scan list lowers it on exit. + Gesture::Press => { + cx.activity.request_sensor_scan(true); + Transition::Push(Screen::SetupSensorScan(SensorScanScreen::new(self.selected as u8))) + } + Gesture::Back => back(SetupStep::Sensors, cx), + Gesture::Hold | Gesture::BackHold => Transition::None, + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + let (w, h) = (rx.w, rx.h); + title_bar(cv, w, h, SetupStep::Sensors, rx.t(Msg::SensorsTitle)); + let saved = rx.settings.saved_sensors; + for (slot, sensor) in saved.iter().enumerate() { + let area = row_rect(LIST_TOP + slot as i32 * (ROW_TWO + ROW_GAP), w, ROW_TWO); + let mut line = heapless::String::<24>::new(); + if sensor.present { + let status = rx.sensor_status.get(slot).copied().unwrap_or_default(); + status_line(&mut line, true, status, rx.settings.language); + } else { + let _ = line.push_str(rx.t(wake_msg(slot))); + } + nav_row(cv, area, rx.t(kind_msg(slot)), Some(Line2::text(&line)), slot == self.selected, true, false); + } + let label = if saved.iter().any(|s| s.present) { Msg::SetupContinue } else { Msg::SetupSkip }; + let area = row_rect(h - 8 - HINT_H - 12 - ROW_ONE, w, ROW_ONE); + nav_row(cv, area, rx.t(label), None, self.selected == SKIP, true, true); + hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(rx.t(Msg::SetupOk))); + } +} + /// A theme's page in the flag slot: its paper, its ink round the edge, and two lines of text. The /// colours are ones the theme mapping keeps, so each swatch shows its own theme on either page. fn swatch(cv: &mut impl Surface, x: i32, y: i32, theme: Theme) { @@ -440,7 +492,7 @@ mod tests { /// The count starts at the first titled step and ends at the last step. #[test] fn the_title_bar_counts_the_titled_steps() { - let places = [SetupStep::Language, SetupStep::Buttons, SetupStep::Units, SetupStep::Theme].map(place); - assert_eq!(places, [(1, 4), (2, 4), (3, 4), (4, 4)]); + let places = [SetupStep::Language, SetupStep::Buttons, SetupStep::Units, SetupStep::Theme, SetupStep::Sensors]; + assert_eq!(places.map(place), [(1, 5), (2, 5), (3, 5), (4, 5), (5, 5)]); } } diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index 76565687e..8d5ec1bcb 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -216,13 +216,20 @@ pub enum SetupStep { Buttons = 3, Units = 4, Theme = 5, + Sensors = 6, } impl SetupStep { /// The steps in the order the rider meets them. The byte values are persisted, so a new step /// takes a new value and its place here. - pub(crate) const ORDER: [SetupStep; 5] = - [SetupStep::Hello, SetupStep::Language, SetupStep::Buttons, SetupStep::Units, SetupStep::Theme]; + pub(crate) const ORDER: [SetupStep; 6] = [ + SetupStep::Hello, + SetupStep::Language, + SetupStep::Buttons, + SetupStep::Units, + SetupStep::Theme, + SetupStep::Sensors, + ]; fn index(self) -> Option { Self::ORDER.iter().position(|&s| s == self) diff --git a/firmware/obc-app/src/ui_runtime.rs b/firmware/obc-app/src/ui_runtime.rs index 2cd282d19..55cd17996 100644 --- a/firmware/obc-app/src/ui_runtime.rs +++ b/firmware/obc-app/src/ui_runtime.rs @@ -421,9 +421,9 @@ impl UiRuntime { } } - /// The Sensors pages, and Connections, whose Sensors door counts the connected ones. + /// Whether the top screen draws the sensor status or the scan hits. fn sensors_screen_up(&self) -> bool { - matches!(self.stack.last(), Some(Screen::Sensors(_) | Screen::SensorScan(_) | Screen::Connections(_))) + self.stack.last().is_some_and(|s| s.caps().render_key == crate::screen::RenderKeyKind::SensorSettings) } /// Whether the base screen draws the connected indicator: everything whose base is diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 092df8e0a..9ea2524de 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1887,6 +1887,47 @@ script = "p p u d b p p p d" expect = "SetupTheme" args = ["--fresh"] +# Setup's sensors step with no sensor saved: each slot says how to wake its sensor, over Skip. +[[frame]] +name = "setup-sensors" +map = "{grimsel}" +script = "p p u d b p p p p" +expect = "SetupSensors" +args = ["--fresh"] + +[[frame]] +name = "setup-sensors" +map = "{grimsel}" +script = "p p u d b p p p p" +expect = "SetupSensors" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +# Two sensors saved: the slots show their live status, and the last row reads Continue. +[[frame]] +name = "setup-sensors-added" +map = "{grimsel}" +script = "p p u d b p p p p" +expect = "SetupSensors" +args = ["--fresh", "--sensors", "screen"] + +# Select on the power slot opens the Settings scan list for it. Before a hit, the empty list says +# how to wake a power meter. +[[frame]] +name = "setup-sensors-scanning" +map = "{grimsel}" +script = "p p u d b p p p p d p" +expect = "SetupSensorScan" +args = ["--fresh"] + +[[frame]] +name = "setup-sensors-scanning" +map = "{grimsel}" +script = "p p u d b p p p p d p" +expect = "SetupSensorScan" +args = ["--fresh"] +langs = ["de", "fr", "es"] + # Route-upload popups (#451), all three variants. `--inject upload[-replace]=ID` raises the upload # event after the script, exactly as the control panel's inject buttons do. specs/vectors holds # two routes: id 0 = route-plain, id 1 = route-waypoints (filename order). diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index 597095141..b704ddbd9 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -299,38 +299,47 @@ d1bfa358b6d4942e985fcd29d5d195be372d58df53e5ed14fdd65c6b4c9433b3 routeswap-rece a52f0e5bea8f23d27d4b573d377d3cc89a26c42350427c024ad8ee8bcc6d2aef sensors-es.png 6d9641aa1cd742d7114de305c81f46327cd48e6b44363062f10af9881738a777 sensors-fr.png 40fafbc43f4532466746bf4dbf7b09c50213f2946f45bd0e82e94b4794bbfd23 sensors-scan.png -def75c60c02b8a81143681b19ac76fa65f70b5065100633103a7b370e64c3018 sensors-scanning.png +8ece814611d60aca5e49ca7150231e7a3d79236abadae929a2b04bdab6fa0ced sensors-scanning.png 798eadec7f7fc5aad65d04cd42d6f24ae08f96f037d1b6ae07a120721584752d sensors.png e163fd0ce13998bdf579b8190e43ace910caef8c8aa47342d35f9071cbfc5ecf settings-de.png e45d66a5ad980acf5ec2891d0ae89b517466fae60b1caf61b814f988be56a65d settings-es.png 49286bcd4069b29c6094ebeadb8b35dc3cf2b3c1fc6dc70da98ab5cd7be810ac settings-fr.png 460cddf4695830c5cdececc764d507b4ee2a13fb3660dd66ca6ef20dedb84f37 settings-ride-waypoints.png c1a9b35839d97d9a06da9885587cc2f8b74dff61561d93b5fac55088148f94e5 settings.png -a9461474f77750513cd66ec6a86a2963e26a03785278c13592f9f11285b4bb3b setup-buttons-de.png -79c78445d53c2c084636f5407bb5f3424e765ac1447326396f2a000bb22b5666 setup-buttons-done-de.png -6bfd684117f1e4a4b2d808b97721109a10ff4933c706a4ca34c86a80c01e9a93 setup-buttons-done-es.png -62f28bf2832429d22170727d6eacb7bd2b0082df311935381a723c5c91e8a818 setup-buttons-done-fr.png -535c61344f3bf9b555c0f437ac5e1cd10f184119b9e85b858e4e57988f05846a setup-buttons-done.png -d3525572fe2f7dadab0dec8e8c40f3cb9e34db2cb2580c9cadfbfd49906345b5 setup-buttons-es.png -85499c5f3fd1df9c0a934c486192a46b2f8988ecc8ba4321aa5eb4c9c698ffe1 setup-buttons-fr.png -0c371e339b7b58f3008488ffe88c507820149b9fae299f262824290057fea012 setup-buttons-partial.png -211550a1c1c7eb1a71675af850949ea1952cdf27f46a3f07948fb18a5f7d9e35 setup-buttons.png +67accee000324b574fb6d5465ed99461ebf52236636cf44db85119dd1ecd2585 setup-buttons-de.png +bedd16773d199eb5bfa4fb769702ebbc7b5d9bd7b79c85b138c504d4eee8abdd setup-buttons-done-de.png +da310937b0a77ac7807f990170bfbf12f09206804c2eb4b061a763c3aec1f98e setup-buttons-done-es.png +e12ab67cb6d4625817fa61b09329fe80e089341c4d6a42c71c91dd2e3e242f2a setup-buttons-done-fr.png +994c73b78a648f8c3fbee2633344b48ff2a3fc99c06eb3f030b50a5eedf2eb31 setup-buttons-done.png +4efa444d3119fde015aad7852546293b5aa6a59ddb20a9d6dccbb8162b980f1d setup-buttons-es.png +b6e241b477c200103d04c42913ef09f83556e6e2ad360a650e5092f054f46654 setup-buttons-fr.png +54a0be240214ab344bd8751b4e563265a2cf42a0021662a078ec454ab3e53c2b setup-buttons-partial.png +df19f049ebd4f120bea3b6d13e7ffb668eb9350e6e054c4fe42241a925a11085 setup-buttons.png fa7017db43b71eb7675c622608c8ac99e93e86a9e26ec39567091b1aa0d5450b setup-hello.png -a8ae5e32dba655379a35c205e1c977efe553e18fda5c54fb1d04004badb2b1d1 setup-language-cursor.png -fa6256ea1c4c57b6c6d62e9802d961550a7f0ace9ee499a4ac0fc159d24af30e setup-language-de.png -f6c37c28f127a0a34e959b71af2e3b423081c4e7ef18eb01b68a70fe2f60ab33 setup-language-es.png -797ef3a19f1b07d7167d7ed44bb03ec2fa90bcb5fdcea89df08afb49046f5d5d setup-language-fr.png -34be58f6976d3afcdd13c7c1c600a0b24d26b16371a43de6f837aea97c6179e6 setup-language.png -2f9157dd9307bac4a97a52525823164c961c11d812eef3a6f2c1ef0680113bc1 setup-theme-dark.png -23c9e0a42d62459c3703c7e2f16376e6d7d930377b9678e665d0fb74f665a624 setup-theme-de.png -5dcf99aef472a1eb17ec20fd7f2e25709315941ad15832b084f1497864ab64f1 setup-theme-es.png -670a61e5b408dcd1a677749cc3d2fd1cdb838857000c8a99a03ba8414c91e4dc setup-theme-fr.png -27de869071accbbeea8042d46f72fa5c591e9c567d357c9da9f905162411b154 setup-theme.png -d054a427e4aa8f06de3ed757328aa50559ec6119e4e492c836755d8520759aab setup-units-de.png -615582d525d4e6d1b6999bc258438954c852d72b0fdc596a3f97f60265e90a01 setup-units-es.png -322b2158b03f637c172eee0648f9216db94ec0344b408bf027b739fc1e4f08cf setup-units-fr.png -33beb01eecbbfa3ac1f693879e55ee5aca39dec2ea409ca46c2dc5420ee972e7 setup-units-imperial.png -08de1fb91d37bdf70733de7352aa0d291fb8a859acb1eeee21d3e40aa2a69099 setup-units.png +f0b3f91678f3de8602cab8f9dcc8e945ed3d0bcf8cb8c4d58a45c8f027e0c6e0 setup-language-cursor.png +bf97929217a4cde32e31911521c9cb779e006be7215dd41da40b35050f033034 setup-language-de.png +73514b869111e5506ea9079622f0a6a1c46f67e4f0bcacba48d6e98ccb1ca13c setup-language-es.png +a6c3e8874817c4f156dee42a3b56aafde42331b178865d8dc0fe054efe7de600 setup-language-fr.png +9a5c7e7f5c2b0c298d1932dacce407e63139ba6fb55d4d73d160af2c033a98cc setup-language.png +8b5c2dce548e34ed5121acfa9796eac24fc386a93acbf7d947bb6021dd0908b6 setup-sensors-added.png +bb60b3da893e568f6ecbbc2b03887ab00b0242011663d9d323c4a029df007489 setup-sensors-de.png +f53fbb278743bafdd8093c96def7468cdac4814d2c3ca6277a1607cf25a84c6c setup-sensors-es.png +5ccf774158a0790e61723e7e1a39a358b0dc1d0c6abddef1eac0947bee6da904 setup-sensors-fr.png +d0ecce76581f88d2877327f45e9b115973a24319966c0bddc45780332c809635 setup-sensors-scanning-de.png +580bcd60218f029c98aadc392918d50af335f7565bacce3c0c33d214b54c745e setup-sensors-scanning-es.png +340ba4899b2f2d983e0750d3ae469392bc19e17da0033ce69694c661418faacf setup-sensors-scanning-fr.png +4b667fd900c929dde3c5179f86d98d098216a118ab7a457ec7bbaca8bd456d17 setup-sensors-scanning.png +8546c958f739083b2e8a54d541fc643a1bdf70eab14f72fa3cd254a081f05fa4 setup-sensors.png +44da4201ee56d8e1d462a6827ebd915244ed0cf85d1def5549767a2f370716e6 setup-theme-dark.png +b097e2d89d897ac01f043baca540483011394ea57d6ccd53185f87847b383714 setup-theme-de.png +71dc77e378a30bf2d1f1e9b363a6a60650fc16e0963b4bd99f590958edad1cab setup-theme-es.png +72c104960cab02bdbe2bae4d898c6c195dde1f062c58f19e3aad1aede73f4e1e setup-theme-fr.png +514c521b22a68103e942b599dc0e62c14e3dfc907156c89ed2148050b7b545ae setup-theme.png +02a5137a51c453d07ea97022d6af904222ea9bea620140e9edb9be48206a59f9 setup-units-de.png +9041396ba236c633309a782d41dad4aa6a7f8bc021ecaffffc9b7bd1f9e71a99 setup-units-es.png +482b1b0cf92e13d59af9b794e23f765ab8faa25162e5507e215f1a7e8efa3531 setup-units-fr.png +ae213d61136c91ea4c338f3b0a86432a9f01ac5384fef42f0e2c7e9d01204433 setup-units-imperial.png +c4cf28cc2da3b1528eb4cefd3fe9477601c1d48e2ae6e15c4e7209b209ae9e24 setup-units.png d44c085c61a1672c4487b36a4eac869cd5f345a784e744a91d7eb89f546c57fa sound.png defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-noroute.png bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png From f8e5d78d393fd0abd98a5b9087ab069e552348a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:53:50 +0000 Subject: [PATCH 10/37] onboarding: add the max heart rate and FTP step after Theme Setup now runs Hello, Language, Buttons, Units, Theme, Effort, then Home. SetupStep gets Effort (byte 7) and its place in ORDER, so the title bar counts to 5. The effort step (title "ZONES") lists Max heart rate and FTP with their committed values, then a last row. Select on a value row opens the drawer editor as a sheet over the page, the same editor the Ride settings page opens. The editor's Select commits the value and the settings save follows; its Back discards. Each value can stay "Not set". The last row reads Skip while neither limit is set, else Continue, and Select on it ends the step. Back on the page returns to the theme step and keeps the committed limits. Two ride tiles show a sample heart rate and power: plain without a limit, in their zone colour with a limit. A sheet over a setup page must keep setup's refusals. The chord refusal and the idle-return exemption now read the base screen, as the escape already does, so a sheet over a blocking or idle-exempt screen neither opens another drawer nor times the screen away. The copy-fit walk ticks once after each gesture, because a sheet a gesture pushes starts its open on the next tick, and steps 500 ms, past the 440 ms open. The gate seeds the effort step with both editors. The sweep gets setup-effort in four languages, setup-effort-editor and setup-effort-set. The other setup frames change only in the counter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 2 + firmware/obc-app/i18n/en.toml | 3 + firmware/obc-app/i18n/es.toml | 2 + firmware/obc-app/i18n/fr.toml | 2 + firmware/obc-app/src/app.rs | 61 ++++++++++++-- firmware/obc-app/src/harness/copy_fit.rs | 9 +- firmware/obc-app/src/screen/mod.rs | 7 +- firmware/obc-app/src/screen/setup.rs | 100 ++++++++++++++++++++--- firmware/obc-app/src/settings.rs | 11 ++- firmware/obc-app/src/ui_runtime.rs | 7 +- firmware/ui-frames.toml | 33 ++++++++ firmware/ui-snapshots.sha256 | 54 ++++++------ 12 files changed, 242 insertions(+), 49 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index 417ed6c0b..b92a2e6ba 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -538,6 +538,8 @@ menu_anywhere = "Menü, von überall" continue = "Weiter" units = "EINHEITEN" theme = "FARBSCHEMA" +effort = "ZONEN" +skip = "Überspringen" # --- Wiederhergestellte Fahrt beim Start --- [ride_recovery] diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index d3081a224..79eb7f1a3 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -575,6 +575,9 @@ continue = "Continue" # The titles of the units and theme steps. units = "UNITS" theme = "THEME" +# The effort step: its title, and its last row while neither limit is set. +effort = "ZONES" +skip = "Skip" # --- Boot-time recovered ride --- [ride_recovery] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 98c5ab943..b86228a15 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -537,6 +537,8 @@ menu_anywhere = "El menú, siempre" continue = "Continuar" units = "UNIDADES" theme = "TEMA" +effort = "ZONAS" +skip = "Omitir" # --- Salida recuperada al arrancar --- [ride_recovery] diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 490ebade6..98e71ad3e 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -537,6 +537,8 @@ menu_anywhere = "Le menu, partout" continue = "Continuer" units = "UNITÉS" theme = "THÈME" +effort = "ZONES" +skip = "Passer" # --- Sortie récupérée au démarrage --- [ride_recovery] diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 4e5d76cae..c4bd52cdc 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -2140,9 +2140,10 @@ impl App { /// It is resolved here, not in a screen, because the recogniser already swallowed the /// chord's constituents and the sheet must open over whatever the rider is on. Two rules /// live here only: a modal that declares [`Caps::blocks_chords`](crate::screen::Caps) stops - /// every chord, and one drawer is open at a time, so the same chord again closes it. + /// every chord, also under a sheet it opened, and one drawer is open at a time, so the same + /// chord again closes it. pub fn apply_chord(&mut self, chord: Chord) -> bool { - if self.ui.stack.last().is_some_and(|s| s.caps().blocks_chords) { + if screen::base_screen(&self.ui.stack).is_some_and(|s| s.caps().blocks_chords) { return false; } // The powering-off frame refuses a squeeze. It cannot be said in `Caps`, because the @@ -6132,7 +6133,8 @@ mod tests { /// The units and theme steps commit on Select only. The theme step draws the frame in the /// theme under its cursor, and that preview is never saved: a step leaves the setting alone, - /// and Back returns to the units step in the committed theme. Select on a theme ends setup. + /// and Back returns to the units step in the committed theme. Select on a theme opens the effort + /// step. #[test] fn the_units_and_theme_steps_commit_on_select_and_the_theme_preview_saves_nothing() { use crate::settings::{SetupStep, Theme, Units}; @@ -6158,10 +6160,57 @@ mod tests { app.apply_gesture(Gesture::Press); app.apply_gesture(Gesture::Step(1)); app.apply_gesture(Gesture::Press); - assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)])); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupEffort(_)])); let s = app.settings(); - assert_eq!((s.setup, s.units, s.theme), (SetupStep::Done, Units::Imperial, Theme::Dark)); - assert!(host.drain(&mut app).is_some(), "the finished setup is saved"); + assert_eq!((s.setup, s.units, s.theme), (SetupStep::Effort, Units::Imperial, Theme::Dark)); + assert!(host.drain(&mut app).is_some(), "the step is saved"); + } + + /// The effort step edits each limit in the drawer editor over the page: the editor's Select + /// commits and saves the value, and its Back discards. The sheet keeps setup's refusals of the + /// chords, the escape and the idle return. Back on the page returns to the theme step with the + /// committed limits kept, and the last row ends setup. + #[test] + fn the_effort_step_edits_its_limits_in_the_drawer_editor() { + use crate::input::Chord; + use crate::settings::SetupStep; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + let factory = Settings { setup: SetupStep::Effort, idle_return: IdleReturn::S15, ..Settings::FACTORY }; + app.set_settings(factory); + let mut host = SettingsHost::default(); + + app.apply_gesture(Gesture::Press); + let editing = |app: &App| { + matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupEffort(_), Screen::ContextDrawer(_)]) + }; + assert!(editing(&app), "Select on Max heart rate opens its editor"); + assert!(!app.apply_chord(Chord::Quick)); + app.apply_gesture(Gesture::BackHold); + idle_tick(&mut app, 60_000); + idle_tick(&mut app, 120_000); + assert!(editing(&app), "the sheet keeps setup's refusals"); + app.apply_gesture(Gesture::Step(1)); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupEffort(_)])); + assert_eq!(app.settings().max_hr, 181, "an unset limit opens on 180 bpm"); + let revision = host.drain(&mut app).expect("the committed limit is saved"); + host.ack(&mut app, revision); + + for g in [Gesture::Step(1), Gesture::Press, Gesture::Step(3), Gesture::Back] { + app.apply_gesture(g); + } + assert_eq!((app.settings().ftp_w, host.drain(&mut app)), (0, None), "the editor's Back discards"); + + app.apply_gesture(Gesture::Back); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupTheme(_)])); + assert_eq!((app.settings().setup, app.settings().max_hr), (SetupStep::Theme, 181)); + + app.apply_gesture(Gesture::Press); + app.apply_gesture(Gesture::Step(-1)); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)]), "the last row ends setup"); + let s = app.settings(); + assert_eq!((s.setup, s.max_hr, s.ftp_w), (SetupStep::Done, 181, 0)); } /// A cancel posted while the plan request is still undrained annihilates it: the rider's net diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 8839f2b0a..d6e364ca7 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -194,6 +194,9 @@ fn seeds(language: Language) -> Vec { // The button lesson changes its foot hint once all four buttons are pressed. let lesson = vec![Gesture::Step(-1), Gesture::Step(1), Gesture::Back, Gesture::Press]; v.push((Screen::SetupButtons(SetupButtonsScreen::default()), lesson)); + // The effort step's last row reads Skip until a limit is set, and each limit opens its editor. + let effort = vec![Gesture::Press, Gesture::Press, Gesture::Step(1), Gesture::Press]; + v.push((Screen::SetupEffort(SetupEffortScreen::default()), effort)); // The About page is taller than the panel, so the lines under the fold are drawn only after it // scrolls. One step per line reaches every one of them; the offset clamps at the end. v.push((Screen::About(AboutScreen::new()), vec![Gesture::Step(1); 24])); @@ -268,6 +271,8 @@ fn walk( if step > 0 { app.advance_animations(InputClock(now)); app.apply_gesture(gestures[step - 1]); + // A sheet the gesture pushed starts its open on the first tick after it. + app.advance_animations(InputClock(now)); now += SLIDE_STEP_MS; app.advance_animations(InputClock(now)); } @@ -291,8 +296,8 @@ fn walk( drawn } -/// Longer than any sheet slide, so the page a gesture opened has landed before the next one. -const SLIDE_STEP_MS: u32 = 400; +/// Longer than any sheet open or slide, so the page a gesture opened has landed before it is drawn. +const SLIDE_STEP_MS: u32 = 500; /// Why `drawn` does not fit, if it does not. fn complaint(name: &str, language: Language, drawn: &obc_render::text_tap::TextDraw) -> Option { diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index 442f25a27..ce5af71f2 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -117,7 +117,9 @@ pub use settings::{ AboutScreen, AddFieldScreen, LanguageScreen, ResetScreen, SensorScanScreen, SensorsScreen, SettingsPage, StatFieldsScreen, }; -pub use setup::{HelloScreen, SetupButtonsScreen, SetupLanguageScreen, SetupThemeScreen, SetupUnitsScreen}; +pub use setup::{ + HelloScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupThemeScreen, SetupUnitsScreen, +}; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; pub use trip_delete::TripDeleteScreen; @@ -918,6 +920,9 @@ screens! { SetupUnits(SetupUnitsScreen) => Caps::modal().blocking(), /// Setup's theme step: Light or Dark. The frame draws in the theme under its cursor. SetupTheme(SetupThemeScreen) => Caps::modal().blocking(), + /// Setup's effort step: max heart rate and FTP, each edited in the drawer editor over the page, + /// then a row that continues. + SetupEffort(SetupEffortScreen) => Caps::modal().blocking(), /// The card after Finish on a trip day: today's ledger, then tomorrow's day or the trip's /// totals. OK returns Home. DayDone(DayDoneScreen) => Caps::modal(), diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index 72305afdd..7e352f742 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -10,24 +10,29 @@ use core::fmt::Write; -use embedded_graphics::prelude::Point; +use embedded_graphics::{prelude::Point, primitives::Rectangle}; use obc_render::{ rect, text::{text_width, Font, TextAlign}, Surface, }; +use crate::effort::Metric; use crate::i18n::t; use crate::input::Gesture; use crate::settings::{Language, Settings, SetupStep, Theme, Units}; use crate::Msg; +use super::context_drawer::{ContextDrawerScreen, ContextValue}; use super::home::contours; use super::settings::LanguageScreen; use super::vocab::chrome::{copy_w, title_frame, wrapped, LIST_TOP}; use super::vocab::flags::{FLAG_H, FLAG_W}; -use super::vocab::rows::{choice_row, nav_row, row_rect, row_tick, Line2, ROW_GAP, ROW_ONE, ROW_TWO, ROW_X}; -use super::vocab::tiles::tile; +use super::vocab::list::step_selection; +use super::vocab::rows::{ + action_row, choice_row, nav_row, row_rect, row_tick, Line2, ROW_GAP, ROW_ONE, ROW_TWO, ROW_X, +}; +use super::vocab::tiles::{tile, zone_tile}; use super::{palette, Ctx, Render, Screen, Transition}; /// The screen of the current setup step, or `None` once setup is done. @@ -38,6 +43,7 @@ pub(crate) fn screen(s: &Settings) -> Option { SetupStep::Buttons => Some(Screen::SetupButtons(SetupButtonsScreen::default())), SetupStep::Units => Some(Screen::SetupUnits(SetupUnitsScreen(s.units))), SetupStep::Theme => Some(Screen::SetupTheme(SetupThemeScreen(s.theme))), + SetupStep::Effort => Some(Screen::SetupEffort(SetupEffortScreen::default())), SetupStep::Done => None, } } @@ -341,25 +347,97 @@ fn swatch(cv: &mut impl Surface, x: i32, y: i32, theme: Theme) { cv.fill(rect(x + 4, y + 7, FLAG_W - 12, 2), ink); } +/// The effort step: the two limits the effort zones are cut from, and a row that continues. A +/// value row opens the drawer editor as a sheet over the page, as on the Ride settings page, and +/// the editor's Select commits and saves the value. The last row reads Skip while neither limit +/// is set. The ride tiles below show the zones a limit gives, and a plain tile without one. +#[derive(Debug, Default)] +pub struct SetupEffortScreen { + selected: usize, +} + +/// The value rows, then the continue row at `LIMITS.len()`. +const LIMITS: [(Msg, ContextValue); 2] = [(Msg::RideMaxHr, ContextValue::MaxHr), (Msg::RideFtp, ContextValue::Ftp)]; + +/// The sample efforts the preview shows, in bpm and watts. +const SAMPLE_EFFORT: [(Metric, u32); 2] = [(Metric::Hr, 152), (Metric::Power, 210)]; + +impl SetupEffortScreen { + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + match g { + Gesture::Step(n) => { + self.selected = step_selection(self.selected, n, LIMITS.len() + 1); + Transition::None + } + Gesture::Press => match LIMITS.get(self.selected) { + Some(&(label, value)) => Transition::Push(Screen::ContextDrawer(ContextDrawerScreen::editor( + value, + label, + &cx.context_facts(), + ))), + None => finish(SetupStep::Effort, cx), + }, + Gesture::Back => back(SetupStep::Effort, cx), + Gesture::Hold | Gesture::BackHold => Transition::None, + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + use palette::*; + let (w, h) = (rx.w, rx.h); + title_bar(cv, w, h, SetupStep::Effort, rx.t(Msg::SetupEffort)); + let facts = rx.context_facts(); + let mut y = LIST_TOP; + for (i, (label, value)) in LIMITS.into_iter().enumerate() { + let mut buf = heapless::String::<24>::new(); + let text = value.choice_label(value.committed(&facts), rx, &mut buf); + nav_row(cv, row_rect(y, w, ROW_TWO), rx.t(label), Some(Line2::text(text)), i == self.selected, true, true); + y += ROW_TWO + ROW_GAP; + } + let unset = rx.settings.max_hr == 0 && rx.settings.ftp_w == 0; + let go = rx.t(if unset { Msg::SetupSkip } else { Msg::SetupContinue }); + action_row(cv, row_rect(y, w, ROW_ONE), go, None, self.selected == LIMITS.len(), true, false, 0.0); + + let limits = rx.settings.effort_limits(); + for (i, (metric, value)) in SAMPLE_EFFORT.into_iter().enumerate() { + let caption = rx.t(if metric == Metric::Hr { Msg::TileHr } else { Msg::TilePwr }); + let mut text = heapless::String::<8>::new(); + let _ = write!(text, "{value}"); + let area = preview_tile(rx, i); + match metric.limit(limits) { + Some(limit) => zone_tile(cv, area, caption, &text, metric.zone_of(value, limit)), + None => { + tile(cv, area, &rx.marquee, caption, &text, false, TextAlign::Left, PARCHMENT_SHADE, SUBTEXT, INK) + } + } + } + hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(rx.t(Msg::SetupOk))); + } +} + /// The sample ride the preview shows: a speed in km/h and a distance in km. const SAMPLE: (f32, f32) = (24.5, 86.4); -/// Two ride tiles over the [`hint`], so a step shows what it changes on the ride screens. They sit -/// at one place on every step, so a step changes them in place. +/// Two ride tiles over the [`hint`], so a step shows what it changes on the ride screens. fn ride_preview(cv: &mut impl Surface, rx: &Render, units: Units) { use palette::*; - let (gap, tile_h) = (6, 54); - let tile_w = (rx.w - 2 * ROW_X - gap) / 2; - let y = rx.h - 8 - HINT_H - 12 - tile_h; let (mut speed, mut dist) = (heapless::String::<8>::new(), heapless::String::<8>::new()); let _ = write!(speed, "{:.1}", units.speed(SAMPLE.0)); let _ = write!(dist, "{:.1}", units.dist(SAMPLE.1)); for (i, (caption, value)) in [(units.speed_label(), speed), (units.dist_label(), dist)].into_iter().enumerate() { - let area = rect(ROW_X + i as i32 * (tile_w + gap), y, tile_w, tile_h); + let area = preview_tile(rx, i); tile(cv, area, &rx.marquee, caption, &value, false, TextAlign::Left, PARCHMENT_SHADE, SUBTEXT, INK); } } +/// The left (`i` 0) or right preview tile over the [`hint`]. The tiles sit at one place on every +/// step, so a step changes them in place. +fn preview_tile(rx: &Render, i: usize) -> Rectangle { + let (gap, tile_h) = (6, 54); + let tile_w = (rx.w - 2 * ROW_X - gap) / 2; + rect(ROW_X + i as i32 * (tile_w + gap), rx.h - 8 - HINT_H - 12 - tile_h, tile_w, tile_h) +} + /// The rider's place among the titled steps, as `(n, of)`. Hello, the first step, has no title /// bar, so the count starts at the step after it. fn place(step: SetupStep) -> (usize, usize) { @@ -440,7 +518,7 @@ mod tests { /// The count starts at the first titled step and ends at the last step. #[test] fn the_title_bar_counts_the_titled_steps() { - let places = [SetupStep::Language, SetupStep::Buttons, SetupStep::Units, SetupStep::Theme].map(place); - assert_eq!(places, [(1, 4), (2, 4), (3, 4), (4, 4)]); + let places = [SetupStep::Language, SetupStep::Units, SetupStep::Effort].map(place); + assert_eq!(places, [(1, 5), (3, 5), (5, 5)]); } } diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index 76565687e..5087368b8 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -216,13 +216,20 @@ pub enum SetupStep { Buttons = 3, Units = 4, Theme = 5, + Effort = 7, } impl SetupStep { /// The steps in the order the rider meets them. The byte values are persisted, so a new step /// takes a new value and its place here. - pub(crate) const ORDER: [SetupStep; 5] = - [SetupStep::Hello, SetupStep::Language, SetupStep::Buttons, SetupStep::Units, SetupStep::Theme]; + pub(crate) const ORDER: [SetupStep; 6] = [ + SetupStep::Hello, + SetupStep::Language, + SetupStep::Buttons, + SetupStep::Units, + SetupStep::Theme, + SetupStep::Effort, + ]; fn index(self) -> Option { Self::ORDER.iter().position(|&s| s == self) diff --git a/firmware/obc-app/src/ui_runtime.rs b/firmware/obc-app/src/ui_runtime.rs index 2cd282d19..8d8cf7d93 100644 --- a/firmware/obc-app/src/ui_runtime.rs +++ b/firmware/obc-app/src/ui_runtime.rs @@ -511,12 +511,13 @@ impl UiRuntime { } } - /// Whether the top screen is exempt from the idle-return timeout: the modal cards that stay + /// Whether the base screen is exempt from the idle-return timeout: the modal cards that stay /// put until dismissed, the route-planning spinner, and the SD-sideload update flow. It reads /// the declared [`idle_exempt`](crate::screen::Caps::idle_exempt) capability, so a new modal - /// card cannot be forgotten here. + /// card cannot be forgotten here. The base answers, because a sheet over a card is not + /// consent to take the card away. fn idle_return_exempt(&self) -> bool { - self.stack.last().is_some_and(|s| s.caps().idle_exempt) + crate::screen::base_screen(&self.stack).is_some_and(|s| s.caps().idle_exempt) } /// Whether the top screen is a deliberate ride view that must never time out while a ride is diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 092df8e0a..df61e3ea4 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1887,6 +1887,39 @@ script = "p p u d b p p p d" expect = "SetupTheme" args = ["--fresh"] +# Setup's effort step after the theme step: max heart rate and FTP unset, the last row Skip, and +# the plain ride tiles. +[[frame]] +name = "setup-effort" +map = "{grimsel}" +script = "p p u d b p p p p" +expect = "SetupEffort" +args = ["--fresh"] + +[[frame]] +name = "setup-effort" +map = "{grimsel}" +script = "p p u d b p p p p" +expect = "SetupEffort" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +# Select on Max heart rate: the drawer editor rises over the page on 180 bpm. +[[frame]] +name = "setup-effort-editor" +map = "{grimsel}" +script = "p p u d b p p p p p w" +expect = "ContextDrawer" +args = ["--fresh"] + +# Both limits set, 185 bpm and 250 W, and the cursor on Continue: the tiles take their zones. +[[frame]] +name = "setup-effort-set" +map = "{grimsel}" +script = "p p u d b p p p p p d*5 p d p d*10 p d w" +expect = "SetupEffort" +args = ["--fresh"] + # Route-upload popups (#451), all three variants. `--inject upload[-replace]=ID` raises the upload # event after the script, exactly as the control panel's inject buttons do. specs/vectors holds # two routes: id 0 = route-plain, id 1 = route-waypoints (filename order). diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index 597095141..8a8beabde 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -306,31 +306,37 @@ e45d66a5ad980acf5ec2891d0ae89b517466fae60b1caf61b814f988be56a65d settings-es.pn 49286bcd4069b29c6094ebeadb8b35dc3cf2b3c1fc6dc70da98ab5cd7be810ac settings-fr.png 460cddf4695830c5cdececc764d507b4ee2a13fb3660dd66ca6ef20dedb84f37 settings-ride-waypoints.png c1a9b35839d97d9a06da9885587cc2f8b74dff61561d93b5fac55088148f94e5 settings.png -a9461474f77750513cd66ec6a86a2963e26a03785278c13592f9f11285b4bb3b setup-buttons-de.png -79c78445d53c2c084636f5407bb5f3424e765ac1447326396f2a000bb22b5666 setup-buttons-done-de.png -6bfd684117f1e4a4b2d808b97721109a10ff4933c706a4ca34c86a80c01e9a93 setup-buttons-done-es.png -62f28bf2832429d22170727d6eacb7bd2b0082df311935381a723c5c91e8a818 setup-buttons-done-fr.png -535c61344f3bf9b555c0f437ac5e1cd10f184119b9e85b858e4e57988f05846a setup-buttons-done.png -d3525572fe2f7dadab0dec8e8c40f3cb9e34db2cb2580c9cadfbfd49906345b5 setup-buttons-es.png -85499c5f3fd1df9c0a934c486192a46b2f8988ecc8ba4321aa5eb4c9c698ffe1 setup-buttons-fr.png -0c371e339b7b58f3008488ffe88c507820149b9fae299f262824290057fea012 setup-buttons-partial.png -211550a1c1c7eb1a71675af850949ea1952cdf27f46a3f07948fb18a5f7d9e35 setup-buttons.png +67accee000324b574fb6d5465ed99461ebf52236636cf44db85119dd1ecd2585 setup-buttons-de.png +bedd16773d199eb5bfa4fb769702ebbc7b5d9bd7b79c85b138c504d4eee8abdd setup-buttons-done-de.png +da310937b0a77ac7807f990170bfbf12f09206804c2eb4b061a763c3aec1f98e setup-buttons-done-es.png +e12ab67cb6d4625817fa61b09329fe80e089341c4d6a42c71c91dd2e3e242f2a setup-buttons-done-fr.png +994c73b78a648f8c3fbee2633344b48ff2a3fc99c06eb3f030b50a5eedf2eb31 setup-buttons-done.png +4efa444d3119fde015aad7852546293b5aa6a59ddb20a9d6dccbb8162b980f1d setup-buttons-es.png +b6e241b477c200103d04c42913ef09f83556e6e2ad360a650e5092f054f46654 setup-buttons-fr.png +54a0be240214ab344bd8751b4e563265a2cf42a0021662a078ec454ab3e53c2b setup-buttons-partial.png +df19f049ebd4f120bea3b6d13e7ffb668eb9350e6e054c4fe42241a925a11085 setup-buttons.png +0fdbe08ac8f534ded33a680d98a3eb4f209ff8e1fc269a40eb939387d6ac88bc setup-effort-de.png +89ebca912f026394adfea0b50fe90aac315770678fca63f6a0f97e26c13fb687 setup-effort-editor.png +c4fd1159ade6cb820db6e69c1c95bf81e37596000172d3642ccb2d6241e246cb setup-effort-es.png +894e02223d436cde787aa1d5fc29ac40974dc69902553a2a5b8286c1b2b2cee5 setup-effort-fr.png +fb0a649ade762823e2c603741f4fdd21735e0f9b46bda1f719a28f8a2d43bfbf setup-effort-set.png +8b29e430ae907ba8d69d0a0ad7c9c27adc45853199c31074345100d8cad95189 setup-effort.png fa7017db43b71eb7675c622608c8ac99e93e86a9e26ec39567091b1aa0d5450b setup-hello.png -a8ae5e32dba655379a35c205e1c977efe553e18fda5c54fb1d04004badb2b1d1 setup-language-cursor.png -fa6256ea1c4c57b6c6d62e9802d961550a7f0ace9ee499a4ac0fc159d24af30e setup-language-de.png -f6c37c28f127a0a34e959b71af2e3b423081c4e7ef18eb01b68a70fe2f60ab33 setup-language-es.png -797ef3a19f1b07d7167d7ed44bb03ec2fa90bcb5fdcea89df08afb49046f5d5d setup-language-fr.png -34be58f6976d3afcdd13c7c1c600a0b24d26b16371a43de6f837aea97c6179e6 setup-language.png -2f9157dd9307bac4a97a52525823164c961c11d812eef3a6f2c1ef0680113bc1 setup-theme-dark.png -23c9e0a42d62459c3703c7e2f16376e6d7d930377b9678e665d0fb74f665a624 setup-theme-de.png -5dcf99aef472a1eb17ec20fd7f2e25709315941ad15832b084f1497864ab64f1 setup-theme-es.png -670a61e5b408dcd1a677749cc3d2fd1cdb838857000c8a99a03ba8414c91e4dc setup-theme-fr.png -27de869071accbbeea8042d46f72fa5c591e9c567d357c9da9f905162411b154 setup-theme.png -d054a427e4aa8f06de3ed757328aa50559ec6119e4e492c836755d8520759aab setup-units-de.png -615582d525d4e6d1b6999bc258438954c852d72b0fdc596a3f97f60265e90a01 setup-units-es.png -322b2158b03f637c172eee0648f9216db94ec0344b408bf027b739fc1e4f08cf setup-units-fr.png -33beb01eecbbfa3ac1f693879e55ee5aca39dec2ea409ca46c2dc5420ee972e7 setup-units-imperial.png -08de1fb91d37bdf70733de7352aa0d291fb8a859acb1eeee21d3e40aa2a69099 setup-units.png +f0b3f91678f3de8602cab8f9dcc8e945ed3d0bcf8cb8c4d58a45c8f027e0c6e0 setup-language-cursor.png +bf97929217a4cde32e31911521c9cb779e006be7215dd41da40b35050f033034 setup-language-de.png +73514b869111e5506ea9079622f0a6a1c46f67e4f0bcacba48d6e98ccb1ca13c setup-language-es.png +a6c3e8874817c4f156dee42a3b56aafde42331b178865d8dc0fe054efe7de600 setup-language-fr.png +9a5c7e7f5c2b0c298d1932dacce407e63139ba6fb55d4d73d160af2c033a98cc setup-language.png +44da4201ee56d8e1d462a6827ebd915244ed0cf85d1def5549767a2f370716e6 setup-theme-dark.png +b097e2d89d897ac01f043baca540483011394ea57d6ccd53185f87847b383714 setup-theme-de.png +71dc77e378a30bf2d1f1e9b363a6a60650fc16e0963b4bd99f590958edad1cab setup-theme-es.png +72c104960cab02bdbe2bae4d898c6c195dde1f062c58f19e3aad1aede73f4e1e setup-theme-fr.png +514c521b22a68103e942b599dc0e62c14e3dfc907156c89ed2148050b7b545ae setup-theme.png +02a5137a51c453d07ea97022d6af904222ea9bea620140e9edb9be48206a59f9 setup-units-de.png +9041396ba236c633309a782d41dad4aa6a7f8bc021ecaffffc9b7bd1f9e71a99 setup-units-es.png +482b1b0cf92e13d59af9b794e23f765ab8faa25162e5507e215f1a7e8efa3531 setup-units-fr.png +ae213d61136c91ea4c338f3b0a86432a9f01ac5384fef42f0e2c7e9d01204433 setup-units-imperial.png +c4cf28cc2da3b1528eb4cefd3fe9477601c1d48e2ae6e15c4e7209b209ae9e24 setup-units.png d44c085c61a1672c4487b36a4eac869cd5f345a784e744a91d7eb89f546c57fa sound.png defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-noroute.png bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png From f54582d551162507f59b0723c6bdf227edf6ccb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:54:47 +0000 Subject: [PATCH 11/37] setup: scan list in the setup chrome, sensors cursor on Continue The setup scan list draws the Settings scan list body under the step's title bar and over its hint, through a thin wrapper. The Settings scan list is unchanged. The sensors step opens its cursor on Continue when a sensor is already saved. Sensors and Effort share one Skip/Continue row without a chevron. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/src/app.rs | 3 +- firmware/obc-app/src/harness/copy_fit.rs | 2 +- firmware/obc-app/src/screen/mod.rs | 10 +-- .../obc-app/src/screen/settings/sensors.rs | 7 +- firmware/obc-app/src/screen/setup.rs | 67 ++++++++++++++++--- firmware/ui-frames.toml | 6 +- firmware/ui-snapshots.sha256 | 18 ++--- 7 files changed, 81 insertions(+), 32 deletions(-) diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index d890783fa..42debf140 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -6200,11 +6200,10 @@ mod tests { assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupTheme(_)])); assert_eq!(app.settings().setup, SetupStep::Theme); app.apply_gesture(Gesture::Press); - app.apply_gesture(Gesture::Step(-1)); app.apply_gesture(Gesture::Press); assert!( matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupEffort(_)]), - "Up from the first slot reaches Continue" + "with a sensor saved, the step opens on Continue" ); assert_eq!(app.settings().setup, SetupStep::Effort); assert!(app.settings().saved_sensors[1].present, "the added sensor stays"); diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index ac5727c2b..521544a10 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -121,7 +121,7 @@ fn seeds(language: Language) -> Vec { Screen::SetupUnits(SetupUnitsScreen(crate::settings::Units::Metric)), Screen::SetupTheme(SetupThemeScreen(crate::settings::Theme::Light)), Screen::SetupSensors(SetupSensorsScreen::default()), - Screen::SetupSensorScan(SensorScanScreen::new(1)), + Screen::SetupSensorScan(SetupSensorScanScreen::new(1)), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Receiving { received_kib: 1_024, total_kib: 65_536 })), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Installed)), Screen::Settings(SettingsPage::hub()), diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index cbbe6e514..f91a52c45 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -118,8 +118,8 @@ pub use settings::{ StatFieldsScreen, }; pub use setup::{ - HelloScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupSensorsScreen, SetupThemeScreen, - SetupUnitsScreen, + HelloScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupSensorScanScreen, SetupSensorsScreen, + SetupThemeScreen, SetupUnitsScreen, }; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; @@ -923,9 +923,9 @@ screens! { SetupTheme(SetupThemeScreen) => Caps::modal().blocking(), /// Setup's sensors step: the three sensor slots with their live status, then Skip or Continue. SetupSensors(SetupSensorsScreen) => Caps::modal().blocking().key(RenderKeyKind::SensorSettings), - /// The Settings scan list for one slot, opened from the sensors step. It blocks the escape, - /// because the step it returns to does. - SetupSensorScan(SensorScanScreen) => Caps::modal().blocking().key(RenderKeyKind::SensorSettings), + /// The Settings scan list for one slot in the setup chrome, opened from the sensors step. It + /// blocks the escape, because the step it returns to does. + SetupSensorScan(SetupSensorScanScreen) => Caps::modal().blocking().key(RenderKeyKind::SensorSettings), /// Setup's effort step: max heart rate and FTP, each edited in the drawer editor over the page, /// then a row that continues. SetupEffort(SetupEffortScreen) => Caps::modal().blocking(), diff --git a/firmware/obc-app/src/screen/settings/sensors.rs b/firmware/obc-app/src/screen/settings/sensors.rs index cdd0ab6fc..714c07f3a 100644 --- a/firmware/obc-app/src/screen/settings/sensors.rs +++ b/firmware/obc-app/src/screen/settings/sensors.rs @@ -119,7 +119,7 @@ pub(crate) fn status_line(buf: &mut heapless::String<24>, present: bool, status: #[derive(Debug)] pub struct SensorScanScreen { /// The kind being paired: 0 heart rate, 1 power, 2 cadence. It filters the scan hits. - slot: u8, + pub(crate) slot: u8, selected: usize, } @@ -171,7 +171,12 @@ impl SensorScanScreen { pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { let (w, h) = (rx.w, rx.h); title_frame(cv, w, h, rx.t(kind_msg(self.slot as usize)), ""); + self.draw_list(cv, rx); + } + /// The hits under the title bar, or the scanning state while there is none. + pub(crate) fn draw_list(&self, cv: &mut impl Surface, rx: &Render) { + let (w, h) = (rx.w, rx.h); let len = self.count(rx.sensor_scan_hits); if len == 0 { empty_state(cv, w, h, rx.t(Msg::SensorsScanning), rx.t(wake_msg(self.slot as usize))); diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index 4bf2ab6f6..50864fed3 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -43,7 +43,7 @@ pub(crate) fn screen(s: &Settings) -> Option { SetupStep::Buttons => Some(Screen::SetupButtons(SetupButtonsScreen::default())), SetupStep::Units => Some(Screen::SetupUnits(SetupUnitsScreen(s.units))), SetupStep::Theme => Some(Screen::SetupTheme(SetupThemeScreen(s.theme))), - SetupStep::Sensors => Some(Screen::SetupSensors(SetupSensorsScreen::default())), + SetupStep::Sensors => Some(Screen::SetupSensors(SetupSensorsScreen::new(s))), SetupStep::Effort => Some(Screen::SetupEffort(SetupEffortScreen::default())), SetupStep::Done => None, } @@ -335,9 +335,9 @@ impl SetupThemeScreen { } /// The sensors step: the three slots of Settings' Sensors page over Skip, which reads Continue -/// once a sensor is saved. Select on a slot opens the Settings scan list for its kind, where a pick -/// saves the sensor and returns here. An empty slot says how to wake its sensor, and a saved one -/// shows its live status. +/// once a sensor is saved. Select on a slot opens the scan list for its kind, where a pick saves +/// the sensor and returns here. An empty slot says how to wake its sensor, and a saved one shows +/// its live status. #[derive(Debug, Default)] pub struct SetupSensorsScreen { /// A slot, or [`SKIP`]. @@ -347,7 +347,16 @@ pub struct SetupSensorsScreen { /// The Skip row's index, after the slots. const SKIP: usize = SENSOR_SLOTS; +fn any_sensor_saved(s: &Settings) -> bool { + s.saved_sensors.iter().any(|s| s.present) +} + impl SetupSensorsScreen { + /// The cursor opens on Continue when a sensor is already saved, and on the first slot if not. + pub fn new(s: &Settings) -> Self { + SetupSensorsScreen { selected: if any_sensor_saved(s) { SKIP } else { 0 } } + } + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { match g { Gesture::Step(n) => on_step(&mut self.selected, n, SKIP + 1), @@ -355,7 +364,7 @@ impl SetupSensorsScreen { // Scan mode makes the host run a discovery scan. The scan list lowers it on exit. Gesture::Press => { cx.activity.request_sensor_scan(true); - Transition::Push(Screen::SetupSensorScan(SensorScanScreen::new(self.selected as u8))) + Transition::Push(Screen::SetupSensorScan(SetupSensorScanScreen::new(self.selected as u8))) } Gesture::Back => back(SetupStep::Sensors, cx), Gesture::Hold | Gesture::BackHold => Transition::None, @@ -377,9 +386,30 @@ impl SetupSensorsScreen { } nav_row(cv, area, rx.t(kind_msg(slot)), Some(Line2::text(&line)), slot == self.selected, true, false); } - let label = if saved.iter().any(|s| s.present) { Msg::SetupContinue } else { Msg::SetupSkip }; - let area = row_rect(h - 8 - HINT_H - 12 - ROW_ONE, w, ROW_ONE); - nav_row(cv, area, rx.t(label), None, self.selected == SKIP, true, true); + let y = h - 8 - HINT_H - 12 - ROW_ONE; + continue_row(cv, rx, y, any_sensor_saved(rx.settings), self.selected == SKIP); + hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(rx.t(Msg::SetupOk))); + } +} + +/// The sensors step's scan list: the Settings scan list for one slot, under the step's title bar +/// and over its hint. +#[derive(Debug)] +pub struct SetupSensorScanScreen(SensorScanScreen); + +impl SetupSensorScanScreen { + pub fn new(slot: u8) -> Self { + SetupSensorScanScreen(SensorScanScreen::new(slot)) + } + + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + self.0.handle(g, cx) + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + let (w, h) = (rx.w, rx.h); + title_bar(cv, w, h, SetupStep::Sensors, rx.t(kind_msg(self.0.slot as usize))); + self.0.draw_list(cv, rx); hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(rx.t(Msg::SetupOk))); } } @@ -442,9 +472,8 @@ impl SetupEffortScreen { nav_row(cv, row_rect(y, w, ROW_TWO), rx.t(label), Some(Line2::text(text)), i == self.selected, true, true); y += ROW_TWO + ROW_GAP; } - let unset = rx.settings.max_hr == 0 && rx.settings.ftp_w == 0; - let go = rx.t(if unset { Msg::SetupSkip } else { Msg::SetupContinue }); - action_row(cv, row_rect(y, w, ROW_ONE), go, None, self.selected == LIMITS.len(), true, false, 0.0); + let set = rx.settings.max_hr != 0 || rx.settings.ftp_w != 0; + continue_row(cv, rx, y, set, self.selected == LIMITS.len()); let limits = rx.settings.effort_limits(); for (i, (metric, value)) in SAMPLE_EFFORT.into_iter().enumerate() { @@ -463,6 +492,13 @@ impl SetupEffortScreen { } } +/// A step's last row at `y`: Skip, or Continue once the step holds a `chosen` value. It acts, so it +/// has no chevron. +fn continue_row(cv: &mut impl Surface, rx: &Render, y: i32, chosen: bool, selected: bool) { + let label = rx.t(if chosen { Msg::SetupContinue } else { Msg::SetupSkip }); + action_row(cv, row_rect(y, rx.w, ROW_ONE), label, None, selected, true, false, 0.0); +} + /// The sample ride the preview shows: a speed in km/h and a distance in km. const SAMPLE: (f32, f32) = (24.5, 86.4); @@ -563,6 +599,15 @@ mod tests { assert_eq!(cx.settings.setup, SetupStep::Language); } + /// The cursor opens on the first slot, and on Continue once a sensor is saved. + #[test] + fn the_sensors_step_opens_on_continue_once_a_sensor_is_saved() { + let mut s = Settings::default(); + assert_eq!(SetupSensorsScreen::new(&s).selected, 0); + s.saved_sensors[1] = crate::settings::SavedSensor::saved(1, [1, 2, 3, 4, 5, 6]); + assert_eq!(SetupSensorsScreen::new(&s).selected, SKIP); + } + /// The count starts at the first titled step and ends at the last step. #[test] fn the_title_bar_counts_the_titled_steps() { diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 516e314f8..314f1a551 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1903,7 +1903,7 @@ expect = "SetupSensors" args = ["--fresh"] langs = ["de", "fr", "es"] -# Two sensors saved: the slots show their live status, and the last row reads Continue. +# Two sensors saved: the slots show their live status, and the cursor opens on Continue. [[frame]] name = "setup-sensors-added" map = "{grimsel}" @@ -1911,8 +1911,8 @@ script = "p p u d b p p p p" expect = "SetupSensors" args = ["--fresh", "--sensors", "screen"] -# Select on the power slot opens the Settings scan list for it. Before a hit, the empty list says -# how to wake a power meter. +# Select on the power slot opens the scan list for it, in the setup chrome. Before a hit, the empty +# list says how to wake a power meter. [[frame]] name = "setup-sensors-scanning" map = "{grimsel}" diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index f94800cd4..9697abd61 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -327,15 +327,15 @@ fa7017db43b71eb7675c622608c8ac99e93e86a9e26ec39567091b1aa0d5450b setup-hello.pn ffd3e0199cc4fb05dee1165b74738a9b51796b6933399efcd0f0790886a67e34 setup-language-es.png 70db7ee4772f781352d53db9d2481bf450f875d6eb0b0524a3ac7f718128a4c2 setup-language-fr.png 0f08be3822dc979a78d3765c29c2c78b0ff99970e6cdb42f1ddaafbd51f2d914 setup-language.png -4fcbf3b1438d32d51623009c150c69fefffa8f7ddefdcfa92abb474f43e3baca setup-sensors-added.png -a341b7e89a44915d7f4685383093601e2fb94941f566a0d425e29d9873e27227 setup-sensors-de.png -931a4daeb71e06609e4fedcd9b6fe8e43b08ba0cdffc3af3a57e87a747ce4e33 setup-sensors-es.png -58dfef1b80410366f67a1dbd08fb64f0b421588a119eb727e00606cba7e97fe6 setup-sensors-fr.png -d0ecce76581f88d2877327f45e9b115973a24319966c0bddc45780332c809635 setup-sensors-scanning-de.png -580bcd60218f029c98aadc392918d50af335f7565bacce3c0c33d214b54c745e setup-sensors-scanning-es.png -340ba4899b2f2d983e0750d3ae469392bc19e17da0033ce69694c661418faacf setup-sensors-scanning-fr.png -4b667fd900c929dde3c5179f86d98d098216a118ab7a457ec7bbaca8bd456d17 setup-sensors-scanning.png -933f67cb6742e65bb7acd6a750615be7bc928c68c99ccbab81a58e535cf991b5 setup-sensors.png +1010b7bd16df499e3e706ead9c7cda6c89270bd78c02abeeeb62967443b3e2f1 setup-sensors-added.png +0d2e8b34353904216f0f5c6712d387a63c70039977f045938689aafab07ecdf1 setup-sensors-de.png +c5d2f8f85c76ad7de278dfe647afa7657108073d4dba380543d618b502ed2a62 setup-sensors-es.png +dfcdab90ba41b3145efbff137202c0a9f4308e19e9ca820dc4a5bf695668a72b setup-sensors-fr.png +f9ba35c7a2a878e8553e6d5472aefe60c5c3b2f043c8cc2886245bf3d980a78a setup-sensors-scanning-de.png +069d6126b975973e76f56a9d74a074f32a23c502d3e1ea43a5cfc856548d98b2 setup-sensors-scanning-es.png +02eb988e43086787f17c7e31ba28e977064f10f393fd8f6e0ef01bb8fe5ff25f setup-sensors-scanning-fr.png +f937d3494802a578f33405e393b10cd7f6612ca4e7eafa060bb58d816327ef61 setup-sensors-scanning.png +b9106ee2bd58f95ea68b5fafa1f1c1df546c45b52e94cac830db9235ed0c9504 setup-sensors.png 03ef56ceb0579160ae3068a455efac145dbcab718bc1152e31ef4c4debb8bd5d setup-theme-dark.png 42a186068a743fa3a7d2268147d0d5a2ce22bb0285fdf2e8128463181a52645d setup-theme-de.png e0182caaf052908241602b9a0cd851f0391d356ef45535fbb1f2beb7b6c8796d setup-theme-es.png From 02afb392068968953331ea3d40a020305de2f0db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:32:48 +0000 Subject: [PATCH 12/37] onboarding: add the pairing code step and the skip-app page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup now runs Hello, Language, Buttons, Units, Theme, Qr, Sensors, Effort. SetupStep gets Qr (byte 8) after Theme, so the title bar counts out of 7. The pairing step shows the link of BLE spec §9.1 as a QR code: version 4, level M, one byte-mode segment, 5 px modules and a 4-module quiet zone, 205 x 205 px. The modules are dark on a light square in both themes. The qrcodegen-no-heap crate encodes it (MIT, no_std, no heap, no unsafe, no dependencies) into two 138 B buffers in the draw frame. The serial comes from App::set_serial: the board feeds the FICR device id that DIS serves, and the simulator feeds the spec's example serial. The board already advertises its factory name and accepts pairing while the bond slot is empty, and a factory reset clears a rename. A bond (set_ble_status with paired) ends the step, also under the passkey card. A bonded device passes the step by in both directions, because §9.3 shows the code only while the bond slot is empty. Back on the code opens "No app": what works offline (map, ride recording), what needs the app (routes, ride sync), a row back to the code and Skip. Skip ends the step. Back on that page walks on to the theme step, so the step before stays reachable. The page is screen state, not a persisted step: a power loss resumes on the code. Connections offers "Pair phone" while the radio is on and no phone is paired. It opens the same code, non-blocking and exempt from idle return. Back or a bond returns to Connections. A factory reset now also requests the Forget phone operation, so a reset device can pair again from setup. The setup hint names BACK as an outlined key beside the amber OK. The sweep gets setup-qr and setup-no-app in four languages, setup-qr-dark, connections-pair in four languages and pair-phone. The other setup frames change only in the counter, connections gains the row, and the later setup frames pass the pairing step through Skip. The copy-fit gate seeds the three new screens. App grows 8 B for the serial. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- Cargo.lock | 9 +- THIRD-PARTY.md | 2 +- apps/obc-sim/src/gui.rs | 1 + apps/obc-sim/src/main.rs | 5 + firmware/obc-app/Cargo.toml | 3 + firmware/obc-app/i18n/de.toml | 13 ++ firmware/obc-app/i18n/en.toml | 14 ++ firmware/obc-app/i18n/es.toml | 13 ++ firmware/obc-app/i18n/fr.toml | 13 ++ firmware/obc-app/src/app.rs | 120 +++++++++++- firmware/obc-app/src/harness/copy_fit.rs | 3 + firmware/obc-app/src/screen/mod.rs | 14 +- firmware/obc-app/src/screen/pair_code.rs | 123 ++++++++++++ firmware/obc-app/src/screen/settings/page.rs | 24 ++- firmware/obc-app/src/screen/settings/reset.rs | 24 ++- firmware/obc-app/src/screen/setup.rs | 182 +++++++++++++++--- firmware/obc-app/src/settings.rs | 4 +- firmware/obc-fw-nrf54l/Cargo.lock | 7 + firmware/obc-fw-nrf54l/src/link/identity.rs | 14 +- firmware/obc-fw-nrf54l/src/ride.rs | 1 + firmware/tools/resource_baseline.json | 8 +- firmware/ui-frames.toml | 85 ++++++-- firmware/ui-snapshots.sha256 | 86 +++++---- 23 files changed, 664 insertions(+), 104 deletions(-) create mode 100644 firmware/obc-app/src/screen/pair_code.rs diff --git a/Cargo.lock b/Cargo.lock index ceb618da5..b0d7d88ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2069,7 +2069,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -2753,6 +2753,7 @@ dependencies = [ "obc-render", "obc-route", "obcm-testkit", + "qrcodegen-no-heap", "serde_json", "sha2", ] @@ -3991,6 +3992,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qrcodegen-no-heap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0e2c0bf8be8a1c4a4f48973dabf26943f05da2bfc2d3180aae62409dbba6f0c" + [[package]] name = "quick-error" version = "2.0.1" diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index ce52d618c..df81854a8 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -2456,7 +2456,7 @@ SOFTWARE. ### MIT License -Applies to: `btuuid 0.1.1`, `cortex-m-rt-macros 0.7.5`, `cortex-m-rt 0.7.5`, `defmt-parser 1.0.0`, `defmt-rtt 1.2.0`, `defmt 0.3.100`, `embassy-embedded-hal 0.6.0`, `embassy-executor-macros 0.8.0`, `embassy-executor-timer-queue 0.1.0`, `embassy-executor 0.10.0`, `embassy-futures 0.1.2`, `embassy-hal-internal 0.4.0`, `embassy-hal-internal 0.5.0`, `embassy-net-driver-channel 0.4.0`, `embassy-net-driver 0.2.0`, `embassy-nrf 0.11.0`, `embassy-sync 0.8.0`, `embassy-time-driver 0.2.2`, `embassy-time-queue-utils 0.3.2`, `embassy-time 0.5.1`, `embassy-usb-driver 0.2.2`, `embassy-usb 0.6.0`, `embedded-graphics-core 0.4.1`, `libm 0.2.16`, `panic-probe 1.0.0`, `trouble-host-macros 0.5.0`, `trouble-host 0.7.0`, `void 1.0.2`, `windows-link 0.2.1`, `windows-result 0.4.1`, `windows-sys 0.61.2` +Applies to: `btuuid 0.1.1`, `cortex-m-rt-macros 0.7.5`, `cortex-m-rt 0.7.5`, `defmt-parser 1.0.0`, `defmt-rtt 1.2.0`, `defmt 0.3.100`, `embassy-embedded-hal 0.6.0`, `embassy-executor-macros 0.8.0`, `embassy-executor-timer-queue 0.1.0`, `embassy-executor 0.10.0`, `embassy-futures 0.1.2`, `embassy-hal-internal 0.4.0`, `embassy-hal-internal 0.5.0`, `embassy-net-driver-channel 0.4.0`, `embassy-net-driver 0.2.0`, `embassy-nrf 0.11.0`, `embassy-sync 0.8.0`, `embassy-time-driver 0.2.2`, `embassy-time-queue-utils 0.3.2`, `embassy-time 0.5.1`, `embassy-usb-driver 0.2.2`, `embassy-usb 0.6.0`, `embedded-graphics-core 0.4.1`, `libm 0.2.16`, `panic-probe 1.0.0`, `qrcodegen-no-heap 1.8.1`, `trouble-host-macros 0.5.0`, `trouble-host 0.7.0`, `void 1.0.2`, `windows-link 0.2.1`, `windows-result 0.4.1`, `windows-sys 0.61.2` ```text MIT License diff --git a/apps/obc-sim/src/gui.rs b/apps/obc-sim/src/gui.rs index cc46e84a6..187a07c18 100644 --- a/apps/obc-sim/src/gui.rs +++ b/apps/obc-sim/src/gui.rs @@ -387,6 +387,7 @@ impl SimGui { app.set_backlight_available(obc_ports::Backlight::available(&backlight)); let sounder = crate::sounder::SimSounder::open(!args.no_sound); app.set_sound_available(obc_ports::Sounder::available(&sounder)); + app.set_serial(crate::SIM_SERIAL); // The window draws into one resident device-64 plane and presents it by self-diff, as the // board does, so the frozen base's rows survive between frames. app.set_resident_frame(true); diff --git a/apps/obc-sim/src/main.rs b/apps/obc-sim/src/main.rs index 2c39effb4..235fb61af 100644 --- a/apps/obc-sim/src/main.rs +++ b/apps/obc-sim/src/main.rs @@ -39,6 +39,10 @@ use obc_host_core::{FlatRouteStore as RouteStore, FlatTripStore as TripStore, Ro use obc_replay::{gpx::Track, BaroSensor, GpxPlayer}; use obc_route::RouteReader; +/// The sim's factory serial, the board's FICR device id stand-in: the example serial of BLE spec +/// §9.1, so the pairing code a frame shows reads as the spec's example link. +const SIM_SERIAL: u64 = 0x0123_4567_89AB_CDEF; + #[derive(Clone, Copy, Default, PartialEq, Eq)] struct BleSeed { connected: bool, @@ -1340,6 +1344,7 @@ fn main() { app.set_backlight_available(!args.no_backlight); // The sounder capability, stated as the window states it, with no device opened. app.set_sound_available(!args.no_sound); + app.set_serial(SIM_SERIAL); // No `set_resident_frame` here: the headless host composes one frame into a buffer that // holds nothing, so every screen must be drawn, including a base a resident host would // leave standing under a sheet. diff --git a/firmware/obc-app/Cargo.toml b/firmware/obc-app/Cargo.toml index 0bb104e4b..dbb59487a 100644 --- a/firmware/obc-app/Cargo.toml +++ b/firmware/obc-app/Cargo.toml @@ -30,6 +30,9 @@ heapless = "0.8" # shim the renderer and route crates use. libm = { version = "0.2", default-features = false } # no arch asm: identical correctly-rounded results, keeps the suite Miri-runnable (the #501 alignment-UB tripwire) +# The QR code of the pairing link (BLE spec §9): no_std, no heap, no unsafe, no dependencies. +qrcodegen-no-heap = "1.8" + [features] # Host-only. It turns on the captured-fixture test targets and the small read-only accessors they # need; the device build never enables it and carries none of it. diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index e36d77f77..8f9810acc 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -286,6 +286,7 @@ title = "VERBINDUNG" phone = "Telefon" sensors = "Sensoren" not_paired = "Nicht gekoppelt" +pair = "Handy koppeln" connected = "verbunden" # --- The phone link: its status words and the Forget action --- @@ -384,6 +385,10 @@ title = "KOPPLUNG" enter_code = "Code eingeben" on_phone = "am Handy" +# --- The pairing code: the QR code of the pairing link --- +[pair] +scan = "Mit dem Handy scannen" + # --- Rides list screen --- [rides] title = "FAHRTEN" @@ -542,6 +547,14 @@ skip = "Überspringen" units = "EINHEITEN" theme = "FARBSCHEMA" effort = "ZONEN" +qr = "APP HOLEN" +no_app = "Ohne App?" +no_app_title = "OHNE APP" +offline = "Funktioniert offline" +needs_app = "Braucht die App" +recording = "Aufzeichnung" +ride_sync = "Fahrten-Sync" +show_code = "Code zeigen" # --- Wiederhergestellte Fahrt beim Start --- [ride_recovery] diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index e85de100c..c3a28d5b1 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -313,6 +313,7 @@ title = "CONNECTIONS" phone = "Phone" sensors = "Sensors" not_paired = "Not paired" +pair = "Pair phone" connected = "connected" # --- The phone link: its status words and the Forget action --- @@ -412,6 +413,10 @@ title = "PAIRING" enter_code = "Enter this code" on_phone = "on your phone" +# --- The pairing code: the QR code of the pairing link --- +[pair] +scan = "Scan with your phone" + # --- Rides list screen --- [rides] title = "RIDES" @@ -580,6 +585,15 @@ skip = "Skip" units = "UNITS" theme = "THEME" effort = "ZONES" +# The pairing step: its title, its Back hint, and the page that asks to ride without the app. +qr = "GET THE APP" +no_app = "No app?" +no_app_title = "NO APP" +offline = "Works offline" +needs_app = "Needs the app" +recording = "Ride recording" +ride_sync = "Ride sync" +show_code = "Show the code" # --- Boot-time recovered ride --- [ride_recovery] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 7babbb1ee..b311ce36a 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -285,6 +285,7 @@ title = "CONEXIONES" phone = "Teléfono" sensors = "Sensores" not_paired = "No vinculado" +pair = "Vincular móvil" connected = "conectado" # --- The phone link: its status words and the Forget action --- @@ -383,6 +384,10 @@ title = "EMPAREJAR" enter_code = "Escribe el código" on_phone = "en el móvil" +# --- The pairing code: the QR code of the pairing link --- +[pair] +scan = "Escanea con el móvil" + # --- Rides list screen --- [rides] title = "SALIDAS" @@ -541,6 +546,14 @@ skip = "Omitir" units = "UNIDADES" theme = "TEMA" effort = "ZONAS" +qr = "OBTÉN LA APP" +no_app = "¿Sin app?" +no_app_title = "SIN APP" +offline = "Funciona sin app" +needs_app = "Necesita la app" +recording = "Grabación" +ride_sync = "Sync de salidas" +show_code = "Ver el código" # --- Salida recuperada al arrancar --- [ride_recovery] diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 4fa39e6bf..e85a203e7 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -285,6 +285,7 @@ title = "CONNEXIONS" phone = "Téléphone" sensors = "Capteurs" not_paired = "Non appairé" +pair = "Appairer tél." connected = "connecté" # --- The phone link: its status words and the Forget action --- @@ -383,6 +384,10 @@ title = "APPAIRAGE" enter_code = "Entrez ce code" on_phone = "sur le tél." +# --- The pairing code: the QR code of the pairing link --- +[pair] +scan = "Scannez avec le tél." + # --- Rides list screen --- [rides] title = "SORTIES" @@ -541,6 +546,14 @@ skip = "Passer" units = "UNITÉS" theme = "THÈME" effort = "ZONES" +qr = "L'APPLI OBC" +no_app = "Sans appli" +no_app_title = "SANS APPLI" +offline = "Marche hors ligne" +needs_app = "Il faut l'appli" +recording = "Enregistrement" +ride_sync = "Synchro sorties" +show_code = "Voir le code" # --- Sortie récupérée au démarrage --- [ride_recovery] diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index d890783fa..e8e795f74 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -124,6 +124,9 @@ pub struct AppState { /// Whether this platform can make a sound, declared once by the host at composition through /// [`App::set_sound_available`]. `false` hides the Sound settings page and plays no cue. pub sound_available: bool, + /// The factory device id that the DIS Serial Number String spells (BLE spec §3.1), declared + /// once by the host through [`App::set_serial`]. + pub serial: u64, /// The Up-ahead timeline's category filter. It resets to Everything on each entry to the /// list. It lives here, not on the list screen, because the sheet that edits it sits above @@ -155,6 +158,7 @@ impl AppState { bond_status: crate::ble::BondStatus::Idle, has_nav_graph: false, sound_available: false, + serial: 0, up_ahead_filter: obc_reader::PoiCategorySet::ALL, } @@ -913,6 +917,11 @@ impl App { } } + /// Declare the factory serial. The host calls this once at boot. + pub fn set_serial(&mut self, serial: u64) { + self.state.serial = serial; + } + /// Feed the loaded map's display name and OBCM format version on map load. The System /// screen's `Map` row reads it as `name · vN`. pub fn set_map_info(&mut self, name: &str, obcm_version: u8) { @@ -1861,10 +1870,30 @@ impl App { if changed && self.ui.indicator_visible() { self.ui.map_dirty = true; } + if status.paired { + self.close_pairing_code(); + } self.ui.cards.set_passkey(status.passkey); self.sweep_cards(); } + /// Close the pairing code, which shows only while the bond slot is empty (BLE spec §9.3). In + /// setup a bond ends the pairing step; the page Connections opened returns there. + fn close_pairing_code(&mut self) { + let Some(i) = self.ui.stack.iter().position(|s| matches!(s, Screen::SetupQr(_) | Screen::PairPhone(_))) else { + return; + }; + if matches!(self.ui.stack[i], Screen::SetupQr(_)) { + self.settings.setup = screen::setup::after(crate::settings::SetupStep::Qr, true); + self.settings_ops.note_edited(); + screen::apply(&mut self.ui.stack, screen::setup::go_to(&self.settings)); + } else { + self.ui.stack.truncate(i); + } + self.ui.cancel_holds(); + self.ui.map_dirty = true; + } + /// Whether the passkey card is currently up. A route-upload popup is dropped, not queued, /// while the card shows. pub fn passkey_card_up(&self) -> bool { @@ -6134,7 +6163,7 @@ mod tests { /// The units and theme steps commit on Select only. The theme step draws the frame in the /// theme under its cursor, and that preview is never saved: a step leaves the setting alone, /// and Back returns to the units step in the committed theme. Select on a theme opens the - /// sensors step. + /// pairing step. #[test] fn the_units_and_theme_steps_commit_on_select_and_the_theme_preview_saves_nothing() { use crate::settings::{SetupStep, Theme, Units}; @@ -6160,14 +6189,14 @@ mod tests { app.apply_gesture(Gesture::Press); app.apply_gesture(Gesture::Step(1)); app.apply_gesture(Gesture::Press); - assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupSensors(_)])); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupQr(_)])); let s = app.settings(); - assert_eq!((s.setup, s.units, s.theme), (SetupStep::Sensors, Units::Imperial, Theme::Dark)); + assert_eq!((s.setup, s.units, s.theme), (SetupStep::Qr, Units::Imperial, Theme::Dark)); assert!(host.drain(&mut app).is_some(), "the step is saved"); } /// The sensors step adds a sensor through the Settings scan list, which setup's escape refusal - /// covers too. A cursor move saves nothing, Back returns to the theme step, and the last row + /// covers too. A cursor move saves nothing, Back returns to the pairing step, and the last row /// opens the effort step. #[test] fn the_sensors_step_adds_through_the_scan_list_and_its_last_row_opens_the_effort_step() { @@ -6197,9 +6226,12 @@ mod tests { assert!(!app.sensor_scan_active()); app.apply_gesture(Gesture::Back); - assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupTheme(_)])); - assert_eq!(app.settings().setup, SetupStep::Theme); - app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupQr(_)])); + assert_eq!(app.settings().setup, SetupStep::Qr); + // Skip the app: Back opens the way out, and its second row skips. + for g in [Gesture::Back, Gesture::Step(1), Gesture::Press] { + app.apply_gesture(g); + } app.apply_gesture(Gesture::Step(-1)); app.apply_gesture(Gesture::Press); assert!( @@ -6210,6 +6242,80 @@ mod tests { assert!(app.settings().saved_sensors[1].present, "the added sensor stays"); } + /// The pairing step's way out. Back opens the page that asks to ride without the app: its + /// first row returns to the code, Skip ends the step, and Back walks on to the theme step. + #[test] + fn back_on_the_pairing_step_offers_to_skip_the_app() { + use crate::settings::SetupStep; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings { setup: SetupStep::Qr, ..Settings::FACTORY }); + let way_out = + |app: &App| matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupQr(_), Screen::SetupNoApp(_)]); + + app.apply_gesture(Gesture::Back); + assert!(way_out(&app)); + app.apply_gesture(Gesture::BackHold); + assert!(way_out(&app), "setup cannot be escaped"); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupQr(_)]), "the first row"); + + app.apply_gesture(Gesture::Back); + app.apply_gesture(Gesture::Back); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupTheme(_)])); + assert_eq!(app.settings().setup, SetupStep::Theme); + + for g in [Gesture::Press, Gesture::Back, Gesture::Step(1), Gesture::Press] { + app.apply_gesture(g); + } + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupSensors(_)]), "Skip"); + assert_eq!(app.settings().setup, SetupStep::Sensors); + } + + /// A bond ends the pairing step, under the passkey card too, and the step is saved. A bonded + /// device passes the step by in both directions, because it shows no code (BLE spec §9.3). + #[test] + fn a_bond_ends_the_pairing_step_and_a_bonded_device_passes_it_by() { + use crate::ble::{BleLink, BleStatus}; + use crate::settings::SetupStep; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings { setup: SetupStep::Qr, ..Settings::FACTORY }); + let mut host = SettingsHost::default(); + + app.set_ble_status(BleStatus { link: BleLink::Connected, passkey: Some(123_456), paired: false }); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupQr(_), Screen::Passkey(_)])); + app.set_ble_status(BleStatus { link: BleLink::Connected, passkey: None, paired: true }); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupSensors(_)])); + assert_eq!(app.settings().setup, SetupStep::Sensors); + assert!(host.drain(&mut app).is_some(), "the step is saved"); + + app.apply_gesture(Gesture::Back); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupTheme(_)])); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupSensors(_)])); + } + + /// Connections offers the pairing code while no phone is paired. Back returns to the page, and + /// so does a bond. + #[test] + fn connections_opens_the_pairing_code_until_a_phone_pairs() { + use crate::ble::{BleLink, BleStatus}; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings::default()); + let connections = crate::screen::SettingsPage::new(&crate::screen::settings::page::CONNECTIONS); + let _ = app.ui.stack.push(Screen::Connections(connections)); + // The Bluetooth switch, then the Sensors door, then the pairing code's door. + for g in [Gesture::Step(1), Gesture::Step(1), Gesture::Press] { + app.apply_gesture(g); + } + assert!(matches!(app.ui.stack.last(), Some(Screen::PairPhone(_)))); + app.apply_gesture(Gesture::Back); + assert!(matches!(app.ui.stack.last(), Some(Screen::Connections(_)))); + + app.apply_gesture(Gesture::Press); + app.set_ble_status(BleStatus { link: BleLink::Connected, passkey: None, paired: true }); + assert!(matches!(app.ui.stack.last(), Some(Screen::Connections(_))), "a bond closes the code"); + } + /// The effort step edits each limit in the drawer editor over the page: the editor's Select /// commits and saves the value, and its Back discards. The sheet keeps setup's refusals of the /// chords, the escape and the idle return. Back on the page returns to the sensors step with the diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index ac5727c2b..0533655ab 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -120,6 +120,9 @@ fn seeds(language: Language) -> Vec { Screen::SetupLanguage(SetupLanguageScreen::new(language)), Screen::SetupUnits(SetupUnitsScreen(crate::settings::Units::Metric)), Screen::SetupTheme(SetupThemeScreen(crate::settings::Theme::Light)), + Screen::SetupQr(SetupQrScreen), + Screen::SetupNoApp(SetupNoAppScreen::default()), + Screen::PairPhone(PairPhoneScreen), Screen::SetupSensors(SetupSensorsScreen::default()), Screen::SetupSensorScan(SensorScanScreen::new(1)), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Receiving { received_kib: 1_024, total_kib: 65_536 })), diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index cbbe6e514..b0cd1c683 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -38,6 +38,7 @@ pub(crate) mod map; mod map_transfer; mod menu; mod nav_route; +mod pair_code; pub mod palette; mod passkey; mod peak_article; @@ -90,6 +91,7 @@ pub use map::{MapScreen, ROUTE_WEIGHT}; pub use map_transfer::{MapTransfer, MapTransferError, MapTransferScreen}; pub use menu::MenuScreen; pub use nav_route::{NavFailScreen, NavPlanningScreen, PlanKind}; +pub use pair_code::PairPhoneScreen; pub use passkey::PasskeyScreen; pub use peak_article::PeakArticleScreen; pub use peak_view::PeakViewScreen; @@ -118,8 +120,8 @@ pub use settings::{ StatFieldsScreen, }; pub use setup::{ - HelloScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupSensorsScreen, SetupThemeScreen, - SetupUnitsScreen, + HelloScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupNoAppScreen, SetupQrScreen, + SetupSensorsScreen, SetupThemeScreen, SetupUnitsScreen, }; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; @@ -921,6 +923,11 @@ screens! { SetupUnits(SetupUnitsScreen) => Caps::modal().blocking(), /// Setup's theme step: Light or Dark. The frame draws in the theme under its cursor. SetupTheme(SetupThemeScreen) => Caps::modal().blocking(), + /// Setup's pairing step: the QR code of the pairing link. A bond ends the step, and Back opens + /// the page that asks whether to ride without the app. + SetupQr(SetupQrScreen) => Caps::modal().blocking(), + /// Ride without the app: a row back to the code and Skip, which ends the pairing step. + SetupNoApp(SetupNoAppScreen) => Caps::modal().blocking(), /// Setup's sensors step: the three sensor slots with their live status, then Skip or Continue. SetupSensors(SetupSensorsScreen) => Caps::modal().blocking().key(RenderKeyKind::SensorSettings), /// The Settings scan list for one slot, opened from the sensors step. It blocks the escape, @@ -988,6 +995,9 @@ screens! { /// it replaces the last per-route popup of the burst. It holds the trip's durable id, not a /// catalog index, so no rescan remap is needed. TripReceived(TripReceivedScreen) => Caps::modal(), + /// The pairing code, opened from Connections while no phone is paired. It waits for the rider + /// to pair on the phone, so idle return leaves it up. A bond closes it. + PairPhone(PairPhoneScreen) => Caps::modal(), /// The BLE pairing passkey card. Host-pushed when the seam's passkey goes `Some`, popped when /// it clears. Opaque and non-dismissible. Passkey(PasskeyScreen) => Caps::modal().blocking(), diff --git a/firmware/obc-app/src/screen/pair_code.rs b/firmware/obc-app/src/screen/pair_code.rs new file mode 100644 index 000000000..875e3b22f --- /dev/null +++ b/firmware/obc-app/src/screen/pair_code.rs @@ -0,0 +1,123 @@ +//! The pairing code: the link of BLE spec §9 as a QR code. A phone camera opens the companion app +//! from it, and the app finds the one OBC that shows it. The link carries only the serial, which +//! the open DIS serves to every central, so the code is no secret. Setup's pairing step draws it, +//! and so does the page that Connections opens. + +use core::fmt::Write as _; + +use embedded_graphics::prelude::Point; +use obc_render::{ + rect, + text::{Font, TextAlign}, + Surface, +}; +use qrcodegen_no_heap::{QrCode, QrCodeEcc, Version}; + +use crate::input::Gesture; +use crate::Msg; + +use super::vocab::chrome::{title_frame, TITLE_BAR_H}; +use super::{palette, Ctx, Render, Transition}; + +/// Version 4 holds the 53 link bytes at level M (BLE spec §9.2). +const VERSION: Version = Version::new(4); +/// The encoder's buffer for one version-4 symbol. +const BUF: usize = VERSION.buffer_len(); +/// The module side in pixels, and the quiet zone in modules (BLE spec §9.2). +const MODULE: i32 = 5; +const QUIET: i32 = 4; +/// The side of the code with its quiet zone: 33 modules and the quiet zone either side. +pub(crate) const CODE_PX: i32 = (4 * 4 + 17 + 2 * QUIET) * MODULE; + +/// The pairing link of `serial` (BLE spec §9.1). +pub(crate) fn link(serial: u64) -> heapless::String<53> { + let mut s = heapless::String::new(); + let _ = write!(s, "https://openbikecomputer.com/pair/?s={serial:016X}"); + s +} + +/// The link of `serial` encoded as §9.2 specifies. The caller lends the two buffers. +fn encode<'a>(serial: u64, data: &mut [u8; BUF], out: &'a mut [u8; BUF]) -> Option> { + let link = link(serial); + data[..link.len()].copy_from_slice(link.as_bytes()); + QrCode::encode_binary(data, link.len(), out, QrCodeEcc::Medium, VERSION, VERSION, None, false).ok() +} + +/// Draw the code of `serial` with its quiet zone, its top-left at `(x, y)`. The modules are dark on +/// a light square in either theme: the two colours are ones the theme mapping keeps. +fn draw_code(cv: &mut impl Surface, x: i32, y: i32, serial: u64) { + use palette::*; + let (mut data, mut out) = ([0; BUF], [0; BUF]); + let Some(qr) = encode(serial, &mut data, &mut out) else { return }; + cv.round(rect(x, y, CODE_PX, CODE_PX), 4, ART_WHITE); + let (x0, y0, n) = (x + QUIET * MODULE, y + QUIET * MODULE, qr.size()); + // One fill for each run of dark modules in a row. + for my in 0..n { + let mut mx = 0; + while mx < n { + let start = mx; + while mx < n && qr.get_module(mx, my) { + mx += 1; + } + if mx > start { + cv.fill(rect(x0 + start * MODULE, y0 + my * MODULE, (mx - start) * MODULE, MODULE), ON_ACCENT); + } + mx += 1; + } + } +} + +/// The code under the title bar, and the line under it that says to scan it. +pub(crate) fn code_page(cv: &mut impl Surface, rx: &Render) { + let top = TITLE_BAR_H + 6; + draw_code(cv, (rx.w - CODE_PX) / 2, top, rx.state.serial); + let caption = Point::new(rx.w / 2, top + CODE_PX + 3); + cv.text(rx.t(Msg::PairScan), caption, Font::Caption, TextAlign::Center, palette::INK); +} + +/// The pairing code outside setup, opened from Connections. Back returns there, and a bond closes +/// the page (see [`App::set_ble_status`](crate::App::set_ble_status)). +#[derive(Debug)] +pub struct PairPhoneScreen; + +impl PairPhoneScreen { + pub fn handle(&mut self, g: Gesture, _cx: &mut Ctx) -> Transition { + match g { + Gesture::Back => Transition::Pop, + _ => Transition::None, + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + title_frame(cv, rx.w, rx.h, rx.t(Msg::PasskeyTitle), ""); + code_page(cv, rx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The serial is the Serial Number String: 16 uppercase hex digits, zero-padded. + #[test] + fn the_link_spells_the_serial_as_sixteen_uppercase_hex_digits() { + assert_eq!(link(0x0123_4567_89AB_CDEF), "https://openbikecomputer.com/pair/?s=0123456789ABCDEF"); + assert_eq!(link(0xAB), "https://openbikecomputer.com/pair/?s=00000000000000AB"); + assert_eq!(link(u64::MAX).len(), 53, "the link is 53 bytes"); + } + + /// Every serial encodes as version 4 at level M, so every code has the side the page lays out. + #[test] + fn every_serial_encodes_as_a_version_4_level_m_symbol() { + for serial in [0, 0x0123_4567_89AB_CDEF, u64::MAX] { + let (mut data, mut out) = ([0; BUF], [0; BUF]); + let qr = encode(serial, &mut data, &mut out).expect("the link fits version 4 at level M"); + assert_eq!(qr.version(), VERSION); + // The level is the format information's first two bits, unmasked (ISO/IEC 18004 + // §7.9): M is 00. The crate's own accessor skips the unmasking. + let level = (u8::from(qr.get_module(0, 8)) << 1 | u8::from(qr.get_module(1, 8))) ^ 0b10; + assert_eq!(level, 0b00, "level M"); + assert_eq!(qr.size() * MODULE + 2 * QUIET * MODULE, CODE_PX); + } + } +} diff --git a/firmware/obc-app/src/screen/settings/page.rs b/firmware/obc-app/src/screen/settings/page.rs index 203da4df1..77ac9d813 100644 --- a/firmware/obc-app/src/screen/settings/page.rs +++ b/firmware/obc-app/src/screen/settings/page.rs @@ -37,6 +37,8 @@ pub(crate) enum Door { About, /// The factory reset's confirm page. A door lettered as the destructive act it leads to. Reset, + /// The pairing code, the one setup shows. + PairPhone, } /// Something a row does when pressed, or, for a destructive act, when held. @@ -140,6 +142,7 @@ pub(crate) static CONNECTIONS: Menu = Menu { toggle(Msg::BluetoothRadio, ContextToggle::BleEnabled), door(Msg::ConnectionsSensors, Door::Sensors), info(Msg::ConnectionsPhone, Info::PhoneStatus), + door(Msg::ConnectionsPair, Door::PairPhone), act(Msg::BluetoothForget, Act::ForgetPhone), ], }; @@ -182,10 +185,12 @@ pub(crate) static FIRMWARE: Menu = Menu { impl Item { /// Whether the row is on the page at all. The phone's Forget row is drawn only while there is a - /// bond to drop, and the Sound door only on a platform that can make a sound. + /// bond to drop, the pairing code's door only while the radio is on and no phone is paired, and + /// the Sound door only on a platform that can make a sound. fn shown(self, state: &AppState) -> bool { match self { Item::Act(Act::ForgetPhone) => state.bond_status.can_forget(state.device.ble_paired), + Item::Door(Door::PairPhone) => state.device.ble_link != BleLink::Off && !state.device.ble_paired, Item::Door(Door::Sound) => state.sound_available, _ => true, } @@ -371,6 +376,7 @@ impl Door { } Door::About => Screen::About(super::AboutScreen::new()), Door::Reset => Screen::Reset(super::ResetScreen::new()), + Door::PairPhone => Screen::PairPhone(crate::screen::PairPhoneScreen), } } @@ -544,20 +550,28 @@ mod tests { } #[test] - fn the_cursor_skips_info_rows_and_the_forget_row_comes_and_goes() { + fn the_cursor_skips_info_rows_and_the_phone_rows_come_and_go() { let (mut st, mut s) = world(); let mut conn = SettingsPage::new(&CONNECTIONS); assert_eq!(conn.selected, 0, "starts on the Bluetooth switch"); run(&mut conn, &mut st, &mut s, Gesture::Step(1)); assert_eq!(conn.selected, 1, "→ Sensors"); run(&mut conn, &mut st, &mut s, Gesture::Step(1)); - assert_eq!(conn.selected, 0, "unpaired: the info row and the hidden Forget row are skipped, so it wraps"); + assert_eq!(conn.selected, 3, "unpaired: past the info row to the pairing code"); + assert!(matches!(run(&mut conn, &mut st, &mut s, Gesture::Press), Transition::Push(Screen::PairPhone(_)))); + run(&mut conn, &mut st, &mut s, Gesture::Step(1)); + assert_eq!(conn.selected, 0, "the hidden Forget row is skipped, so it wraps"); run(&mut conn, &mut st, &mut s, Gesture::Hold); assert!(!st.ble_forget_requested, "unpaired: a hold does nothing"); + st.device.ble_link = BleLink::Off; + run(&mut conn, &mut st, &mut s, Gesture::Step(-1)); + assert_eq!(conn.selected, 1, "with the radio off, no code is offered"); + st.device.ble_link = BleLink::Advertising; + st.device.ble_paired = true; - run(&mut conn, &mut st, &mut s, Gesture::Step(2)); - assert_eq!(conn.selected, 3, "paired: the Forget row is on the page, past the info row"); + run(&mut conn, &mut st, &mut s, Gesture::Step(1)); + assert_eq!(conn.selected, 4, "paired: the Forget row replaces the pairing code's"); assert!(conn.selection_is_guarded(&st)); run(&mut conn, &mut st, &mut s, Gesture::Press); assert!(!st.ble_forget_requested, "a plain press never forgets"); diff --git a/firmware/obc-app/src/screen/settings/reset.rs b/firmware/obc-app/src/screen/settings/reset.rs index 8cbb6042f..84a6726d5 100644 --- a/firmware/obc-app/src/screen/settings/reset.rs +++ b/firmware/obc-app/src/screen/settings/reset.rs @@ -1,6 +1,7 @@ //! The Factory Reset screen. The long-press threshold is about 500 ms, which is too short to feel //! safe alone, so a reset takes two steps: a press to arm, then a hold to erase. A hold on an -//! un-armed screen does nothing. The reset clears the settings, but keeps the files on the card. +//! un-armed screen does nothing. The reset clears the settings and forgets the paired phone, so the +//! setup it starts can pair a phone again. It keeps the files on the card. use embedded_graphics::prelude::Point; use obc_render::{ @@ -47,8 +48,10 @@ impl ResetScreen { Transition::None } // `apply_gesture` sees the change and flags the host to persist the cleared settings. + // The forget takes the Forget phone path, and does nothing while no phone is paired. Gesture::Hold if self.armed => { *cx.settings = Settings::FACTORY; + cx.state.ble_forget_requested = true; self.done = true; Transition::None } @@ -107,27 +110,32 @@ mod tests { use crate::{AppState, Mode, Units}; fn run(scr: &mut ResetScreen, s: &mut Settings, g: Gesture) -> Transition { - let mut st = AppState::new(0, 0, 1.0); + run_in(scr, s, &mut AppState::new(0, 0, 1.0), g) + } + + fn run_in(scr: &mut ResetScreen, s: &mut Settings, st: &mut AppState, g: Gesture) -> Transition { let mut act = Activity::new(Mode::Idle); - let mut cx = test_ctx(&mut st, &mut act, s); + let mut cx = test_ctx(st, &mut act, s); scr.handle(g, &mut cx) } #[test] - fn arm_then_hold_resets_to_factory_and_starts_setup() { + fn arm_then_hold_resets_to_factory_forgets_the_phone_and_starts_setup() { let mut s = Settings { units: Units::Imperial, power_saver: true, fix_interval_s: 30, ..Settings::default() }; let before = s; + let mut st = AppState::new(0, 0, 1.0); let mut scr = ResetScreen::new(); - run(&mut scr, &mut s, Gesture::Hold); + run_in(&mut scr, &mut s, &mut st, Gesture::Hold); assert!(!scr.done, "an un-armed hold does nothing"); - assert_eq!(s, before, "and changes no settings"); + assert_eq!((s, st.ble_forget_requested), (before, false), "and changes no settings"); - run(&mut scr, &mut s, Gesture::Press); + run_in(&mut scr, &mut s, &mut st, Gesture::Press); assert!(scr.armed && !scr.done); - let t = run(&mut scr, &mut s, Gesture::Hold); + let t = run_in(&mut scr, &mut s, &mut st, Gesture::Hold); assert!(matches!(t, Transition::None), "stays to show the done message"); assert_eq!(s, Settings::FACTORY, "settings were cleared to factory defaults"); + assert!(st.ble_forget_requested, "the paired phone is forgotten"); assert!(scr.done); assert!(matches!(run(&mut scr, &mut s, Gesture::Press), Transition::Root(crate::Screen::Hello(_)))); } diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index 4bf2ab6f6..8d79557cf 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -1,8 +1,9 @@ //! First-use setup. A new device, and one after a factory reset, boots into it instead of Home. //! The step is persisted in [`Settings::setup`](crate::Settings), so setup resumes at its step //! after a power loss. Each step is a screen: [`screen`] maps a step to it. Select ends the step -//! through [`finish`], and Back returns to the step before through [`back`]. A new step is a -//! [`SetupStep`] variant, its place in `SetupStep::ORDER`, its screen, and its arm in [`screen`]. +//! through [`finish`], and Back returns to the step before through [`back`]. Both pass by a step +//! that does not show (see [`shows`]). A new step is a [`SetupStep`] variant, its place in +//! `SetupStep::ORDER`, its screen, and its arm in [`screen`]. //! //! The first step is Hello: a greeting in the four UI languages, because the language is not chosen //! yet. Every step after it is a titled page: [`title_bar`] at its head and the [`hint`] at its @@ -25,8 +26,9 @@ use crate::Msg; use super::context_drawer::{ContextDrawerScreen, ContextValue}; use super::home::contours; +use super::pair_code::code_page; use super::settings::{kind_msg, status_line, wake_msg, LanguageScreen, SensorScanScreen}; -use super::vocab::chrome::{copy_w, title_frame, wrapped, LIST_TOP}; +use super::vocab::chrome::{copy_w, row_check, title_frame, wrapped, wrapped_line_pitch, LIST_TOP}; use super::vocab::flags::{FLAG_H, FLAG_W}; use super::vocab::list::on_step; use super::vocab::rows::{ @@ -43,6 +45,7 @@ pub(crate) fn screen(s: &Settings) -> Option { SetupStep::Buttons => Some(Screen::SetupButtons(SetupButtonsScreen::default())), SetupStep::Units => Some(Screen::SetupUnits(SetupUnitsScreen(s.units))), SetupStep::Theme => Some(Screen::SetupTheme(SetupThemeScreen(s.theme))), + SetupStep::Qr => Some(Screen::SetupQr(SetupQrScreen)), SetupStep::Sensors => Some(Screen::SetupSensors(SetupSensorsScreen::default())), SetupStep::Effort => Some(Screen::SetupEffort(SetupEffortScreen::default())), SetupStep::Done => None, @@ -54,16 +57,36 @@ pub(crate) fn go_to(s: &Settings) -> Transition { screen(s).map_or(Transition::Home, Transition::Root) } -/// End setup step `step`: persist the next step and go to it. +/// Whether `step` shows. The pairing step shows its code only while the bond slot is empty (BLE +/// spec §9.3), so a bonded device passes it by. +fn shows(step: SetupStep, paired: bool) -> bool { + step != SetupStep::Qr || !paired +} + +/// The first step that shows as `walk` leaves `step`. +fn walk_to_shown(step: SetupStep, paired: bool, walk: fn(SetupStep) -> SetupStep) -> SetupStep { + let mut to = walk(step); + while !shows(to, paired) { + to = walk(to); + } + to +} + +/// The first step after `step` that shows. +pub(crate) fn after(step: SetupStep, paired: bool) -> SetupStep { + walk_to_shown(step, paired, SetupStep::next) +} + +/// End setup step `step`: persist the next step that shows and go to it. fn finish(step: SetupStep, cx: &mut Ctx) -> Transition { - cx.settings.setup = step.next(); + cx.settings.setup = after(step, cx.state.device.ble_paired); go_to(cx.settings) } -/// Leave setup step `step` for the one before it. The persisted step follows, so a power loss -/// resumes on the step the rider sees. +/// Leave setup step `step` for the one before it that shows. The persisted step follows, so a +/// power loss resumes on the step the rider sees. fn back(step: SetupStep, cx: &mut Ctx) -> Transition { - cx.settings.setup = step.prev(); + cx.settings.setup = walk_to_shown(step, cx.state.device.ble_paired, SetupStep::prev); go_to(cx.settings) } @@ -166,7 +189,7 @@ impl SetupLanguageScreen { let lang = self.0.cursor(); title_bar(cv, rx.w, rx.h, SetupStep::Language, t(Msg::LanguageTitle, lang)); self.0.draw_list(cv, rx); - hint(cv, rx.w, rx.h, true, t(Msg::SetupChoose, lang), Some(t(Msg::SetupOk, lang))); + hint(cv, rx.w, rx.h, true, t(Msg::SetupChoose, lang), Some(Key::Ok(t(Msg::SetupOk, lang)))); } } @@ -218,7 +241,7 @@ impl SetupButtonsScreen { let y = wrapped(cv, rx.t(Msg::SetupHoldBack), w / 2, TIP_TOP, copy_w(w), Font::Label, INK); wrapped(cv, rx.t(Msg::SetupMenuAnywhere), w / 2, y, copy_w(w), Font::Label, SUBTEXT); if self.learnt() { - hint(cv, w, h, false, rx.t(Msg::SetupContinue), Some(rx.t(Msg::SetupOk))); + hint(cv, w, h, false, rx.t(Msg::SetupContinue), Some(Key::Ok(rx.t(Msg::SetupOk)))); } else { hint(cv, w, h, false, rx.t(Msg::SetupPressEach), None); } @@ -295,7 +318,7 @@ impl SetupUnitsScreen { } } ride_preview(cv, rx, self.0); - hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(rx.t(Msg::SetupOk))); + hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(Key::Ok(rx.t(Msg::SetupOk)))); } } @@ -330,7 +353,7 @@ impl SetupThemeScreen { choice_row(cv, area, name, theme == self.0, committed, |cv, x, y| swatch(cv, x, y, theme)); } ride_preview(cv, rx, rx.settings.units); - hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(rx.t(Msg::SetupOk))); + hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(Key::Ok(rx.t(Msg::SetupOk)))); } } @@ -380,8 +403,94 @@ impl SetupSensorsScreen { let label = if saved.iter().any(|s| s.present) { Msg::SetupContinue } else { Msg::SetupSkip }; let area = row_rect(h - 8 - HINT_H - 12 - ROW_ONE, w, ROW_ONE); nav_row(cv, area, rx.t(label), None, self.selected == SKIP, true, true); - hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(rx.t(Msg::SetupOk))); + hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(Key::Ok(rx.t(Msg::SetupOk)))); + } +} + +/// The pairing step: the code that opens the app on this OBC (BLE spec §9). The OBC advertises and +/// accepts pairing while the step shows, and a bond ends the step (see +/// [`App::set_ble_status`](crate::App::set_ble_status)). Back asks whether to ride without the app. +#[derive(Debug)] +pub struct SetupQrScreen; + +impl SetupQrScreen { + pub fn handle(&mut self, g: Gesture, _cx: &mut Ctx) -> Transition { + match g { + Gesture::Back => Transition::Push(Screen::SetupNoApp(SetupNoAppScreen::default())), + _ => Transition::None, + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + let (w, h) = (rx.w, rx.h); + title_bar(cv, w, h, SetupStep::Qr, rx.t(Msg::SetupQr)); + code_page(cv, rx); + hint(cv, w, h, false, rx.t(Msg::SetupNoApp), Some(Key::Back)); + } +} + +/// The pairing step's way out: what works without the app and what needs it, over a row back to +/// the code and Skip, which ends the step. Back walks on to the step before the pairing step, so +/// that step stays one press away, as on every setup page. +#[derive(Debug, Default)] +pub struct SetupNoAppScreen { + /// 0 returns to the code, 1 is Skip. + selected: usize, +} + +/// What works without the app, then what needs it: a heading and its two items each. +const NO_APP: [(Msg, [Msg; 2]); 2] = [ + (Msg::SetupOffline, [Msg::MenuMap, Msg::SetupRecording]), + (Msg::SetupNeedsApp, [Msg::MenuRoutes, Msg::SetupRideSync]), +]; + +impl SetupNoAppScreen { + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + match g { + Gesture::Step(n) => on_step(&mut self.selected, n, 2), + Gesture::Press if self.selected == 0 => Transition::Pop, + Gesture::Press => finish(SetupStep::Qr, cx), + Gesture::Back => back(SetupStep::Qr, cx), + Gesture::Hold | Gesture::BackHold => Transition::None, + } } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + use palette::*; + let (w, h) = (rx.w, rx.h); + title_bar(cv, w, h, SetupStep::Qr, rx.t(Msg::SetupNoAppTitle)); + let mut y = LIST_TOP + 4; + for (i, (heading, items)) in NO_APP.into_iter().enumerate() { + cv.text(rx.t(heading), Point::new(ROW_X, y), Font::Caption, TextAlign::Left, SUBTEXT); + y += wrapped_line_pitch(Font::Caption); + for item in items { + let cy = y + Font::Label.cap_mid() as i32; + if i == 0 { + row_check(cv, Point::new(ROW_X + 8, cy), INK); + } else { + phone(cv, ROW_X + 8, cy); + } + cv.text(rx.t(item), Point::new(ROW_X + 24, y), Font::Label, TextAlign::Left, INK); + y += wrapped_line_pitch(Font::Label); + } + y += 10; + } + let top = h - 8 - HINT_H - 12 - 2 * ROW_ONE - ROW_GAP; + for (i, label) in [Msg::SetupShowCode, Msg::SetupSkip].into_iter().enumerate() { + let area = row_rect(top + i as i32 * (ROW_ONE + ROW_GAP), w, ROW_ONE); + action_row(cv, area, rx.t(label), None, i == self.selected, true, false, 0.0); + } + hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(Key::Ok(rx.t(Msg::SetupOk)))); + } +} + +/// A phone centred on `(cx, cy)`, the height of a Label capital: what needs the app. The outline +/// is doubled for a 2 px stroke, as the passkey card's phone is. +fn phone(cv: &mut impl Surface, cx: i32, cy: i32) { + use palette::*; + cv.round_outline(rect(cx - 5, cy - 8, 10, 17), 3, INK); + cv.round_outline(rect(cx - 4, cy - 7, 8, 15), 2, INK); + cv.hline(cx - 1, cy + 4, 2, INK); } /// A theme's page in the flag slot: its paper, its ink round the edge, and two lines of text. The @@ -459,7 +568,7 @@ impl SetupEffortScreen { } } } - hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(rx.t(Msg::SetupOk))); + hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(Key::Ok(rx.t(Msg::SetupOk)))); } } @@ -505,10 +614,19 @@ fn title_bar(cv: &mut impl Surface, w: i32, h: i32, step: SetupStep, title: &str /// The height of the [`hint`] band. const HINT_H: i32 = 40; +/// The button a [`hint`] names after its text. +#[derive(Clone, Copy)] +enum Key<'a> { + /// Select, as the amber `OK`: Hello's button in small, so it names the press the rider has + /// already made. + Ok(&'a str), + /// Back, outlined and named as the button is. + Back, +} + /// The controls hint at the foot of a titled setup page: the Up and Down glyphs when they choose, -/// the `text`, and the amber `ok` when Select confirms. The amber OK is Hello's button in small, so -/// it names the press the rider has already made. -fn hint(cv: &mut impl Surface, w: i32, h: i32, arrows: bool, text: &str, ok: Option<&str>) { +/// the `text`, and the `key` that acts. +fn hint(cv: &mut impl Surface, w: i32, h: i32, arrows: bool, text: &str, key: Option) { use palette::*; let font = Font::Label; let top = h - 8 - HINT_H; @@ -519,9 +637,14 @@ fn hint(cv: &mut impl Surface, w: i32, h: i32, arrows: bool, text: &str, ok: Opt // Up and Down side by side, each `2k` wide. let k = 5; let arrows_w = if arrows { 4 * k + 3 + 8 } else { 0 }; - let pill_w = ok.map_or(0, |ok| text_width(ok, font) as i32 + 16); + let label = match key { + Some(Key::Ok(ok)) => ok, + Some(Key::Back) => "BACK", + None => "", + }; + let pill_w = text_width(label, font) as i32 + 16; let text_w = text_width(text, font) as i32; - let left = (w - (arrows_w + text_w + if ok.is_some() { 24 + pill_w } else { 0 })) / 2; + let left = (w - (arrows_w + text_w + if key.is_some() { 24 + pill_w } else { 0 })) / 2; if arrows { let x = left; @@ -532,11 +655,19 @@ fn hint(cv: &mut impl Surface, w: i32, h: i32, arrows: bool, text: &str, ok: Opt let x = left + arrows_w; cv.text_vcentered(text, x, span, font, TextAlign::Left, SUBTEXT); - if let Some(ok) = ok { - let px = x + text_w + 24; - cv.round(rect(px, cy - 12, pill_w, 24), 6, AMBER); - cv.text_vcentered(ok, px + pill_w / 2, span, font, TextAlign::Center, ON_ACCENT); - } + let pill = rect(x + text_w + 24, cy - 12, pill_w, 24); + let ink = match key { + Some(Key::Ok(_)) => { + cv.round(pill, 6, AMBER); + ON_ACCENT + } + Some(Key::Back) => { + cv.round_outline(pill, 6, INK); + INK + } + None => return, + }; + cv.text_vcentered(label, x + text_w + 24 + pill_w / 2, span, font, TextAlign::Center, ink); } #[cfg(test)] @@ -571,9 +702,10 @@ mod tests { SetupStep::Buttons, SetupStep::Units, SetupStep::Theme, + SetupStep::Qr, SetupStep::Sensors, SetupStep::Effort, ]; - assert_eq!(places.map(place), [(1, 6), (2, 6), (3, 6), (4, 6), (5, 6), (6, 6)]); + assert_eq!(places.map(place), [(1, 7), (2, 7), (3, 7), (4, 7), (5, 7), (6, 7), (7, 7)]); } } diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index f25ca9d86..1c7c9a536 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -218,17 +218,19 @@ pub enum SetupStep { Theme = 5, Sensors = 6, Effort = 7, + Qr = 8, } impl SetupStep { /// The steps in the order the rider meets them. The byte values are persisted, so a new step /// takes a new value and its place here. - pub(crate) const ORDER: [SetupStep; 7] = [ + pub(crate) const ORDER: [SetupStep; 8] = [ SetupStep::Hello, SetupStep::Language, SetupStep::Buttons, SetupStep::Units, SetupStep::Theme, + SetupStep::Qr, SetupStep::Sensors, SetupStep::Effort, ]; diff --git a/firmware/obc-fw-nrf54l/Cargo.lock b/firmware/obc-fw-nrf54l/Cargo.lock index 542490d9b..e715cd3fd 100644 --- a/firmware/obc-fw-nrf54l/Cargo.lock +++ b/firmware/obc-fw-nrf54l/Cargo.lock @@ -1256,6 +1256,7 @@ dependencies = [ "obc-reader", "obc-render", "obc-route", + "qrcodegen-no-heap", ] [[package]] @@ -1567,6 +1568,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qrcodegen-no-heap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0e2c0bf8be8a1c4a4f48973dabf26943f05da2bfc2d3180aae62409dbba6f0c" + [[package]] name = "quote" version = "1.0.46" diff --git a/firmware/obc-fw-nrf54l/src/link/identity.rs b/firmware/obc-fw-nrf54l/src/link/identity.rs index ae239fd00..f4e1aebff 100644 --- a/firmware/obc-fw-nrf54l/src/link/identity.rs +++ b/firmware/obc-fw-nrf54l/src/link/identity.rs @@ -51,13 +51,17 @@ pub(crate) fn resolved_name(store: &LinkControl) -> heapless::String<48> { s } -/// The serial number string: the 64-bit FICR `DEVICEID` as 16 uppercase hex digits, high word -/// first, so its last four digits are [`device_name`]'s `XXXX`. Also the USB `iSerialNumber`, which -/// is what makes a plugged-in device distinguishable in the browser's chooser. -pub(crate) fn serial_string() -> heapless::String<16> { +/// The 64-bit FICR `DEVICEID`, high word first, so its low 16 bits are [`device_name`]'s `XXXX`. +pub(crate) fn serial() -> u64 { let (id0, id1) = device_id_words(); + (u64::from(id1) << 32) | u64::from(id0) +} + +/// The serial number string: [`serial`] as 16 uppercase hex digits. Also the USB `iSerialNumber`, +/// which is what makes a plugged-in device distinguishable in the browser's chooser. +pub(crate) fn serial_string() -> heapless::String<16> { let mut s = heapless::String::new(); - let _ = core::fmt::write(&mut s, format_args!("{:08X}{:08X}", id1, id0)); + let _ = core::fmt::write(&mut s, format_args!("{:016X}", serial())); s } diff --git a/firmware/obc-fw-nrf54l/src/ride.rs b/firmware/obc-fw-nrf54l/src/ride.rs index 49d969df6..4787f3ae2 100644 --- a/firmware/obc-fw-nrf54l/src/ride.rs +++ b/firmware/obc-fw-nrf54l/src/ride.rs @@ -730,6 +730,7 @@ pub(crate) async fn run_app( let mut backlight_level = u8::MAX; app.set_backlight_available(obc_ports::Backlight::available(&backlight)); app.set_sound_available(obc_ports::Sounder::available(&buzzer)); + app.set_serial(crate::link::identity::serial()); // The map plane is one resident framebuffer that the present scans out of, so every repaint is a // repaint over the last frame. That is what lets the app leave a frozen base's rows alone while // a drawer's sheet grows over them. diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index e8238a5d7..c4d1876ca 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": 58440, + "app": 58448, "map_cache": 37084, "map_tables": 7528, "route_cache": 9260, @@ -88,6 +88,12 @@ "_deep_ride_margin_note": "A durable raw 8,704 B floor leaves 8,192 B effective after the 512 B below MSPLIM reserved for fault handling; it is deliberately not pinned to today's exact margin. The shipping image measured beside the run above has 46,328 B residual, so it clears the 31,340 B peak by 14,988 B raw / 14,476 B effective.", "_deep_ride_high_water_note": "Read off the board's own stackmeter on the nRF54LM20-DK over the VCOM harness, with the grimsel-demo map on the card. One walk reached the peak and a second boot reproduced it exactly. What the walk covered: a route opened from the Route menu and a ride started from it; map redraws under an injected 1 Hz fix; a Find a place search and its return; three planner runs through the debug N line, each released; Peak View generation, a peak article and its photo page, and the return to Peak View; the ride finished and saved; and, across a warm reset taken while recording, the section 7.3 ride recovery, its Continue and a second finish. The peak lands on ride entry; nothing after it goes deeper. The image is the debug-uart one, because the host has to inject the buttons and the fixes, and that image compiles the ride loop differently: it binds the debug sensor sources where the shipping image builds the hub drains as call temporaries, and it never spawns sensor_task. The gated image is therefore not the measured one, so the static frames were compared before this row moved down. On the same two ELFs, resource_guard.py frames --match run_app gives 4,640 B shipping against 4,656 B debug-uart, --match ride gives the same 16,184 B largest frame in both, the largest guarded poll frame is 9,776 B in both, and the largest task body is 4,816 B shipping against 4,808 B. The measured compilation of the ride loop is therefore 16 B DEEPER than the shipping one, and the whole shipping-only sensor path fits in frames of at most 200 B inside its own task. Not covered, and therefore not claimed: the real-sensor paths, because the measured image runs none of them - the GPS, altimeter and compass drains and sensor_task itself; a plan that finds a path (this map answers no-path); an off-route rejoin; an archive receipt; and BLE or USB traffic during the ride.", "_boot_chain_roots_note": "The mount_in_place root is unscoped because toolchains demangle FlatStore differently. The symbol resolver rejects missing or ambiguous roots; a second matching constructor must be resolved with an explicit, portable needle.", + "_pairing_code_allocation_measurement": { + "source_commit": "f5-qr on claude/zu-skills-install-x0qn6t 155753961", + "build": "cargo build --release --locked --features resource-report on x86_64-unknown-linux-gnu, rustc 1.98.1 (App 58,448 B)", + "changes": "App +8 B: AppState keeps the factory serial that the pairing code carries. The QR encoder keeps no resident state; its two 138 B buffers live in the draw frame. All other named allocations and ceilings are unchanged.", + "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,969,916 B of the 2,019,328 B slot limit." + }, "_ride_zone_limits_allocation_measurement": { "source_commit": "claude/ride-zone-limits on develop 0a0b15804", "build": "cargo build --release --locked --features resource-report on the pinned aarch64-apple-darwin host, rustc 1.96.0 (App 58,440 B)", diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 516e314f8..1ae304201 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -702,15 +702,35 @@ script = "B u p d d p" expect = "Sound" # Connections (group 3): the Bluetooth switch, the Sensors door, the phone's status line (read-only, -# the cursor skips it) and the guarded Forget row — drawn only while there is a bond to drop. -# Unpaired: no Forget row, one step lands on Sensors and the next wraps. Paired: two steps select -# the Forget row (the shaded guarded base), and a partial hold fills it warning-red. +# the cursor skips it), then the pairing code's door while no phone is paired, or the guarded Forget +# row while there is a bond to drop. Paired: two steps select the Forget row (the shaded guarded +# base), and a partial hold fills it warning-red. [[frame]] name = "connections" map = "{grimsel}" script = "B u p d d d p" expect = "Connections" +# Unpaired: two steps select the pairing code's door, and Select opens the code. +[[frame]] +name = "connections-pair" +map = "{grimsel}" +script = "B u p d d d p d d" +expect = "Connections" + +[[frame]] +name = "connections-pair" +map = "{grimsel}" +script = "B u p d d d p d d" +expect = "Connections" +langs = ["de", "fr", "es"] + +[[frame]] +name = "pair-phone" +map = "{grimsel}" +script = "B u p d d d p d d p" +expect = "PairPhone" + [[frame]] name = "connections-paired" map = "{grimsel}" @@ -1887,18 +1907,59 @@ script = "p p u d b p p p d" expect = "SetupTheme" args = ["--fresh"] +# Setup's pairing step after the theme step: the QR code of the pairing link, which the sim's +# serial makes the spec's example link. The code stays dark on light in the Dark theme. +[[frame]] +name = "setup-qr" +map = "{grimsel}" +script = "p p u d b p p p p" +expect = "SetupQr" +args = ["--fresh"] + +[[frame]] +name = "setup-qr" +map = "{grimsel}" +script = "p p u d b p p p p" +expect = "SetupQr" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +[[frame]] +name = "setup-qr-dark" +map = "{grimsel}" +script = "p p u d b p p p d p" +expect = "SetupQr" +args = ["--fresh"] + +# Back on the pairing step asks whether to ride without the app. Skip passes the step, which the +# later setup frames do. +[[frame]] +name = "setup-no-app" +map = "{grimsel}" +script = "p p u d b p p p p b" +expect = "SetupNoApp" +args = ["--fresh"] + +[[frame]] +name = "setup-no-app" +map = "{grimsel}" +script = "p p u d b p p p p b" +expect = "SetupNoApp" +args = ["--fresh"] +langs = ["de", "fr", "es"] + # Setup's sensors step with no sensor saved: each slot says how to wake its sensor, over Skip. [[frame]] name = "setup-sensors" map = "{grimsel}" -script = "p p u d b p p p p" +script = "p p u d b p p p p b d p" expect = "SetupSensors" args = ["--fresh"] [[frame]] name = "setup-sensors" map = "{grimsel}" -script = "p p u d b p p p p" +script = "p p u d b p p p p b d p" expect = "SetupSensors" args = ["--fresh"] langs = ["de", "fr", "es"] @@ -1907,7 +1968,7 @@ langs = ["de", "fr", "es"] [[frame]] name = "setup-sensors-added" map = "{grimsel}" -script = "p p u d b p p p p" +script = "p p u d b p p p p b d p" expect = "SetupSensors" args = ["--fresh", "--sensors", "screen"] @@ -1916,14 +1977,14 @@ args = ["--fresh", "--sensors", "screen"] [[frame]] name = "setup-sensors-scanning" map = "{grimsel}" -script = "p p u d b p p p p d p" +script = "p p u d b p p p p b d p d p" expect = "SetupSensorScan" args = ["--fresh"] [[frame]] name = "setup-sensors-scanning" map = "{grimsel}" -script = "p p u d b p p p p d p" +script = "p p u d b p p p p b d p d p" expect = "SetupSensorScan" args = ["--fresh"] langs = ["de", "fr", "es"] @@ -1933,14 +1994,14 @@ langs = ["de", "fr", "es"] [[frame]] name = "setup-effort" map = "{grimsel}" -script = "p p u d b p p p p u p" +script = "p p u d b p p p p b d p u p" expect = "SetupEffort" args = ["--fresh"] [[frame]] name = "setup-effort" map = "{grimsel}" -script = "p p u d b p p p p u p" +script = "p p u d b p p p p b d p u p" expect = "SetupEffort" args = ["--fresh"] langs = ["de", "fr", "es"] @@ -1949,7 +2010,7 @@ langs = ["de", "fr", "es"] [[frame]] name = "setup-effort-editor" map = "{grimsel}" -script = "p p u d b p p p p u p p w" +script = "p p u d b p p p p b d p u p p w" expect = "ContextDrawer" args = ["--fresh"] @@ -1957,7 +2018,7 @@ args = ["--fresh"] [[frame]] name = "setup-effort-set" map = "{grimsel}" -script = "p p u d b p p p p u p p d*5 p d p d*10 p d w" +script = "p p u d b p p p p b d p u p p d*5 p d p d*10 p d w" expect = "SetupEffort" args = ["--fresh"] diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index f94800cd4..ad9b6505e 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -17,8 +17,12 @@ ec87d4c8b447a0bebd5067add677e47b4690eb49ef3b887c16292926cece8e0e assistant-ridi 3f28a801c4b387eb94b67a5ca377d261bb48185e782d59b717cc744f48f27311 climb.png 575f3215751c3fef83508b010eb852e9d0eca6a3d0297c260ae95d05c84720d6 connections-forget-hold.png b8fb078bc5fd0eab2792f7a7b84a7e9d73eb481dbe6bdaa23e21e8d47224e69f connections-forget-selected.png +28b8ca2585a156ca61f2b17409faec2c4929e10d198737bf0325212919a2650c connections-pair-de.png +86a6a699d0f234fd1ad4f69ceb8b0443f92ad95f9307408eb51823f03185adb3 connections-pair-es.png +3f4dc86d2f1ef679dc6824c4653dcd48ec90a22bf09d993ce00021e047efdcbc connections-pair-fr.png +b486414a05f6017d9c449852c1daa7b840cc25050f5c69ba7a4ad904cf3d2bd7 connections-pair.png 83889a299c5e5319014ae6b4105e97d1a95665eb02b5eadf82ab4e202c459d46 connections-paired.png -c0da2bc9d1fda901e38794bcff064a70fd4463126c7f3260edb7fba8977c12ef connections.png +95a0195edd33b02d621fee650936434ac3ae408a4db530d82a9ea55f1a224750 connections.png 067e02a924dcee5a92a3e58880892d5ef5ea8c9b6b4e8e2bf5b7a40a0a76cc63 dark-assistant.png 88c8686c113d96b080bb585448495d9819a27d3ba2fd9ac66524358d2206a4da dark-climb.png 1969d859989758d244e779ebaf4d3ce91f1b710b2c533b8a258c62011255cbea dark-detour-planning.png @@ -178,6 +182,7 @@ f8b0cf201379675b5db0d3d38880545fb191df4b758cac591b9fc9171a754456 menu-de.png 48a35fa6b1e01972ec2d97a226df18a9899e7183fd4ea529928c2b6eea5f20a9 menu-fr.png 01e7eba6740469fcbf64d9a8feba011a2618869b08912ec07c1d38835077874d menu-pois.png 37eff17acaf4799bfced8df633e43f69f6b21f9f36a0c3dafceefbfb5e9a4e68 menu.png +3d02f42cc61aa232f99cbd12edb6cf86d5e02e8dff3ab6d9903ec3d2e9fe6000 pair-phone.png f219b744a652adb1a877e58f4e004ed9893454571778d708a8ef2d347ed3c800 passkey-card.png 99b89756ca6d977555dd691b30f6ee55b546a9a217d16742841afed18a179084 peak-article-back.png 99b89756ca6d977555dd691b30f6ee55b546a9a217d16742841afed18a179084 peak-article-indicator.png @@ -306,46 +311,55 @@ e45d66a5ad980acf5ec2891d0ae89b517466fae60b1caf61b814f988be56a65d settings-es.pn 49286bcd4069b29c6094ebeadb8b35dc3cf2b3c1fc6dc70da98ab5cd7be810ac settings-fr.png 460cddf4695830c5cdececc764d507b4ee2a13fb3660dd66ca6ef20dedb84f37 settings-ride-waypoints.png c1a9b35839d97d9a06da9885587cc2f8b74dff61561d93b5fac55088148f94e5 settings.png -5a2c7ba0f4b74c770ad442c7a8f69bb0fd64f376799abe1b19866c727266e48a setup-buttons-de.png -5e14f74f3b1b6be04db110875fb433f7cdc73444892fe289a088c25b03450d4b setup-buttons-done-de.png -e775932ce9cb815efcaa709d5bddf82e7033fb9009fac8fce14a5353a629e51f setup-buttons-done-es.png -6cdefb4f318aa40853e45d2bddb329dce57f49592381627ec4562bd63c356066 setup-buttons-done-fr.png -ab1b679c069f327dcefe7589fcd35774b82b0b39412f1636dba0c63612115829 setup-buttons-done.png -e84f6b5655265d30e232aaf5c94f3cfcfdf7cad444f3759f0ae18a55846725cc setup-buttons-es.png -868b7304483d5c11fd7f9994a7b5756a28316d4cff9c00c377e9cb9b70d00ddd setup-buttons-fr.png -6bae0f615925fc7c6f3687780bd6a76a0da96bf1e8714b359bcc1b28d0fd7ad3 setup-buttons-partial.png -bc3838b9d0c19ebafc0f69683efb69b8a6ea023d39506bdc1446c56fdd15e908 setup-buttons.png -c95dc1d025eb93b5bab6c6d15153395c151cf508be9e18d816e5501be0650e88 setup-effort-de.png -ff823f5faa317edb42a858a2f90acab6aa4b285b5dfb3eac134cf8696a1ae536 setup-effort-editor.png -4ed7e0fbd54e3745fe2282ec83ad81c6893676f77d4c187c13440cdf431a7f2d setup-effort-es.png -bd6c2bff7c70053bcd7eafb202f6e3dd37e34ecb2ac7bdcc95475beb816fb1aa setup-effort-fr.png -38f1027d9991dd9d42f38ca7286b9e011e8ae81cf7d7e6c5a5f5d40dae3407df setup-effort-set.png -84c0037cfa891bf3a7ad860d3c7d90d2651dc5abcfcc6c6a47fb3b58cc463c2c setup-effort.png +7f2d7b81e43eba3dff167d0fd4f95a21fd32242aec1afb17b4dde8984423e118 setup-buttons-de.png +12cbf9a341b3109a3af251ebbfe5f78f30572991cf903ab11e3395f42213bac9 setup-buttons-done-de.png +e9f720180f12726a7ab36782f324ea67d18915a0ff4b44781b85f3c0c783c108 setup-buttons-done-es.png +3ca451caf4991b026a6d57c293d1a408d31056ab7cb3936116e6ffe002512d1b setup-buttons-done-fr.png +1b03b353ab0da2fca424d2429149f62bc8d5923abf371d8bbb0e8edb3e6650d1 setup-buttons-done.png +25bde0388ef9e6f4c15e63445e67e01c3cfc252706428acf2988fe53bbe80f20 setup-buttons-es.png +d5cf0913b90cdd756b1a0b162f684dca8cbba969f1ba4e557b07c4ae3a789199 setup-buttons-fr.png +834536f37faf685f2c50b423d026151cbe230920484d522fd82b79803bff2fc1 setup-buttons-partial.png +aa0fd31e179d4b84ba86f3cf4b7ee47f1d4439a98bd6c526fc90274f4b84192c setup-buttons.png +4a3b2e7bff4d7d0c53869d91c5647c6365bb83a1fcae638ff3ff4b3634125547 setup-effort-de.png +3014dfec1c98b2097988df805fd17ff2b74675a6559290a38c2e438f115256aa setup-effort-editor.png +05dde8be9f85a40ee1e73224f59d0ecb42a12ce38cc87d7bd26ba679dd3e0a01 setup-effort-es.png +238392b1eb02609c62cdfc5318df92705e694d3f77e94c88516f9193f8fe32ba setup-effort-fr.png +befb434a3d75051d48aa8f0445f7a6be879f2c48a8989ad558036c97fa6a8618 setup-effort-set.png +97180e788c490f3610fd3545a55f012d3e3dcbc128a4d2b20978b87febd1ef2f setup-effort.png fa7017db43b71eb7675c622608c8ac99e93e86a9e26ec39567091b1aa0d5450b setup-hello.png -4c4186b05f6a424f693020f53796ce60b56f09651dfa60cd0e35272b3520c253 setup-language-cursor.png -474a211bd8657025e76324c2ea77e71634fa7ed6b94cde42ae227d37cdde54a1 setup-language-de.png -ffd3e0199cc4fb05dee1165b74738a9b51796b6933399efcd0f0790886a67e34 setup-language-es.png -70db7ee4772f781352d53db9d2481bf450f875d6eb0b0524a3ac7f718128a4c2 setup-language-fr.png -0f08be3822dc979a78d3765c29c2c78b0ff99970e6cdb42f1ddaafbd51f2d914 setup-language.png -4fcbf3b1438d32d51623009c150c69fefffa8f7ddefdcfa92abb474f43e3baca setup-sensors-added.png -a341b7e89a44915d7f4685383093601e2fb94941f566a0d425e29d9873e27227 setup-sensors-de.png -931a4daeb71e06609e4fedcd9b6fe8e43b08ba0cdffc3af3a57e87a747ce4e33 setup-sensors-es.png -58dfef1b80410366f67a1dbd08fb64f0b421588a119eb727e00606cba7e97fe6 setup-sensors-fr.png +28ddd35d44fdada46fa58d96c749df9e54d8351dca06eac61b6c9b273a9c4752 setup-language-cursor.png +4ab89a366bcbbd2d0091c1d4b5054d32f075f0946004904391ae2b53ad3241dd setup-language-de.png +cb5a7ff2f1a20d6c4ad467225fd2c5e59d93bcea581246b9e7a6eb790fc1964f setup-language-es.png +4b41d5364c0938990c75d2d674c21898475d93d495453f2fda5fb35fc0cc0157 setup-language-fr.png +b58cd8802fb380914be14e60e97e19517de22b6fb392859b31d3e8eb58b4a684 setup-language.png +890a0ee5b56e8a9a5579010c720eb39adfed8c73677139a80c05840af45c875d setup-no-app-de.png +5ea022b742fc995d1976e24b088111daf26a045cc9d50f30ad5be44d6996bbd1 setup-no-app-es.png +4e3a8ded3158085680c230ba5906ce6f89b5354bbdcc48910344fa8bfca50216 setup-no-app-fr.png +f50de6f1bb9a468c9ee0de251e16c32a7b8da3616278841ad25fc02999f6b7f0 setup-no-app.png +aab369d9dbd0f24736e32be1b521261be2d5bb28ac375f4e01fbe3f866ca8f7e setup-qr-dark.png +2bca403cfcdcbf115095648fe18a7e6bd2474aab1c51664a7f8a1b9e45061cc2 setup-qr-de.png +2169a9f8ac2c1bbb5970aedaa462c6cb1900598bdd3ae6344cdc45349f1957ef setup-qr-es.png +0fae1be3810fcb2ad86a342e473e384bdf506f00ff76c3059c7118f045e102e3 setup-qr-fr.png +712e05dba31af90b06a5be95177ed06f4bf573bc3ad6e5285122c848e0f40eec setup-qr.png +728df3cf4861db769384bdb5b83f26189b784dfbe4b888310a102ab0a02ff885 setup-sensors-added.png +73d2adff50f4d1cfa98235508b4adf2a3a53991493f23494da88bf8ab5d43983 setup-sensors-de.png +15d29677d7c83244f6176ce00048b31b1e39d43f577d236210a2d45915bfa9d6 setup-sensors-es.png +5dae91524a403b21739e477da77aa7b0111304ad0687d232f099ccc6109a67fa setup-sensors-fr.png d0ecce76581f88d2877327f45e9b115973a24319966c0bddc45780332c809635 setup-sensors-scanning-de.png 580bcd60218f029c98aadc392918d50af335f7565bacce3c0c33d214b54c745e setup-sensors-scanning-es.png 340ba4899b2f2d983e0750d3ae469392bc19e17da0033ce69694c661418faacf setup-sensors-scanning-fr.png 4b667fd900c929dde3c5179f86d98d098216a118ab7a457ec7bbaca8bd456d17 setup-sensors-scanning.png -933f67cb6742e65bb7acd6a750615be7bc928c68c99ccbab81a58e535cf991b5 setup-sensors.png -03ef56ceb0579160ae3068a455efac145dbcab718bc1152e31ef4c4debb8bd5d setup-theme-dark.png -42a186068a743fa3a7d2268147d0d5a2ce22bb0285fdf2e8128463181a52645d setup-theme-de.png -e0182caaf052908241602b9a0cd851f0391d356ef45535fbb1f2beb7b6c8796d setup-theme-es.png -677261eb5ce5126d8c46d39ac7bef3b05d9c0f377893ca5cac1fff37fd47bb24 setup-theme-fr.png -8e9be6c703987ca7076656da0c213519b4ed3aa53c03097177d7dbc2dd78e030 setup-theme.png -f5bb42baceeb74f848d567d23d50781c57f419ba543e52cacc188c1a0d4fbc75 setup-units-de.png -9f33e7e8d00947e7e80577a5274dbf5f7c248b7ff564bddcd6c0b6f49adf8773 setup-units-es.png -3357a71341b756d6f58f5bed722e78477f8af6509704d27f1ce31f2836818bb4 setup-units-fr.png -e53d4aa0c5c1cfe731e44fcaa904d02cf9cef3bccbfdf257df7b596e4b74a178 setup-units-imperial.png -54d6ee98ed948895a1a13f1acdc55955c35b45738985a4b986652d64e36ea8ba setup-units.png +2ca51ba6ba61b30fc68b9ce87c610144aca35fdc87fb86d359f25f2c4bddc2b3 setup-sensors.png +b81eece169e430fb9f7059b1000362420217421ff337e263e87c2b955a965cec setup-theme-dark.png +cddb456e5109ae9f21ba2aeed625a11411a21e0d87903793dfcb93c5f1f1e6e3 setup-theme-de.png +ad8b85425f6f4fca676b38a09d18ad8602f325dd1adff6cc35474294e96ef571 setup-theme-es.png +33f69f3fb537dc18db31e67527cfc4859dffdc470a138b7aba5f0d5ee8f31a6a setup-theme-fr.png +768dd934c41a97bc427ec9dd8d9f25b978bcbb6baadbf35870acd6a8b5737996 setup-theme.png +cd4977b7166bb8e21918001b423dbe1a1e118f88fb82cc81ae77e34d9c788be4 setup-units-de.png +f5036e0c065fcedb205fbda19e4904c26f4d9a919eee2b59fc2e034bdad5aa58 setup-units-es.png +78809770463d35173c0da5726f5f743f21ba3296d40b269abe274c32d5a1af11 setup-units-fr.png +4611d37bbcb05180c517082afa5a019d954fa451cb2d76626f7af8a834283c49 setup-units-imperial.png +bc03b85ce99ec782071e54dec06ed914fb91bf286d1d00b4dde26c87dd8f14ff setup-units.png d44c085c61a1672c4487b36a4eac869cd5f345a784e744a91d7eb89f546c57fa sound.png defa30f78f8b1f3e08798973ded8490a60ac0747117cb5b2c9696e0fff2dbc26 start-away-noroute.png bccc4c7c5d0fbc0867fcd80ff4cb931559d1c9ce8b04e26dc4f36bdf9ca4d480 start-away-planning.png From feafd58a99f10e2dcbfc84452a87d1580434b4be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:33:03 +0000 Subject: [PATCH 13/37] docs: a factory reset also forgets the paired phone The companion link guide said that only Forget phone clears the bond. A factory reset now runs the same removal, so the setup after it can pair a phone again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- docs/content/software/companion-link.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/content/software/companion-link.md b/docs/content/software/companion-link.md index 00c26bba4..476aef629 100644 --- a/docs/content/software/companion-link.md +++ b/docs/content/software/companion-link.md @@ -217,7 +217,7 @@ offers the rider an age-based cleanup instead of deciding alone. The durable for
- + @@ -311,7 +311,8 @@ next wheel. **Forget phone** removes the stored keys. It writes and verifies the empty persistent slot first, then removes the host keys, because the order decides what a failure leaves behind. Controller cleanup has no receipt, so the screen asks for a restart to finish. A disconnect proves nothing -about stored keys. +about stored keys. A factory reset runs the same removal, so the setup that follows can pair a +phone again. Device information, the battery level, and the protocol version are readable before pairing. Everything else needs an authenticated, encrypted link. The clock is set by the phone or by a GPS From 2719683ed68539734f24dd080e998074e0d04dcb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 19:46:35 +0000 Subject: [PATCH 14/37] resources: re-pin the BLE settings cache for the setup step The persisted setup step grows Settings by 2 B. RefCell holds a Settings, so ble_object_store grows from 128 B to 132 B with padding, and ble_total from 24,973 B to 24,977 B. All other named allocations and ceilings are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/tools/resource_baseline.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index c4d1876ca..61d88bfcd 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -38,13 +38,13 @@ "terrain": 2152, "terrain_window": 24, "stack_reserve": 65536, - "ble_total": 24973, + "ble_total": 24977, "ble_mpsl": 0, "ble_sdc_memory": 11336, "ble_host_resources": 6120, "ble_packet_pool": 4036, "ble_cracen": 0, - "ble_object_store": 128, + "ble_object_store": 132, "ble_server": 2168, "ble_gap_name": 52, "ble_sensor_manager": 370, @@ -88,6 +88,12 @@ "_deep_ride_margin_note": "A durable raw 8,704 B floor leaves 8,192 B effective after the 512 B below MSPLIM reserved for fault handling; it is deliberately not pinned to today's exact margin. The shipping image measured beside the run above has 46,328 B residual, so it clears the 31,340 B peak by 14,988 B raw / 14,476 B effective.", "_deep_ride_high_water_note": "Read off the board's own stackmeter on the nRF54LM20-DK over the VCOM harness, with the grimsel-demo map on the card. One walk reached the peak and a second boot reproduced it exactly. What the walk covered: a route opened from the Route menu and a ride started from it; map redraws under an injected 1 Hz fix; a Find a place search and its return; three planner runs through the debug N line, each released; Peak View generation, a peak article and its photo page, and the return to Peak View; the ride finished and saved; and, across a warm reset taken while recording, the section 7.3 ride recovery, its Continue and a second finish. The peak lands on ride entry; nothing after it goes deeper. The image is the debug-uart one, because the host has to inject the buttons and the fixes, and that image compiles the ride loop differently: it binds the debug sensor sources where the shipping image builds the hub drains as call temporaries, and it never spawns sensor_task. The gated image is therefore not the measured one, so the static frames were compared before this row moved down. On the same two ELFs, resource_guard.py frames --match run_app gives 4,640 B shipping against 4,656 B debug-uart, --match ride gives the same 16,184 B largest frame in both, the largest guarded poll frame is 9,776 B in both, and the largest task body is 4,816 B shipping against 4,808 B. The measured compilation of the ride loop is therefore 16 B DEEPER than the shipping one, and the whole shipping-only sensor path fits in frames of at most 200 B inside its own task. Not covered, and therefore not claimed: the real-sensor paths, because the measured image runs none of them - the GPS, altimeter and compass drains and sensor_task itself; a plan that finds a path (this map answers no-path); an off-route rejoin; an archive receipt; and BLE or USB traffic during the ride.", "_boot_chain_roots_note": "The mount_in_place root is unscoped because toolchains demangle FlatStore differently. The symbol resolver rejects missing or ambiguous roots; a second matching constructor must be resolved with an explicit, portable needle.", + "_setup_step_allocation_measurement": { + "source_commit": "first-use setup on claude/zu-skills-install-x0qn6t 8ae047ec", + "build": "cargo build --release --locked --features resource-report on x86_64-unknown-linux-gnu, rustc 1.98.1 (ble_object_store 132 B)", + "changes": "BLE settings cache +4 B (Settings +2 B for the persisted setup step, padded), also counted in ble_total. All other named allocations and ceilings are unchanged.", + "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,964,500 B of the 2,019,328 B slot limit." + }, "_pairing_code_allocation_measurement": { "source_commit": "f5-qr on claude/zu-skills-install-x0qn6t 155753961", "build": "cargo build --release --locked --features resource-report on x86_64-unknown-linux-gnu, rustc 1.98.1 (App 58,448 B)", From eb56e77f9800ca61b319d81a4dd9ef9f0f88460f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:52:26 +0000 Subject: [PATCH 15/37] onboarding: the pairing code is one fixed app link The pairing code now holds https://openbikecomputer.com/app on every OBC. The link opens the companion app into its pairing flow, and the app lists the unpaired OBCs nearby by advertised name. The OBC shows that name under the code, so a rider with more than one OBC nearby can select the correct one. The passkey stays the proof of possession. BLE spec section 9 specifies the link, the symbol (version 3, level Q, byte mode, mask 6, 185 x 185 px with the quiet zone), the name under the code, the app flow, the fallback page and the path /app in the associated-domains file. The serial parameter and its matching rules are gone. The symbol is fixed, so pair_code.rs keeps its 29 rows as a const table and draws them with the same module size and quiet zone. A test encodes the link with qrcodegen-no-heap, now a dev-dependency only, and compares every module. The encoder leaves the device image and the board lockfile and THIRD-PARTY.md drop it. The name is the stored rename, else the factory name OBC-XXXX, which the host declares through App::set_factory_name. That replaces App::set_serial and AppState.serial. The board feeds identity::device_name(), the sim feeds OBC-7A2F, and identity::serial() goes back into serial_string(). The five setup-qr frames and pair-phone change. Each decodes to the link with zbar and OpenCV. App stays 58,448 B; the image is 1,951,604 B, 12,896 B below the setup-step build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- THIRD-PARTY.md | 2 +- apps/obc-sim/src/gui.rs | 2 +- apps/obc-sim/src/main.rs | 7 +- firmware/obc-app/Cargo.toml | 5 +- firmware/obc-app/src/app.rs | 23 ++-- firmware/obc-app/src/screen/mod.rs | 2 + firmware/obc-app/src/screen/pair_code.rs | 131 +++++++++++--------- firmware/obc-fw-nrf54l/Cargo.lock | 7 -- firmware/obc-fw-nrf54l/src/link/identity.rs | 14 +-- firmware/obc-fw-nrf54l/src/ride.rs | 2 +- firmware/tools/resource_baseline.json | 6 +- firmware/ui-frames.toml | 4 +- firmware/ui-snapshots.sha256 | 12 +- specs/obc-ble-interface-spec.md | 94 ++++++-------- 14 files changed, 152 insertions(+), 159 deletions(-) diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index df81854a8..ce52d618c 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -2456,7 +2456,7 @@ SOFTWARE. ### MIT License -Applies to: `btuuid 0.1.1`, `cortex-m-rt-macros 0.7.5`, `cortex-m-rt 0.7.5`, `defmt-parser 1.0.0`, `defmt-rtt 1.2.0`, `defmt 0.3.100`, `embassy-embedded-hal 0.6.0`, `embassy-executor-macros 0.8.0`, `embassy-executor-timer-queue 0.1.0`, `embassy-executor 0.10.0`, `embassy-futures 0.1.2`, `embassy-hal-internal 0.4.0`, `embassy-hal-internal 0.5.0`, `embassy-net-driver-channel 0.4.0`, `embassy-net-driver 0.2.0`, `embassy-nrf 0.11.0`, `embassy-sync 0.8.0`, `embassy-time-driver 0.2.2`, `embassy-time-queue-utils 0.3.2`, `embassy-time 0.5.1`, `embassy-usb-driver 0.2.2`, `embassy-usb 0.6.0`, `embedded-graphics-core 0.4.1`, `libm 0.2.16`, `panic-probe 1.0.0`, `qrcodegen-no-heap 1.8.1`, `trouble-host-macros 0.5.0`, `trouble-host 0.7.0`, `void 1.0.2`, `windows-link 0.2.1`, `windows-result 0.4.1`, `windows-sys 0.61.2` +Applies to: `btuuid 0.1.1`, `cortex-m-rt-macros 0.7.5`, `cortex-m-rt 0.7.5`, `defmt-parser 1.0.0`, `defmt-rtt 1.2.0`, `defmt 0.3.100`, `embassy-embedded-hal 0.6.0`, `embassy-executor-macros 0.8.0`, `embassy-executor-timer-queue 0.1.0`, `embassy-executor 0.10.0`, `embassy-futures 0.1.2`, `embassy-hal-internal 0.4.0`, `embassy-hal-internal 0.5.0`, `embassy-net-driver-channel 0.4.0`, `embassy-net-driver 0.2.0`, `embassy-nrf 0.11.0`, `embassy-sync 0.8.0`, `embassy-time-driver 0.2.2`, `embassy-time-queue-utils 0.3.2`, `embassy-time 0.5.1`, `embassy-usb-driver 0.2.2`, `embassy-usb 0.6.0`, `embedded-graphics-core 0.4.1`, `libm 0.2.16`, `panic-probe 1.0.0`, `trouble-host-macros 0.5.0`, `trouble-host 0.7.0`, `void 1.0.2`, `windows-link 0.2.1`, `windows-result 0.4.1`, `windows-sys 0.61.2` ```text MIT License diff --git a/apps/obc-sim/src/gui.rs b/apps/obc-sim/src/gui.rs index 187a07c18..fc9499815 100644 --- a/apps/obc-sim/src/gui.rs +++ b/apps/obc-sim/src/gui.rs @@ -387,7 +387,7 @@ impl SimGui { app.set_backlight_available(obc_ports::Backlight::available(&backlight)); let sounder = crate::sounder::SimSounder::open(!args.no_sound); app.set_sound_available(obc_ports::Sounder::available(&sounder)); - app.set_serial(crate::SIM_SERIAL); + app.set_factory_name(crate::SIM_FACTORY_NAME); // The window draws into one resident device-64 plane and presents it by self-diff, as the // board does, so the frozen base's rows survive between frames. app.set_resident_frame(true); diff --git a/apps/obc-sim/src/main.rs b/apps/obc-sim/src/main.rs index 235fb61af..d18f2b7b4 100644 --- a/apps/obc-sim/src/main.rs +++ b/apps/obc-sim/src/main.rs @@ -39,9 +39,8 @@ use obc_host_core::{FlatRouteStore as RouteStore, FlatTripStore as TripStore, Ro use obc_replay::{gpx::Track, BaroSensor, GpxPlayer}; use obc_route::RouteReader; -/// The sim's factory serial, the board's FICR device id stand-in: the example serial of BLE spec -/// §9.1, so the pairing code a frame shows reads as the spec's example link. -const SIM_SERIAL: u64 = 0x0123_4567_89AB_CDEF; +/// The sim's factory name, the stand-in for the board's name from its FICR device id. +const SIM_FACTORY_NAME: &str = "OBC-7A2F"; #[derive(Clone, Copy, Default, PartialEq, Eq)] struct BleSeed { @@ -1344,7 +1343,7 @@ fn main() { app.set_backlight_available(!args.no_backlight); // The sounder capability, stated as the window states it, with no device opened. app.set_sound_available(!args.no_sound); - app.set_serial(SIM_SERIAL); + app.set_factory_name(SIM_FACTORY_NAME); // No `set_resident_frame` here: the headless host composes one frame into a buffer that // holds nothing, so every screen must be drawn, including a base a resident host would // leave standing under a sheet. diff --git a/firmware/obc-app/Cargo.toml b/firmware/obc-app/Cargo.toml index dbb59487a..b62e37287 100644 --- a/firmware/obc-app/Cargo.toml +++ b/firmware/obc-app/Cargo.toml @@ -30,9 +30,6 @@ heapless = "0.8" # shim the renderer and route crates use. libm = { version = "0.2", default-features = false } # no arch asm: identical correctly-rounded results, keeps the suite Miri-runnable (the #501 alignment-UB tripwire) -# The QR code of the pairing link (BLE spec §9): no_std, no heap, no unsafe, no dependencies. -qrcodegen-no-heap = "1.8" - [features] # Host-only. It turns on the captured-fixture test targets and the small read-only accessors they # need; the device build never enables it and carries none of it. @@ -52,6 +49,8 @@ obcm-testkit = { path = "../../host/obcm-testkit" } obc-fixtures = { path = "../../host/obc-fixtures" } serde_json = "1" sha2 = "0.11" +# The QR encoder the pairing code's test re-encodes the app link with (BLE spec §9.2). +qrcodegen-no-heap = "1.8" [[test]] name = "peak_view_photos" diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 7b2815547..4ba9883e6 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -124,9 +124,6 @@ pub struct AppState { /// Whether this platform can make a sound, declared once by the host at composition through /// [`App::set_sound_available`]. `false` hides the Sound settings page and plays no cue. pub sound_available: bool, - /// The factory device id that the DIS Serial Number String spells (BLE spec §3.1), declared - /// once by the host through [`App::set_serial`]. - pub serial: u64, /// The Up-ahead timeline's category filter. It resets to Everything on each entry to the /// list. It lives here, not on the list screen, because the sheet that edits it sits above @@ -158,7 +155,6 @@ impl AppState { bond_status: crate::ble::BondStatus::Idle, has_nav_graph: false, sound_available: false, - serial: 0, up_ahead_filter: obc_reader::PoiCategorySet::ALL, } @@ -454,6 +450,9 @@ pub struct App { /// The running firmware version string, fed by the host at boot. Resident because the System /// screen draws on a frame with no `Reader`. fw_version: heapless::String<32>, + /// The factory name `OBC-XXXX` that the OBC advertises while no rename is stored (BLE spec + /// §9.3), fed by the host at boot. + factory_name: heapless::String<8>, /// The loaded map's display name, fed on map load. Empty until a map loads. map_name: heapless::String<24>, /// The loaded map's OBCM format version, the right half of the `Map` row. `0` until a map loads. @@ -513,6 +512,7 @@ impl App { storage: StorageInfo::new(), pass: crate::device_core::pass::PassState::new(), fw_version: heapless::String::new(), + factory_name: heapless::String::new(), map_name: heapless::String::new(), map_obcm_version: 0, backlight_available: false, @@ -557,6 +557,7 @@ impl App { storage, pass, fw_version, + factory_name, map_name, map_obcm_version, backlight_available, @@ -581,7 +582,10 @@ impl App { assert_eq!(bond.status(), crate::ble::BondStatus::Idle); storage.assert_boot_state(); assert_eq!(*pass, crate::device_core::pass::PassState::new(), "no connection wired, no pass in flight"); - assert!(fw_version.is_empty() && map_name.is_empty(), "the host has identified nothing yet"); + assert!( + fw_version.is_empty() && factory_name.is_empty() && map_name.is_empty(), + "the host has identified nothing yet" + ); assert_eq!(*map_obcm_version, 0, "no map format known yet"); assert!(!*backlight_available, "no host has claimed a panel light yet"); assert_eq!(*cues, crate::cues::Cues::new(), "no cue raised, no level pending"); @@ -917,9 +921,10 @@ impl App { } } - /// Declare the factory serial. The host calls this once at boot. - pub fn set_serial(&mut self, serial: u64) { - self.state.serial = serial; + /// Declare the factory name. The host calls this once at boot. + pub fn set_factory_name(&mut self, name: &str) { + self.factory_name.clear(); + let _ = self.factory_name.push_str(name); } /// Feed the loaded map's display name and OBCM format version on map load. The System @@ -3123,6 +3128,7 @@ impl App { recorder, ui, fw_version, + factory_name, map_name, map_obcm_version, storage, @@ -3202,6 +3208,7 @@ impl App { clock, stats: RenderStats::default(), fw_version: fw_version.as_str(), + factory_name: factory_name.as_str(), map_name: map_name.as_str(), map_obcm_version: *map_obcm_version, card_free_bytes: storage.free_bytes(), diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index 82920639b..c4e30e0c5 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -470,6 +470,8 @@ pub struct Render<'a> { pub stats: RenderStats, /// The running firmware version string. Empty until the host feeds it. pub fw_version: &'a str, + /// The factory name `OBC-XXXX`, the name the OBC advertises while no rename is stored. + pub factory_name: &'a str, /// The loaded map's display name. Empty until map load. pub map_name: &'a str, /// The loaded map's OBCM format version; `0` means no map yet. diff --git a/firmware/obc-app/src/screen/pair_code.rs b/firmware/obc-app/src/screen/pair_code.rs index 875e3b22f..c1b8a9ed0 100644 --- a/firmware/obc-app/src/screen/pair_code.rs +++ b/firmware/obc-app/src/screen/pair_code.rs @@ -1,9 +1,7 @@ -//! The pairing code: the link of BLE spec §9 as a QR code. A phone camera opens the companion app -//! from it, and the app finds the one OBC that shows it. The link carries only the serial, which -//! the open DIS serves to every central, so the code is no secret. Setup's pairing step draws it, -//! and so does the page that Connections opens. - -use core::fmt::Write as _; +//! The pairing code: the app link of BLE spec §9 as a QR code. A phone camera opens the companion +//! app from it, and the app lists the unpaired OBCs nearby by name. Every OBC shows the same code, +//! so the name under it tells the rider which one to pick. Setup's pairing step draws it, and so +//! does the page that Connections opens. use embedded_graphics::prelude::Point; use obc_render::{ @@ -11,52 +9,71 @@ use obc_render::{ text::{Font, TextAlign}, Surface, }; -use qrcodegen_no_heap::{QrCode, QrCodeEcc, Version}; use crate::input::Gesture; use crate::Msg; use super::vocab::chrome::{title_frame, TITLE_BAR_H}; +use super::vocab::marquee::fit; use super::{palette, Ctx, Render, Transition}; -/// Version 4 holds the 53 link bytes at level M (BLE spec §9.2). -const VERSION: Version = Version::new(4); -/// The encoder's buffer for one version-4 symbol. -const BUF: usize = VERSION.buffer_len(); +/// The modules of the §9.2 symbol of the §9.1 link: version 3, level Q, mask 6. Row `y` is +/// `MODULES[y]`, and bit `SIZE - 1 - x` is the module in column `x`; 1 is dark. +const MODULES: [u32; SIZE as usize] = [ + 0b11111110010001111011101111111, + 0b10000010101010001110001000001, + 0b10111010110110101010101011101, + 0b10111010001111110101001011101, + 0b10111010010000110001001011101, + 0b10000010010001010011001000001, + 0b11111110101010101010101111111, + 0b00000000000011001110100000000, + 0b01110110001011111101000000110, + 0b01001100011110000111111111101, + 0b11011111111000100010001101010, + 0b11000100000111110000000100001, + 0b10011110110110100100110100111, + 0b01110000110101001001001101101, + 0b10111011000100100111111111011, + 0b11000000110010111001100011001, + 0b01011111100100100011110011000, + 0b00111000101100001000110100100, + 0b10101011101110110110001111000, + 0b00011000110000100101011001100, + 0b01111011111001001110111110101, + 0b00000000100001101010100011011, + 0b11111110001101001101101010110, + 0b10000010111101011010100010011, + 0b10111010011010101100111111100, + 0b10111010100111011011000010101, + 0b10111010110111001000100101001, + 0b10000010101110110000110011010, + 0b11111110000011100010101101010, +]; +/// The modules on one side of a version-3 symbol. +const SIZE: i32 = 29; /// The module side in pixels, and the quiet zone in modules (BLE spec §9.2). const MODULE: i32 = 5; const QUIET: i32 = 4; -/// The side of the code with its quiet zone: 33 modules and the quiet zone either side. -pub(crate) const CODE_PX: i32 = (4 * 4 + 17 + 2 * QUIET) * MODULE; - -/// The pairing link of `serial` (BLE spec §9.1). -pub(crate) fn link(serial: u64) -> heapless::String<53> { - let mut s = heapless::String::new(); - let _ = write!(s, "https://openbikecomputer.com/pair/?s={serial:016X}"); - s -} +/// The side of the code with its quiet zone. +const CODE_PX: i32 = (SIZE + 2 * QUIET) * MODULE; -/// The link of `serial` encoded as §9.2 specifies. The caller lends the two buffers. -fn encode<'a>(serial: u64, data: &mut [u8; BUF], out: &'a mut [u8; BUF]) -> Option> { - let link = link(serial); - data[..link.len()].copy_from_slice(link.as_bytes()); - QrCode::encode_binary(data, link.len(), out, QrCodeEcc::Medium, VERSION, VERSION, None, false).ok() +fn dark(x: i32, y: i32) -> bool { + MODULES[y as usize] >> (SIZE - 1 - x) & 1 == 1 } -/// Draw the code of `serial` with its quiet zone, its top-left at `(x, y)`. The modules are dark on -/// a light square in either theme: the two colours are ones the theme mapping keeps. -fn draw_code(cv: &mut impl Surface, x: i32, y: i32, serial: u64) { +/// Draw the code with its quiet zone, its top-left at `(x, y)`. The modules are dark on a light +/// square in either theme: the two colours are ones the theme mapping keeps. +fn draw_code(cv: &mut impl Surface, x: i32, y: i32) { use palette::*; - let (mut data, mut out) = ([0; BUF], [0; BUF]); - let Some(qr) = encode(serial, &mut data, &mut out) else { return }; cv.round(rect(x, y, CODE_PX, CODE_PX), 4, ART_WHITE); - let (x0, y0, n) = (x + QUIET * MODULE, y + QUIET * MODULE, qr.size()); + let (x0, y0) = (x + QUIET * MODULE, y + QUIET * MODULE); // One fill for each run of dark modules in a row. - for my in 0..n { + for my in 0..SIZE { let mut mx = 0; - while mx < n { + while mx < SIZE { let start = mx; - while mx < n && qr.get_module(mx, my) { + while mx < SIZE && dark(mx, my) { mx += 1; } if mx > start { @@ -67,11 +84,16 @@ fn draw_code(cv: &mut impl Surface, x: i32, y: i32, serial: u64) { } } -/// The code under the title bar, and the line under it that says to scan it. +/// The code under the title bar, the name the OBC advertises under it (BLE spec §9.3), and the +/// line that says to scan it. pub(crate) fn code_page(cv: &mut impl Surface, rx: &Render) { - let top = TITLE_BAR_H + 6; - draw_code(cv, (rx.w - CODE_PX) / 2, top, rx.state.serial); - let caption = Point::new(rx.w / 2, top + CODE_PX + 3); + let top = TITLE_BAR_H + 4; + draw_code(cv, (rx.w - CODE_PX) / 2, top); + let rename = rx.settings.device_name.as_str(); + let name = if rename.is_empty() { rx.factory_name } else { rename }; + let name_at = Point::new(rx.w / 2, top + CODE_PX + 2); + cv.text(&fit(name, rx.w - 16, Font::Label), name_at, Font::Label, TextAlign::Center, palette::INK); + let caption = name_at + Point::new(0, Font::Label.line_height() as i32); cv.text(rx.t(Msg::PairScan), caption, Font::Caption, TextAlign::Center, palette::INK); } @@ -97,27 +119,24 @@ impl PairPhoneScreen { #[cfg(test)] mod tests { use super::*; + use qrcodegen_no_heap::{QrCode, QrCodeEcc, Version}; - /// The serial is the Serial Number String: 16 uppercase hex digits, zero-padded. - #[test] - fn the_link_spells_the_serial_as_sixteen_uppercase_hex_digits() { - assert_eq!(link(0x0123_4567_89AB_CDEF), "https://openbikecomputer.com/pair/?s=0123456789ABCDEF"); - assert_eq!(link(0xAB), "https://openbikecomputer.com/pair/?s=00000000000000AB"); - assert_eq!(link(u64::MAX).len(), 53, "the link is 53 bytes"); - } + const LINK: &str = "https://openbikecomputer.com/app"; - /// Every serial encodes as version 4 at level M, so every code has the side the page lays out. + /// The bitmap is the symbol an encoder makes from the link as §9.2 specifies, module for module. #[test] - fn every_serial_encodes_as_a_version_4_level_m_symbol() { - for serial in [0, 0x0123_4567_89AB_CDEF, u64::MAX] { - let (mut data, mut out) = ([0; BUF], [0; BUF]); - let qr = encode(serial, &mut data, &mut out).expect("the link fits version 4 at level M"); - assert_eq!(qr.version(), VERSION); - // The level is the format information's first two bits, unmasked (ISO/IEC 18004 - // §7.9): M is 00. The crate's own accessor skips the unmasking. - let level = (u8::from(qr.get_module(0, 8)) << 1 | u8::from(qr.get_module(1, 8))) ^ 0b10; - assert_eq!(level, 0b00, "level M"); - assert_eq!(qr.size() * MODULE + 2 * QUIET * MODULE, CODE_PX); + fn the_modules_are_the_link_encoded_as_version_3_level_q() { + const VERSION: Version = Version::new(3); + let (mut data, mut out) = ([0; VERSION.buffer_len()], [0; VERSION.buffer_len()]); + data[..LINK.len()].copy_from_slice(LINK.as_bytes()); + let qr = + QrCode::encode_binary(&mut data, LINK.len(), &mut out, QrCodeEcc::Quartile, VERSION, VERSION, None, false) + .expect("the link fits version 3 at level Q"); + assert_eq!(qr.size(), SIZE); + for y in 0..SIZE { + for x in 0..SIZE { + assert_eq!(dark(x, y), qr.get_module(x, y), "module ({x}, {y})"); + } } } } diff --git a/firmware/obc-fw-nrf54l/Cargo.lock b/firmware/obc-fw-nrf54l/Cargo.lock index e715cd3fd..542490d9b 100644 --- a/firmware/obc-fw-nrf54l/Cargo.lock +++ b/firmware/obc-fw-nrf54l/Cargo.lock @@ -1256,7 +1256,6 @@ dependencies = [ "obc-reader", "obc-render", "obc-route", - "qrcodegen-no-heap", ] [[package]] @@ -1568,12 +1567,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "qrcodegen-no-heap" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0e2c0bf8be8a1c4a4f48973dabf26943f05da2bfc2d3180aae62409dbba6f0c" - [[package]] name = "quote" version = "1.0.46" diff --git a/firmware/obc-fw-nrf54l/src/link/identity.rs b/firmware/obc-fw-nrf54l/src/link/identity.rs index f4e1aebff..ae239fd00 100644 --- a/firmware/obc-fw-nrf54l/src/link/identity.rs +++ b/firmware/obc-fw-nrf54l/src/link/identity.rs @@ -51,17 +51,13 @@ pub(crate) fn resolved_name(store: &LinkControl) -> heapless::String<48> { s } -/// The 64-bit FICR `DEVICEID`, high word first, so its low 16 bits are [`device_name`]'s `XXXX`. -pub(crate) fn serial() -> u64 { - let (id0, id1) = device_id_words(); - (u64::from(id1) << 32) | u64::from(id0) -} - -/// The serial number string: [`serial`] as 16 uppercase hex digits. Also the USB `iSerialNumber`, -/// which is what makes a plugged-in device distinguishable in the browser's chooser. +/// The serial number string: the 64-bit FICR `DEVICEID` as 16 uppercase hex digits, high word +/// first, so its last four digits are [`device_name`]'s `XXXX`. Also the USB `iSerialNumber`, which +/// is what makes a plugged-in device distinguishable in the browser's chooser. pub(crate) fn serial_string() -> heapless::String<16> { + let (id0, id1) = device_id_words(); let mut s = heapless::String::new(); - let _ = core::fmt::write(&mut s, format_args!("{:016X}", serial())); + let _ = core::fmt::write(&mut s, format_args!("{:08X}{:08X}", id1, id0)); s } diff --git a/firmware/obc-fw-nrf54l/src/ride.rs b/firmware/obc-fw-nrf54l/src/ride.rs index 4787f3ae2..c82084624 100644 --- a/firmware/obc-fw-nrf54l/src/ride.rs +++ b/firmware/obc-fw-nrf54l/src/ride.rs @@ -730,7 +730,7 @@ pub(crate) async fn run_app( let mut backlight_level = u8::MAX; app.set_backlight_available(obc_ports::Backlight::available(&backlight)); app.set_sound_available(obc_ports::Sounder::available(&buzzer)); - app.set_serial(crate::link::identity::serial()); + app.set_factory_name(&crate::link::identity::device_name()); // The map plane is one resident framebuffer that the present scans out of, so every repaint is a // repaint over the last frame. That is what lets the app leave a frozen base's rows alone while // a drawer's sheet grows over them. diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index 61d88bfcd..c838c738e 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -95,10 +95,10 @@ "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,964,500 B of the 2,019,328 B slot limit." }, "_pairing_code_allocation_measurement": { - "source_commit": "f5-qr on claude/zu-skills-install-x0qn6t 155753961", + "source_commit": "fixed app link on claude/zu-skills-install-x0qn6t, working tree on 2719683e", "build": "cargo build --release --locked --features resource-report on x86_64-unknown-linux-gnu, rustc 1.98.1 (App 58,448 B)", - "changes": "App +8 B: AppState keeps the factory serial that the pairing code carries. The QR encoder keeps no resident state; its two 138 B buffers live in the draw frame. All other named allocations and ceilings are unchanged.", - "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,969,916 B of the 2,019,328 B slot limit." + "changes": "App 58,448 B, unchanged: App keeps the 12 B factory name that the host declares, AppState drops the 8 B factory serial, and alignment padding takes the difference. The pairing code is a 116 B constant module table; no QR encoder links into the image. All other named allocations and ceilings are unchanged.", + "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,951,604 B of the 2,019,328 B slot limit, which is 12,896 B below the 1,964,500 B of the setup-step build." }, "_ride_zone_limits_allocation_measurement": { "source_commit": "claude/ride-zone-limits on develop 0a0b15804", diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 398cfc5d8..c0281d4c8 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1907,8 +1907,8 @@ script = "p p u d b p p p d" expect = "SetupTheme" args = ["--fresh"] -# Setup's pairing step after the theme step: the QR code of the pairing link, which the sim's -# serial makes the spec's example link. The code stays dark on light in the Dark theme. +# Setup's pairing step after the theme step: the QR code of the app link over the sim's factory +# name. The code stays dark on light in the Dark theme. [[frame]] name = "setup-qr" map = "{grimsel}" diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index a5c855dc2..efedabf89 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -182,7 +182,7 @@ f8b0cf201379675b5db0d3d38880545fb191df4b758cac591b9fc9171a754456 menu-de.png 48a35fa6b1e01972ec2d97a226df18a9899e7183fd4ea529928c2b6eea5f20a9 menu-fr.png 01e7eba6740469fcbf64d9a8feba011a2618869b08912ec07c1d38835077874d menu-pois.png 37eff17acaf4799bfced8df633e43f69f6b21f9f36a0c3dafceefbfb5e9a4e68 menu.png -3d02f42cc61aa232f99cbd12edb6cf86d5e02e8dff3ab6d9903ec3d2e9fe6000 pair-phone.png +bbabff3aba47c4588f27189258be8a74f9d216ff9dce0ea2f6fadaff800a45db pair-phone.png f219b744a652adb1a877e58f4e004ed9893454571778d708a8ef2d347ed3c800 passkey-card.png 99b89756ca6d977555dd691b30f6ee55b546a9a217d16742841afed18a179084 peak-article-back.png 99b89756ca6d977555dd691b30f6ee55b546a9a217d16742841afed18a179084 peak-article-indicator.png @@ -336,11 +336,11 @@ b58cd8802fb380914be14e60e97e19517de22b6fb392859b31d3e8eb58b4a684 setup-language 5ea022b742fc995d1976e24b088111daf26a045cc9d50f30ad5be44d6996bbd1 setup-no-app-es.png 4e3a8ded3158085680c230ba5906ce6f89b5354bbdcc48910344fa8bfca50216 setup-no-app-fr.png f50de6f1bb9a468c9ee0de251e16c32a7b8da3616278841ad25fc02999f6b7f0 setup-no-app.png -aab369d9dbd0f24736e32be1b521261be2d5bb28ac375f4e01fbe3f866ca8f7e setup-qr-dark.png -2bca403cfcdcbf115095648fe18a7e6bd2474aab1c51664a7f8a1b9e45061cc2 setup-qr-de.png -2169a9f8ac2c1bbb5970aedaa462c6cb1900598bdd3ae6344cdc45349f1957ef setup-qr-es.png -0fae1be3810fcb2ad86a342e473e384bdf506f00ff76c3059c7118f045e102e3 setup-qr-fr.png -712e05dba31af90b06a5be95177ed06f4bf573bc3ad6e5285122c848e0f40eec setup-qr.png +557695044d1badfafa2b1fc021622d79bbee03f798188070c13ed1d03a13cfba setup-qr-dark.png +9c038d20e74c53cb79d9837372920e0d1130c45a967d33bc8a3888da1a73af22 setup-qr-de.png +687db6befc17ec59b2ff80c31c974c85ad9269ab9868ed849157390f51c30153 setup-qr-es.png +84f87fb621ff54e31fc1c51196a95140db0ca429712369c293947501b1009721 setup-qr-fr.png +ae0575193829cd7b6a566cc9e9e0c85a856ecc48f27ea9ee8fc1f5f5a8ba61f4 setup-qr.png 64d1c5be55e33f0ed63c75750ee90ad68440ec29cec19fc4e52b6abf359c3961 setup-sensors-added.png 57498cf0d19c72506f58fcd53665f4677b0b54466e8d5b359efaee4c06e60e61 setup-sensors-de.png 9471b2d517e513d659f3049cd4d79dd394424386244524d50525620d2d14ccc5 setup-sensors-es.png diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index c72864a84..ff8f9988f 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -433,87 +433,65 @@ Days count from 0 in the object; the rider sees Day 1 for day 0. ## 9. Pairing link -During first-use setup the device shows a QR code. The code holds a universal link. The phone -camera opens the app from the link, and the app finds the one device that shows the code. Pairing -then runs as §8 specifies. The link carries no secret: the passkey stays the only proof that the -rider holds the device. +While no phone is paired, the device shows a QR code. The code holds one fixed universal link, the +same on every device. The link opens the companion app into its pairing flow, and pairing then runs +as §8 specifies. The link identifies no device and carries no secret: the passkey stays the only +proof that the rider holds the device. ### 9.1 The link ``` -https://openbikecomputer.com/pair/?s= +https://openbikecomputer.com/app ``` -| Part | Bytes | -|---|---| -| scheme and host | `https://openbikecomputer.com`, lowercase | -| path | `/pair/` | -| query | one item, `s=` | -| `` | the Serial Number String of §3.1: 16 uppercase hex digits, `[0-9A-F]{16}` | - -Example: `https://openbikecomputer.com/pair/?s=0123456789ABCDEF`. The link is 53 ASCII bytes. It has -no fragment and no percent-encoding. - -**Why the serial.** The app can use only what it sees before it pairs: - -- The device address is not available. CoreBluetooth gives an app an opaque identifier per phone, - never the address. -- The factory name `OBC-XXXX` holds only the last four digits of the serial. Two devices can have - the same factory name. -- The serial is the full 64-bit factory device id, and the open DIS serves it before pairing (§8). - -The serial is not secret. The device gives it to every central in range. +The link is these 32 ASCII bytes. It has no query, no fragment and no trailing slash. ### 9.2 The QR code | Property | Value | |---|---| | Symbol | QR Code model 2 (ISO/IEC 18004) | -| Version | 4 (33 × 33 modules) | -| Error correction | level M | -| Segment | one byte-mode segment with the 53 link bytes | -| Mask | the mask that the standard penalty rule selects | +| Version | 3 (29 × 29 modules) | +| Error correction | level Q | +| Segment | one byte-mode segment with the 32 link bytes | +| Mask | the mask that the standard penalty rule selects: mask 6 | | Module | 5 × 5 px or larger | | Quiet zone | 4 modules or more on each side | | Colors | dark modules on a light background, in each theme | -Version 4 at level M holds 62 bytes in byte mode. At 5 px per module the code and its quiet zone -use 205 × 205 px, which fits the 240 px panel width. +Version 3 is the smallest version that holds the link at level M. At version 3, level Q holds 32 +bytes in byte mode, so the link also fits at the higher level. At 5 px per module the code and its +quiet zone use 185 × 185 px. The symbol is fixed, so the device stores its modules and does not +encode at run time. ### 9.3 Device rules - The device shows the code only while its bond slot is empty (§8). A bonded device refuses every new pairing, so its code would be of no use. -- While the device shows the code, it advertises the OBC Control service UUID (§3.3) and its - factory name `OBC-XXXX`, where `XXXX` is the last four digits of the serial. First-use setup - follows a factory reset, which clears the name that the rider set. +- While the device shows the code, it advertises the OBC Control service UUID (§3.3) and its name. +- The device shows the name that it advertises under the code. With no stored name, this is the + factory name `OBC-XXXX`, where `XXXX` is the last four digits of the Serial Number String (§3.1). + First-use setup follows a factory reset, which clears the name that the rider set, so setup shows + the factory name. ### 9.4 App rules -1. **Validate.** The link is valid when the host is `openbikecomputer.com`, the path is `/pair/`, - and the query has exactly one `s` item that matches `[0-9A-F]{16}`. The app ignores query items - with other names. For an invalid link, the app tells the rider that the code is not an OBC - pairing code, and it does not scan. -2. **Scan.** The app scans for the OBC Control service UUID. A candidate is a device whose - advertised local name is `OBC-` followed by the last four digits of `s`. -3. **Match.** The app connects to a candidate and reads the Serial Number String (§3.1). It does no - gated operation first. When the value equals `s`, the app starts pairing (§8). When it does not, - the app disconnects and ignores that device for the rest of the scan. -4. **Not found.** When the scan window ends without a match, the app tells the rider that it did - not find the OBC and offers a new scan. - -The app never pairs with a device whose serial it did not match to `s`. After a match, a pairing -failure is as §8 specifies: a wrong passkey and a bonded device look the same to the app. +1. **Open.** The link opens the app into its pairing flow. +2. **Scan.** The app scans for the OBC Control service UUID. It lists each device that it finds and + has no bond with, by the advertised name. +3. **Pair.** When the scan finds exactly one device, the app connects to it and starts pairing (§8). + When it finds more, the rider selects the name that the device shows under the code. +4. **Not found.** When the scan window ends with no device, the app tells the rider that it did not + find the OBC and offers a new scan. -### 9.5 When the app is not installed +A pairing failure is as §8 specifies: a wrong passkey and a bonded device look the same to the app. -The phone opens the link in the browser. `https://openbikecomputer.com/pair/` serves one static page -for each query. The page: +### 9.5 When the app is not installed -- tells the rider that the code is for the OBC companion app, and how to install the app; -- tells the rider to scan the code on the OBC again after the install, because iOS does not give - the link to an app that it installs later; -- does not store or send `s`. +The phone opens the link in the browser. `https://openbikecomputer.com/app` redirects the browser to +the app's App Store page. Until a store listing exists, it redirects to the TestFlight invitation or +to a landing page. After the install, the rider scans the code again, because iOS does not give the +link to an app that it installs later. ### 9.6 Associated domains @@ -531,7 +509,7 @@ A universal link opens the app only when the app and the domain each name the ot "details": [ { "appIDs": [".com.openbikecomputer.companion"], - "components": [{ "/": "/pair/" }] + "components": [{ "/": "/app" }] } ] } @@ -539,6 +517,6 @@ A universal link opens the app only when the app and the domain each name the ot ``` `` is the Apple Developer team id that signs the release app. The component matches only -the path `/pair/`, so every other page of the site opens in the browser. The file does not limit -the query, because the app validates it (§9.4). iOS gets the file through the Apple CDN when it -installs or updates the app, so a change to the file does not reach a phone immediately. +the path `/app`, so every other page of the site opens in the browser. iOS gets the file through +the Apple CDN when it installs or updates the app, so a change to the file does not reach a phone +immediately. From 5b6c640b644e64ab29663542315ed6a1a66eefe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:58:01 +0000 Subject: [PATCH 16/37] onboarding: the pairing code says to unplug the cable A USB cable parks the radio while the rider's Bluetooth switch stays on, so no phone can find the OBC. The pairing code then shows "Unplug cable to pair" in place of the scan line, on the setup step and on the page that Connections opens. The app reads the interlock as the radio off with the switch on, which is the only state that gives it. "Unplug the cable to pair" is 240 px in the caption font, wider than the 228 px copy width, so the English line drops "the". German, French and Spanish get their own line. The copy-fit gate also measures the page with the radio off. The sim takes --ble off, and the new setup-qr-cable frame shows the line. No other frame changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- apps/obc-sim/src/main.rs | 22 ++++++++++++---------- firmware/obc-app/i18n/de.toml | 1 + firmware/obc-app/i18n/en.toml | 1 + firmware/obc-app/i18n/es.toml | 1 + firmware/obc-app/i18n/fr.toml | 1 + firmware/obc-app/src/harness/copy_fit.rs | 7 +++++++ firmware/obc-app/src/screen/pair_code.rs | 13 ++++++++----- firmware/ui-frames.toml | 9 +++++++++ firmware/ui-snapshots.sha256 | 1 + 9 files changed, 41 insertions(+), 15 deletions(-) diff --git a/apps/obc-sim/src/main.rs b/apps/obc-sim/src/main.rs index d18f2b7b4..8ea797750 100644 --- a/apps/obc-sim/src/main.rs +++ b/apps/obc-sim/src/main.rs @@ -44,7 +44,7 @@ const SIM_FACTORY_NAME: &str = "OBC-7A2F"; #[derive(Clone, Copy, Default, PartialEq, Eq)] struct BleSeed { - connected: bool, + link: obc_app::BleLink, paired: bool, passkey: Option, } @@ -405,7 +405,9 @@ fn parse_ble(s: &str) -> Result { let mut seed = BleSeed::default(); for part in s.split('+') { match part { - "connected" => seed.connected = true, + "connected" => seed.link = obc_app::BleLink::Connected, + // The radio parked with the rider's switch on: the board's USB cable interlock. + "off" => seed.link = obc_app::BleLink::Off, "paired" => seed.paired = true, _ if part.starts_with("passkey=") && seed.passkey.is_none() => { seed.passkey = Some( @@ -415,7 +417,11 @@ fn parse_ble(s: &str) -> Result { .ok_or("--ble passkey needs 0..=999999")?, ); } - _ => return Err("--ble needs connected, paired, and/or passkey=N joined by + (N is 0..=999999)".into()), + _ => { + return Err( + "--ble needs connected or off, paired, and/or passkey=N joined by + (N is 0..=999999)".into() + ) + } } } Ok(seed) @@ -1036,7 +1042,7 @@ Device state: --lang LANG UI language: en|de|fr|es --stat-fields LIST Comma-separated Statistics field ids --physical Use saved physical-size calibration in the GUI - --ble STATE connected|paired|passkey=N (join independent facts with +) + --ble STATE connected|off|paired|passkey=N (join independent facts with +) --sensors MODE demo|screen Scripted snapshots: @@ -1380,11 +1386,7 @@ fn main() { app.set_rides(ride_store.catalog(), ride_store.trip_names()); // Inject BLE before the script. `+` keeps independent link, bond and passkey facts. let ble = args.ble.unwrap_or_default(); - app.set_ble_status(obc_app::BleStatus { - link: if ble.connected { obc_app::BleLink::Connected } else { obc_app::BleLink::Advertising }, - passkey: ble.passkey, - paired: ble.paired, - }); + app.set_ble_status(obc_app::BleStatus { link: ble.link, passkey: ble.passkey, paired: ble.paired }); // Planner emission and map-referenced altitude share terrain from this retained map. let mut elev = map.elevation(); // The open ride log. Opened above the script, because every settling pass reconciles it @@ -1914,7 +1916,7 @@ mod cli_tests { assert_eq!(parse(&["--peak-view", "scheidegg"]).unwrap().peak_view, Some(peak_view::Preset::KleineScheidegg)); assert_eq!(parse(&["--ble", "passkey=42"]).unwrap().ble.unwrap().passkey, Some(42)); let linked_bond = parse(&["--ble", "connected+paired"]).unwrap().ble.unwrap(); - assert!(linked_bond.connected); + assert_eq!(linked_bond.link, obc_app::BleLink::Connected); assert!(linked_bond.paired); assert!(matches!( parse(&["--inject", "upload-replace=7"]).unwrap().inject, diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index 8f9810acc..c4e43fc75 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -388,6 +388,7 @@ on_phone = "am Handy" # --- The pairing code: the QR code of the pairing link --- [pair] scan = "Mit dem Handy scannen" +unplug = "Zum Koppeln abstecken" # --- Rides list screen --- [rides] diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index c3a28d5b1..8570f913e 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -416,6 +416,7 @@ on_phone = "on your phone" # --- The pairing code: the QR code of the pairing link --- [pair] scan = "Scan with your phone" +unplug = "Unplug cable to pair" # --- Rides list screen --- [rides] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index b311ce36a..959ea47b5 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -387,6 +387,7 @@ on_phone = "en el móvil" # --- The pairing code: the QR code of the pairing link --- [pair] scan = "Escanea con el móvil" +unplug = "Desconecta el cable" # --- Rides list screen --- [rides] diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index e85a203e7..8fd9e0a13 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -387,6 +387,7 @@ on_phone = "sur le tél." # --- The pairing code: the QR code of the pairing link --- [pair] scan = "Scannez avec le tél." +unplug = "Débranchez le câble" # --- Rides list screen --- [rides] diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 55b47bea2..086db0889 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -342,6 +342,13 @@ fn every_string_fits_the_panel_in_every_language() { offenders.extend(complaint(name, language, &drawn)); } } + // The pairing code's line while the USB interlock parks the radio. + let off = crate::BleStatus { link: crate::BleLink::Off, ..crate::BleStatus::DISCONNECTED }; + for (name, drawn) in walk(plain(vec![Screen::PairPhone(PairPhoneScreen)]).remove(0), language, &bytes, |app| { + app.set_ble_status(off) + }) { + offenders.extend(complaint(name, language, &drawn)); + } } report(offenders); } diff --git a/firmware/obc-app/src/screen/pair_code.rs b/firmware/obc-app/src/screen/pair_code.rs index c1b8a9ed0..5aad2e029 100644 --- a/firmware/obc-app/src/screen/pair_code.rs +++ b/firmware/obc-app/src/screen/pair_code.rs @@ -11,9 +11,9 @@ use obc_render::{ }; use crate::input::Gesture; -use crate::Msg; +use crate::{BleLink, Msg}; -use super::vocab::chrome::{title_frame, TITLE_BAR_H}; +use super::vocab::chrome::{copy_w, title_frame, TITLE_BAR_H}; use super::vocab::marquee::fit; use super::{palette, Ctx, Render, Transition}; @@ -85,16 +85,19 @@ fn draw_code(cv: &mut impl Surface, x: i32, y: i32) { } /// The code under the title bar, the name the OBC advertises under it (BLE spec §9.3), and the -/// line that says to scan it. +/// line that says to scan it. With the rider's switch on, the radio is off only under the board's +/// USB interlock, so that line then says to unplug the cable. pub(crate) fn code_page(cv: &mut impl Surface, rx: &Render) { let top = TITLE_BAR_H + 4; draw_code(cv, (rx.w - CODE_PX) / 2, top); let rename = rx.settings.device_name.as_str(); let name = if rename.is_empty() { rx.factory_name } else { rename }; let name_at = Point::new(rx.w / 2, top + CODE_PX + 2); - cv.text(&fit(name, rx.w - 16, Font::Label), name_at, Font::Label, TextAlign::Center, palette::INK); + cv.text(&fit(name, copy_w(rx.w), Font::Label), name_at, Font::Label, TextAlign::Center, palette::INK); let caption = name_at + Point::new(0, Font::Label.line_height() as i32); - cv.text(rx.t(Msg::PairScan), caption, Font::Caption, TextAlign::Center, palette::INK); + let cable = rx.settings.ble_enabled && rx.state.device.ble_link == BleLink::Off; + let line = if cable { Msg::PairUnplug } else { Msg::PairScan }; + cv.text(rx.t(line), caption, Font::Caption, TextAlign::Center, palette::INK); } /// The pairing code outside setup, opened from Connections. Back returns there, and a bond closes diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index c0281d4c8..545b09d64 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1931,6 +1931,15 @@ script = "p p u d b p p p d p" expect = "SetupQr" args = ["--fresh"] +# A USB cable parks the radio while the Bluetooth switch stays on, so the line under the name says +# to unplug it. +[[frame]] +name = "setup-qr-cable" +map = "{grimsel}" +script = "p p u d b p p p p" +expect = "SetupQr" +args = ["--fresh", "--ble", "off"] + # Back on the pairing step asks whether to ride without the app. Skip passes the step, which the # later setup frames do. [[frame]] diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index efedabf89..fe9f4a095 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -336,6 +336,7 @@ b58cd8802fb380914be14e60e97e19517de22b6fb392859b31d3e8eb58b4a684 setup-language 5ea022b742fc995d1976e24b088111daf26a045cc9d50f30ad5be44d6996bbd1 setup-no-app-es.png 4e3a8ded3158085680c230ba5906ce6f89b5354bbdcc48910344fa8bfca50216 setup-no-app-fr.png f50de6f1bb9a468c9ee0de251e16c32a7b8da3616278841ad25fc02999f6b7f0 setup-no-app.png +bd0c8f638320f465e33b75d537c1c632c561ad411db8cbb94051032ea6d297c7 setup-qr-cable.png 557695044d1badfafa2b1fc021622d79bbee03f798188070c13ed1d03a13cfba setup-qr-dark.png 9c038d20e74c53cb79d9837372920e0d1130c45a967d33bc8a3888da1a73af22 setup-qr-de.png 687db6befc17ec59b2ff80c31c974c85ad9269ab9868ed849157390f51c30153 setup-qr-es.png From cc0c1e9a779653dd831fe6f9b6190d4969bdd1c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:00:30 +0000 Subject: [PATCH 17/37] onboarding: the skip-app page is titled WITHOUT APP The English title of the page that Back opens on the pairing code is now "WITHOUT APP". It has the eleven cells of "GET THE APP", so its ink ends at x 165 and the 5/7 counter starts at x 192, a 26 px gap. The other languages keep their titles. Only setup-no-app changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/en.toml | 2 +- firmware/ui-snapshots.sha256 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 8570f913e..98b502f20 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -589,7 +589,7 @@ effort = "ZONES" # The pairing step: its title, its Back hint, and the page that asks to ride without the app. qr = "GET THE APP" no_app = "No app?" -no_app_title = "NO APP" +no_app_title = "WITHOUT APP" offline = "Works offline" needs_app = "Needs the app" recording = "Ride recording" diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index fe9f4a095..e5e3daef4 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -335,7 +335,7 @@ b58cd8802fb380914be14e60e97e19517de22b6fb392859b31d3e8eb58b4a684 setup-language 890a0ee5b56e8a9a5579010c720eb39adfed8c73677139a80c05840af45c875d setup-no-app-de.png 5ea022b742fc995d1976e24b088111daf26a045cc9d50f30ad5be44d6996bbd1 setup-no-app-es.png 4e3a8ded3158085680c230ba5906ce6f89b5354bbdcc48910344fa8bfca50216 setup-no-app-fr.png -f50de6f1bb9a468c9ee0de251e16c32a7b8da3616278841ad25fc02999f6b7f0 setup-no-app.png +567ddcce9db29e7a93e768b2d040decb2c5ede8d4d5e93ae5db72c1a95799465 setup-no-app.png bd0c8f638320f465e33b75d537c1c632c561ad411db8cbb94051032ea6d297c7 setup-qr-cable.png 557695044d1badfafa2b1fc021622d79bbee03f798188070c13ed1d03a13cfba setup-qr-dark.png 9c038d20e74c53cb79d9837372920e0d1130c45a967d33bc8a3888da1a73af22 setup-qr-de.png From fc00848256e87f008f387964df0285166d03a5f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:04:45 +0000 Subject: [PATCH 18/37] sensors: the scan list scrolls The scan list drew every hit from the title bar down, so a fifth hit ran past the outline in Settings and the fourth ran into the hint band in setup. The list now draws the window that keeps the cursor on the panel, down to a bottom the caller gives: the outline in Settings (four rows), the hint band less 12 px in setup (three rows). The shared window_start and scrollbar do the scrolling, so the scrollbar shows when the hits do not fit. A test renders six hits on both screens, steps the cursor to the last one and checks that it shows, that the first one scrolled off, and that no name draws below the bottom. No frame changes: the sensors-scan frames hold fewer hits than fit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- .../obc-app/src/screen/settings/sensors.rs | 50 +++++++++++++++++-- firmware/obc-app/src/screen/setup.rs | 2 +- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/firmware/obc-app/src/screen/settings/sensors.rs b/firmware/obc-app/src/screen/settings/sensors.rs index 714c07f3a..e2338477a 100644 --- a/firmware/obc-app/src/screen/settings/sensors.rs +++ b/firmware/obc-app/src/screen/settings/sensors.rs @@ -12,6 +12,7 @@ use obc_render::Surface; use crate::input::Gesture; use crate::screen::vocab::chrome::{empty_state, title_frame, LIST_TOP}; use crate::screen::vocab::fmt::write_ble_address; +use crate::screen::vocab::list::{scrollbar, window_start}; use crate::screen::vocab::rows::{self, row_rect, Line2, ROW_GAP, ROW_TWO}; use crate::screen::{Ctx, Render, Screen, Transition}; use crate::sensors::{SensorPhase, SensorStatus}; @@ -171,11 +172,12 @@ impl SensorScanScreen { pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { let (w, h) = (rx.w, rx.h); title_frame(cv, w, h, rx.t(kind_msg(self.slot as usize)), ""); - self.draw_list(cv, rx); + self.draw_list(cv, rx, h - 6); } - /// The hits under the title bar, or the scanning state while there is none. - pub(crate) fn draw_list(&self, cv: &mut impl Surface, rx: &Render) { + /// The hits under the title bar down to `bottom`, or the scanning state while there is none. + /// More hits than fit scroll the window with the cursor. + pub(crate) fn draw_list(&self, cv: &mut impl Surface, rx: &Render, bottom: i32) { let (w, h) = (rx.w, rx.h); let len = self.count(rx.sensor_scan_hits); if len == 0 { @@ -184,8 +186,10 @@ impl SensorScanScreen { } let selected = self.selected.min(len - 1); - for (i, hit) in self.hits(rx.sensor_scan_hits).enumerate() { - let y = LIST_TOP + i as i32 * (ROW_TWO + ROW_GAP); + let visible = ((bottom - LIST_TOP + ROW_GAP) / (ROW_TWO + ROW_GAP)).max(1) as usize; + let first = window_start(selected, visible, len); + for (i, hit) in self.hits(rx.sensor_scan_hits).enumerate().skip(first).take(visible) { + let y = LIST_TOP + (i - first) as i32 * (ROW_TWO + ROW_GAP); let row = row_rect(y, w, ROW_TWO); let mut addr = heapless::String::<24>::new(); if hit.name.is_empty() { @@ -196,6 +200,7 @@ impl SensorScanScreen { let _ = write!(rssi, "{} dBm", hit.rssi); rows::nav_row(cv, row, name, Some(Line2::text(&rssi)), i == selected, true, false); } + scrollbar(cv, w - 8, LIST_TOP, bottom - LIST_TOP, len, first, visible); } } @@ -352,4 +357,39 @@ mod tests { status_line(&mut b, true, SensorStatus { phase: SensorPhase::Connected, battery: None, last_value_ms: 0 }, en); assert_eq!(b.as_str(), "Connected", "no battery → no percent tail"); } + + /// More hits than fit scroll with the cursor, and no row reaches past the list's bottom: the + /// outline in Settings, the hint band in setup. + #[test] + fn the_scan_list_scrolls_with_the_cursor_and_stays_above_its_bottom() { + use crate::harness::support::{build_min_obcm, Buf}; + use crate::screen::SetupSensorScanScreen; + use crate::App; + let bytes = build_min_obcm(0xF800); + let src = obc_reader::SliceSource(&bytes); + let tables = obc_reader::MapTables::parse(&src).expect("valid fixture"); + let cache = obc_reader::MapCache::new(); + let reader = obc_reader::Reader::new(&src, &tables, &cache); + let hits: Vec = (0..6).map(|i| hit(1, &format!("Meter {i}"), -40)).collect(); + for (screen, bottom) in [ + (Screen::SensorScan(SensorScanScreen::new(1)), 314), + (Screen::SetupSensorScan(SetupSensorScanScreen::new(1)), 260), + ] { + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + assert!(app.ui.stack.push(screen).is_ok()); + app.set_sensor_scan_hits(&hits); + app.apply_gesture(Gesture::Step(-1)); + let (mut scratch, mut buf) = (Box::new(obc_render::RenderScratch::new()), Buf::new(240, 320)); + let drawn = obc_render::text_tap::record(|| { + app.render_frame(Some(&mut scratch), &mut buf, &reader, None, 240.0, 320.0, |c| { + let (r, g, b) = obc_reader::rgb565_to_rgb888(c); + embedded_graphics::pixelcolor::Rgb888::new(r, g, b) + }); + }); + let names: Vec<_> = drawn.iter().filter(|d| d.text.starts_with("Meter")).collect(); + assert!(names.iter().any(|d| d.text == "Meter 5"), "the cursor wrapped to the last hit, and it shows"); + assert!(!names.iter().any(|d| d.text == "Meter 0"), "the window left the first hit"); + assert!(names.iter().all(|d| d.area.top_left.y + d.area.size.height as i32 <= bottom)); + } + } } diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index 882f0b934..ed354bd8f 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -432,7 +432,7 @@ impl SetupSensorScanScreen { pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { let (w, h) = (rx.w, rx.h); title_bar(cv, w, h, SetupStep::Sensors, rx.t(kind_msg(self.0.slot as usize))); - self.0.draw_list(cv, rx); + self.0.draw_list(cv, rx, h - 8 - HINT_H - 12); hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(Key::Ok(rx.t(Msg::SetupOk)))); } } From 28c1aa4be34255871159629b4e7ced69f619e99f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:09:38 +0000 Subject: [PATCH 19/37] resources: re-pin the pairing-code note on the branch head One head build of fc008482; no base rebuild. The board guards pass: 315,744 B linked resident, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,952,188 B of the 2,019,328 B slot limit. App stays 58,448 B and the allocation report matches the baseline. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- 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 c838c738e..fe148b749 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -95,10 +95,10 @@ "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,964,500 B of the 2,019,328 B slot limit." }, "_pairing_code_allocation_measurement": { - "source_commit": "fixed app link on claude/zu-skills-install-x0qn6t, working tree on 2719683e", + "source_commit": "fixed app link on claude/zu-skills-install-x0qn6t fc008482", "build": "cargo build --release --locked --features resource-report on x86_64-unknown-linux-gnu, rustc 1.98.1 (App 58,448 B)", "changes": "App 58,448 B, unchanged: App keeps the 12 B factory name that the host declares, AppState drops the 8 B factory serial, and alignment padding takes the difference. The pairing code is a 116 B constant module table; no QR encoder links into the image. All other named allocations and ceilings are unchanged.", - "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,951,604 B of the 2,019,328 B slot limit, which is 12,896 B below the 1,964,500 B of the setup-step build." + "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,952,188 B of the 2,019,328 B slot limit, which is 12,312 B below the 1,964,500 B of the setup-step build." }, "_ride_zone_limits_allocation_measurement": { "source_commit": "claude/ride-zone-limits on develop 0a0b15804", From 4f89985cc7ddfc22f6417f7140801c3b7b2efd7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:30:35 +0000 Subject: [PATCH 20/37] onboarding: a bond on the pairing step opens a Paired page When a phone bonds on the pairing step, setup saves the next step as before, and then opens a PAIRED page in place of the sensors step. The page shows the success check, "Phone paired", and two lines: the phone goes on in the app, and this OBC goes on here. The lines use the phone glyph of the skip-app page and a new OBC glyph with an amber screen. Select opens the saved step. Back goes to the theme step, because a bonded device passes the pairing step by. The page is screen state, not a persisted step: after a power loss setup resumes on the sensors step. A rejected or failed pairing clears the passkey without a bond, so the passkey card closes onto the code. This was already the behaviour, and the tests now cover it on both paths. From Connections a bond still returns to Connections. The rider opened the code there, and the phone row and Forget phone now show the bond. The "goes on here" copy belongs to setup only. The sim script token P feeds a bond, so the new setup-paired frames reach the page from --fresh. The copy-fit gate measures the page in all four languages. No other frame changes. App and the allocation report are unchanged. The shipping image is 1,954,116 B of the 2,019,328 B slot limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- apps/obc-sim/src/main.rs | 8 +++- firmware/obc-app/i18n/de.toml | 4 ++ firmware/obc-app/i18n/en.toml | 5 +++ firmware/obc-app/i18n/es.toml | 4 ++ firmware/obc-app/i18n/fr.toml | 4 ++ firmware/obc-app/src/app.rs | 34 +++++++++++---- firmware/obc-app/src/harness/copy_fit.rs | 1 + firmware/obc-app/src/screen/mod.rs | 7 ++- firmware/obc-app/src/screen/setup.rs | 54 +++++++++++++++++++++++- firmware/ui-frames.toml | 16 +++++++ firmware/ui-snapshots.sha256 | 4 ++ 11 files changed, 127 insertions(+), 14 deletions(-) diff --git a/apps/obc-sim/src/main.rs b/apps/obc-sim/src/main.rs index 8ea797750..1a4628352 100644 --- a/apps/obc-sim/src/main.rs +++ b/apps/obc-sim/src/main.rs @@ -997,6 +997,12 @@ fn apply_script(app: &mut App, script: &str, start_ms: u32, hook: &mut dyn FnMut now += 5 * 60_000 + 1_000; feed(app, now, vec![]); } + // A phone bonds: the link is up and the passkey clears, as the board reports a bond. + 'P' => app.set_ble_status(obc_app::BleStatus { + link: obc_app::BleLink::Connected, + passkey: None, + paired: true, + }), other => eprintln!("warning: ignoring unknown --script token '{other}'"), } hook(app, ScriptHook::After(ch), now); @@ -1050,7 +1056,7 @@ Scripted snapshots: --script TOKENS Apply device-button script tokens before rendering (d/u step, p press, b back, h/B hold, H/M partial hold, Q quick-drawer tap, A held Up+Select (Assistant), C context-drawer squeeze, - w wait, f frame, T tick, I idle) + w wait, f frame, T tick, I idle, P a phone bonds) --trip-progress D:M:L The first trip's progress: day D (from 0), M metres into it, and the last finished day L (or -) --no-backlight Model a panel with no controllable light (three quick-drawer controls) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index c4e43fc75..d2f2877c6 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -556,6 +556,10 @@ needs_app = "Braucht die App" recording = "Aufzeichnung" ride_sync = "Fahrten-Sync" show_code = "Code zeigen" +paired_title = "GEKOPPELT" +phone_paired = "Handy gekoppelt" +app_goes_on = "In der App am Handy weiter" +obc_goes_on = "Hier am OBC weiter" # --- Wiederhergestellte Fahrt beim Start --- [ride_recovery] diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 98b502f20..5769ba995 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -595,6 +595,11 @@ needs_app = "Needs the app" recording = "Ride recording" ride_sync = "Ride sync" show_code = "Show the code" +# The page a bond opens on the pairing step: what goes on where. +paired_title = "PAIRED" +phone_paired = "Phone paired" +app_goes_on = "Your phone goes on in the app" +obc_goes_on = "This OBC goes on here" # --- Boot-time recovered ride --- [ride_recovery] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 959ea47b5..1c3d41a8f 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -555,6 +555,10 @@ needs_app = "Necesita la app" recording = "Grabación" ride_sync = "Sync de salidas" show_code = "Ver el código" +paired_title = "VINCULADO" +phone_paired = "Móvil vinculado" +app_goes_on = "Sigue en la app del móvil" +obc_goes_on = "Sigue aquí en el OBC" # --- Salida recuperada al arrancar --- [ride_recovery] diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 8fd9e0a13..30ff10eef 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -555,6 +555,10 @@ needs_app = "Il faut l'appli" recording = "Enregistrement" ride_sync = "Synchro sorties" show_code = "Voir le code" +paired_title = "APPAIRÉ" +phone_paired = "Tél. appairé" +app_goes_on = "Suite dans l'appli du tél." +obc_goes_on = "Suite ici, sur l'OBC" # --- Sortie récupérée au démarrage --- [ride_recovery] diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 4ba9883e6..c2646ec94 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1883,7 +1883,8 @@ impl App { } /// Close the pairing code, which shows only while the bond slot is empty (BLE spec §9.3). In - /// setup a bond ends the pairing step; the page Connections opened returns there. + /// setup a bond ends the pairing step and opens the page that confirms it; the page Connections + /// opened returns there, where the phone row shows the bond. fn close_pairing_code(&mut self) { let Some(i) = self.ui.stack.iter().position(|s| matches!(s, Screen::SetupQr(_) | Screen::PairPhone(_))) else { return; @@ -1891,7 +1892,7 @@ impl App { if matches!(self.ui.stack[i], Screen::SetupQr(_)) { self.settings.setup = screen::setup::after(crate::settings::SetupStep::Qr, true); self.settings_ops.note_edited(); - screen::apply(&mut self.ui.stack, screen::setup::go_to(&self.settings)); + screen::apply(&mut self.ui.stack, screen::Transition::Root(Screen::SetupPaired(screen::SetupPairedScreen))); } else { self.ui.stack.truncate(i); } @@ -6277,22 +6278,33 @@ mod tests { assert_eq!(app.settings().setup, SetupStep::Sensors); } - /// A bond ends the pairing step, under the passkey card too, and the step is saved. A bonded - /// device passes the step by in both directions, because it shows no code (BLE spec §9.3). + /// A rejected pairing closes the passkey card onto the code. A bond ends the pairing step, + /// under the passkey card too: the step after it is saved at once, and the page that confirms + /// the bond opens. Its Select opens that step, and its Back the step before the pairing step, + /// which a bonded device passes by in both directions (BLE spec §9.3). #[test] - fn a_bond_ends_the_pairing_step_and_a_bonded_device_passes_it_by() { + fn a_bond_confirms_the_pairing_step_and_a_bonded_device_passes_it_by() { use crate::ble::{BleLink, BleStatus}; use crate::settings::SetupStep; let mut app = App::new_idle(AppState::new(0, 0, 1.0)); app.set_settings(Settings { setup: SetupStep::Qr, ..Settings::FACTORY }); let mut host = SettingsHost::default(); + let pairing = BleStatus { link: BleLink::Connected, passkey: Some(123_456), paired: false }; - app.set_ble_status(BleStatus { link: BleLink::Connected, passkey: Some(123_456), paired: false }); + app.set_ble_status(pairing); assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupQr(_), Screen::Passkey(_)])); + app.set_ble_status(BleStatus { passkey: None, ..pairing }); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupQr(_)]), "a rejection"); + + app.set_ble_status(pairing); app.set_ble_status(BleStatus { link: BleLink::Connected, passkey: None, paired: true }); - assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupSensors(_)])); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupPaired(_)])); assert_eq!(app.settings().setup, SetupStep::Sensors); assert!(host.drain(&mut app).is_some(), "the step is saved"); + app.apply_gesture(Gesture::BackHold); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupPaired(_)]), "no escape"); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupSensors(_)])); app.apply_gesture(Gesture::Back); assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupTheme(_)])); @@ -6300,8 +6312,8 @@ mod tests { assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupSensors(_)])); } - /// Connections offers the pairing code while no phone is paired. Back returns to the page, and - /// so does a bond. + /// Connections offers the pairing code while no phone is paired. Back returns to the page, a + /// rejected pairing to the code, and a bond to the page, where the phone row shows it. #[test] fn connections_opens_the_pairing_code_until_a_phone_pairs() { use crate::ble::{BleLink, BleStatus}; @@ -6318,6 +6330,10 @@ mod tests { assert!(matches!(app.ui.stack.last(), Some(Screen::Connections(_)))); app.apply_gesture(Gesture::Press); + let pairing = BleStatus { link: BleLink::Connected, passkey: Some(123_456), paired: false }; + app.set_ble_status(pairing); + app.set_ble_status(BleStatus { passkey: None, ..pairing }); + assert!(matches!(app.ui.stack.last(), Some(Screen::PairPhone(_))), "a rejection returns to the code"); app.set_ble_status(BleStatus { link: BleLink::Connected, passkey: None, paired: true }); assert!(matches!(app.ui.stack.last(), Some(Screen::Connections(_))), "a bond closes the code"); } diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 086db0889..0b52a28b7 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -122,6 +122,7 @@ fn seeds(language: Language) -> Vec { Screen::SetupTheme(SetupThemeScreen(crate::settings::Theme::Light)), Screen::SetupQr(SetupQrScreen), Screen::SetupNoApp(SetupNoAppScreen::default()), + Screen::SetupPaired(SetupPairedScreen), Screen::PairPhone(PairPhoneScreen), Screen::SetupSensors(SetupSensorsScreen::default()), Screen::SetupSensorScan(SetupSensorScanScreen::new(1)), diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index c4e30e0c5..c9886bec0 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -120,8 +120,8 @@ pub use settings::{ StatFieldsScreen, }; pub use setup::{ - HelloScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupNoAppScreen, SetupQrScreen, - SetupSensorScanScreen, SetupSensorsScreen, SetupThemeScreen, SetupUnitsScreen, + HelloScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupNoAppScreen, SetupPairedScreen, + SetupQrScreen, SetupSensorScanScreen, SetupSensorsScreen, SetupThemeScreen, SetupUnitsScreen, }; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; @@ -930,6 +930,9 @@ screens! { SetupQr(SetupQrScreen) => Caps::modal().blocking(), /// Ride without the app: a row back to the code and Skip, which ends the pairing step. SetupNoApp(SetupNoAppScreen) => Caps::modal().blocking(), + /// The page a bond opens on the pairing step: the phone is paired, and each side goes on with + /// its own steps. Select opens the next step. + SetupPaired(SetupPairedScreen) => Caps::modal().blocking(), /// Setup's sensors step: the three sensor slots with their live status, then Skip or Continue. SetupSensors(SetupSensorsScreen) => Caps::modal().blocking().key(RenderKeyKind::SensorSettings), /// The Settings scan list for one slot in the setup chrome, opened from the sensors step. It diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index ed354bd8f..a936fd04f 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -28,7 +28,9 @@ use super::context_drawer::{ContextDrawerScreen, ContextValue}; use super::home::contours; use super::pair_code::code_page; use super::settings::{kind_msg, status_line, wake_msg, LanguageScreen, SensorScanScreen}; -use super::vocab::chrome::{copy_w, row_check, title_frame, wrapped, wrapped_line_pitch, LIST_TOP}; +use super::vocab::chrome::{ + card_check, copy_w, row_check, title_frame, wrapped, wrapped_aligned, wrapped_line_pitch, LIST_TOP, TITLE_BAR_H, +}; use super::vocab::flags::{FLAG_H, FLAG_W}; use super::vocab::list::on_step; use super::vocab::rows::{ @@ -514,7 +516,44 @@ impl SetupNoAppScreen { } } -/// A phone centred on `(cx, cy)`, the height of a Label capital: what needs the app. The outline +/// The page a bond opens over the pairing step: the phone is paired, the app goes on on the phone, +/// and setup goes on here. The bond has already saved the next step, so a power loss resumes there. +/// Select goes on, and Back walks to the step before the pairing step, as on the skip-app page. +#[derive(Debug)] +pub struct SetupPairedScreen; + +impl SetupPairedScreen { + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + match g { + Gesture::Press => go_to(cx.settings), + Gesture::Back => back(SetupStep::Qr, cx), + _ => Transition::None, + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + use palette::*; + let (w, h) = (rx.w, rx.h); + title_bar(cv, w, h, SetupStep::Qr, rx.t(Msg::SetupPairedTitle)); + card_check(cv, Point::new(w / 2, TITLE_BAR_H + 46), 20); + let mut y = TITLE_BAR_H + 78; + cv.text(rx.t(Msg::SetupPhonePaired), Point::new(w / 2, y), Font::Body, TextAlign::Center, INK); + y += Font::Body.line_height() as i32 + 20; + let (x, text_w) = (ROW_X + 24, w - 2 * ROW_X - 24); + for (i, line) in [Msg::SetupAppGoesOn, Msg::SetupObcGoesOn].into_iter().enumerate() { + let cy = y + Font::Label.cap_mid() as i32; + if i == 0 { + phone(cv, ROW_X + 8, cy); + } else { + obc(cv, ROW_X + 8, cy); + } + y = wrapped_aligned(cv, rx.t(line), x, y, text_w, Font::Label, TextAlign::Left, INK) + 14; + } + hint(cv, w, h, false, rx.t(Msg::SetupContinue), Some(Key::Ok(rx.t(Msg::SetupOk)))); + } +} + +/// A phone centred on `(cx, cy)`, the height of a Label capital: the app. The outline /// is doubled for a 2 px stroke, as the passkey card's phone is. fn phone(cv: &mut impl Surface, cx: i32, cy: i32) { use palette::*; @@ -523,6 +562,17 @@ fn phone(cv: &mut impl Surface, cx: i32, cy: i32) { cv.hline(cx - 1, cy + 4, 2, INK); } +/// The OBC centred on `(cx, cy)`, beside [`phone`] and as tall: a wider body round a filled screen, +/// with a button on each flank. +fn obc(cv: &mut impl Surface, cx: i32, cy: i32) { + use palette::*; + cv.round_outline(rect(cx - 6, cy - 8, 12, 17), 3, INK); + cv.round_outline(rect(cx - 5, cy - 7, 10, 15), 2, INK); + cv.fill(rect(cx - 3, cy - 5, 6, 8), AMBER); + cv.fill(rect(cx - 8, cy - 2, 2, 5), INK); + cv.fill(rect(cx + 6, cy - 2, 2, 5), INK); +} + /// A theme's page in the flag slot: its paper, its ink round the edge, and two lines of text. The /// colours are ones the theme mapping keeps, so each swatch shows its own theme on either page. fn swatch(cv: &mut impl Surface, x: i32, y: i32, theme: Theme) { diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 545b09d64..ef5c854e6 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1957,6 +1957,22 @@ expect = "SetupNoApp" args = ["--fresh"] langs = ["de", "fr", "es"] +# A phone bonds on the pairing step: the page that confirms it, before the sensors step. +[[frame]] +name = "setup-paired" +map = "{grimsel}" +script = "p p u d b p p p p P" +expect = "SetupPaired" +args = ["--fresh"] + +[[frame]] +name = "setup-paired" +map = "{grimsel}" +script = "p p u d b p p p p P" +expect = "SetupPaired" +args = ["--fresh"] +langs = ["de", "fr", "es"] + # Setup's sensors step with no sensor saved: each slot says how to wake its sensor, over Skip. [[frame]] name = "setup-sensors" diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index e5e3daef4..e4da6e682 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -336,6 +336,10 @@ b58cd8802fb380914be14e60e97e19517de22b6fb392859b31d3e8eb58b4a684 setup-language 5ea022b742fc995d1976e24b088111daf26a045cc9d50f30ad5be44d6996bbd1 setup-no-app-es.png 4e3a8ded3158085680c230ba5906ce6f89b5354bbdcc48910344fa8bfca50216 setup-no-app-fr.png 567ddcce9db29e7a93e768b2d040decb2c5ede8d4d5e93ae5db72c1a95799465 setup-no-app.png +be26d48121e097b8083584f9f3dedb216a73b607f2a8bbb39acb879058533174 setup-paired-de.png +b777653980d448db79959f67483531bb8b0718c4e4774493682e2340af5574df setup-paired-es.png +fdff710a49113fc839cc95b8f593c503ead9abcef3a4fc6ae37d44591200719b setup-paired-fr.png +481aa9f9f120a99a9fb5ae7ff2c5a8fae8a34bdbabea715b10c1abaed0753618 setup-paired.png bd0c8f638320f465e33b75d537c1c632c561ad411db8cbb94051032ea6d297c7 setup-qr-cable.png 557695044d1badfafa2b1fc021622d79bbee03f798188070c13ed1d03a13cfba setup-qr-dark.png 9c038d20e74c53cb79d9837372920e0d1130c45a967d33bc8a3888da1a73af22 setup-qr-de.png From 96a66d092fb02795b77329501e6bff36beed56ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:33:37 +0000 Subject: [PATCH 21/37] onboarding: end setup on an All set page, then Home Setup now ends with an All set page after the effort step. It shows the device's success check over one line each for the phone, the sensors and the zones: "Phone paired" or "No phone", the count of saved sensors or "No sensors", and "Zones set" once either limit is set, else "No zones". A skipped part draws a dim dash, a done part an ink check. Select saves setup as Done and opens Home; Back returns to the effort step. All set is a persisted step, byte 9, last in SetupStep::ORDER. So the existing finish and back walk it with no special case, and a power loss resumes on the page the rider sees. The step counter leaves it out, as it leaves out Hello, so the counted pages stay 1/7 to 7/7. The copy-fit gate seeds the page empty and with a paired phone, two sensors and a limit. The sweep gets setup-all-set and setup-all-set-full, each in four languages. No other frame changes. Public docs did not change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 8 ++ firmware/obc-app/i18n/en.toml | 10 ++ firmware/obc-app/i18n/es.toml | 8 ++ firmware/obc-app/i18n/fr.toml | 8 ++ firmware/obc-app/src/app.rs | 35 ++++++- firmware/obc-app/src/harness/copy_fit.rs | 15 +++ firmware/obc-app/src/screen/mod.rs | 6 +- firmware/obc-app/src/screen/setup.rs | 115 ++++++++++++++++++++--- firmware/obc-app/src/settings.rs | 4 +- firmware/ui-frames.toml | 33 +++++++ firmware/ui-snapshots.sha256 | 8 ++ 11 files changed, 233 insertions(+), 17 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index c4e43fc75..b936cb843 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -556,6 +556,14 @@ needs_app = "Braucht die App" recording = "Aufzeichnung" ride_sync = "Fahrten-Sync" show_code = "Code zeigen" +all_set = "ALLES BEREIT" +phone_paired = "Handy gekoppelt" +no_phone = "Kein Handy" +sensor = "Sensor" +sensors = "Sensoren" +no_sensors = "Keine Sensoren" +zones_set = "Zonen gesetzt" +no_zones = "Keine Zonen" # --- Wiederhergestellte Fahrt beim Start --- [ride_recovery] diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 98b502f20..83a96abe9 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -595,6 +595,16 @@ needs_app = "Needs the app" recording = "Ride recording" ride_sync = "Ride sync" show_code = "Show the code" +# The closing page: its title, then a line each for the phone, the sensors and the zones. The count +# of saved sensors goes before `sensor` (one) or `sensors`. +all_set = "ALL SET" +phone_paired = "Phone paired" +no_phone = "No phone" +sensor = "sensor" +sensors = "sensors" +no_sensors = "No sensors" +zones_set = "Zones set" +no_zones = "No zones" # --- Boot-time recovered ride --- [ride_recovery] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 959ea47b5..75326b244 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -555,6 +555,14 @@ needs_app = "Necesita la app" recording = "Grabación" ride_sync = "Sync de salidas" show_code = "Ver el código" +all_set = "TODO LISTO" +phone_paired = "Móvil vinculado" +no_phone = "Sin móvil" +sensor = "sensor" +sensors = "sensores" +no_sensors = "Sin sensores" +zones_set = "Zonas fijadas" +no_zones = "Sin zonas" # --- Salida recuperada al arrancar --- [ride_recovery] diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 8fd9e0a13..69fd4040a 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -555,6 +555,14 @@ needs_app = "Il faut l'appli" recording = "Enregistrement" ride_sync = "Synchro sorties" show_code = "Voir le code" +all_set = "TOUT EST PRÊT" +phone_paired = "Tél. appairé" +no_phone = "Pas de téléphone" +sensor = "capteur" +sensors = "capteurs" +no_sensors = "Aucun capteur" +zones_set = "Zones définies" +no_zones = "Pas de zones" # --- Sortie récupérée au démarrage --- [ride_recovery] diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 4ba9883e6..f689b80f7 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -6325,7 +6325,7 @@ mod tests { /// The effort step edits each limit in the drawer editor over the page: the editor's Select /// commits and saves the value, and its Back discards. The sheet keeps setup's refusals of the /// chords, the escape and the idle return. Back on the page returns to the sensors step with the - /// committed limits kept, and the last row ends setup. + /// committed limits kept, and the last row opens the All set page. #[test] fn the_effort_step_edits_its_limits_in_the_drawer_editor() { use crate::input::Chord; @@ -6364,9 +6364,38 @@ mod tests { for g in [Gesture::Step(-1), Gesture::Press, Gesture::Step(-1), Gesture::Press] { app.apply_gesture(g); } - assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)]), "the last row ends setup"); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupAllSet(_)]), "the last row"); let s = app.settings(); - assert_eq!((s.setup, s.max_hr, s.ftp_w), (SetupStep::Done, 181, 0)); + assert_eq!((s.setup, s.max_hr, s.ftp_w), (SetupStep::AllSet, 181, 0)); + } + + /// The All set page is the last step and refuses the escape. Back returns to the effort step, + /// and Select saves setup as done and opens Home. + #[test] + fn the_all_set_page_ends_setup_on_home() { + use crate::settings::SetupStep; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings { setup: SetupStep::AllSet, ..Settings::FACTORY }); + let mut host = SettingsHost::default(); + let all_set = |app: &App| matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupAllSet(_)]); + + assert!(all_set(&app), "a power loss resumes on the page"); + app.apply_gesture(Gesture::BackHold); + assert!(all_set(&app), "setup cannot be escaped"); + app.apply_gesture(Gesture::Back); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupEffort(_)])); + assert_eq!(app.settings().setup, SetupStep::Effort); + + for g in [Gesture::Step(-1), Gesture::Press] { + app.apply_gesture(g); + } + assert!(all_set(&app), "Skip on the effort step"); + let revision = host.drain(&mut app).expect("the step is saved"); + host.ack(&mut app, revision); + app.apply_gesture(Gesture::Press); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_)])); + assert_eq!(app.settings().setup, SetupStep::Done); + assert!(host.drain(&mut app).is_some(), "setup is saved as done"); } /// A cancel posted while the plan request is still undrained annihilates it: the rider's net diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 086db0889..45591a0b7 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -125,6 +125,7 @@ fn seeds(language: Language) -> Vec { Screen::PairPhone(PairPhoneScreen), Screen::SetupSensors(SetupSensorsScreen::default()), Screen::SetupSensorScan(SetupSensorScanScreen::new(1)), + Screen::SetupAllSet(SetupAllSetScreen), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Receiving { received_kib: 1_024, total_kib: 65_536 })), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Installed)), Screen::Settings(SettingsPage::hub()), @@ -349,6 +350,20 @@ fn every_string_fits_the_panel_in_every_language() { }) { offenders.extend(complaint(name, language, &drawn)); } + // The All set page with a phone paired, sensors saved and the zones set. One sensor reads + // shorter than two in every catalog. + for (name, drawn) in + walk(plain(vec![Screen::SetupAllSet(SetupAllSetScreen)]).remove(0), language, &bytes, |app| { + let mut s = + Settings { language, setup: crate::settings::SetupStep::AllSet, max_hr: 185, ..Default::default() }; + s.saved_sensors[0] = crate::settings::SavedSensor::saved(1, [1, 2, 3, 4, 5, 6]); + s.saved_sensors[1] = crate::settings::SavedSensor::saved(0, [6, 5, 4, 3, 2, 1]); + app.set_settings(s); + app.set_ble_status(crate::BleStatus { paired: true, ..crate::BleStatus::DISCONNECTED }); + }) + { + offenders.extend(complaint(name, language, &drawn)); + } } report(offenders); } diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index c4e30e0c5..fe24bbd9d 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -120,8 +120,8 @@ pub use settings::{ StatFieldsScreen, }; pub use setup::{ - HelloScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupNoAppScreen, SetupQrScreen, - SetupSensorScanScreen, SetupSensorsScreen, SetupThemeScreen, SetupUnitsScreen, + HelloScreen, SetupAllSetScreen, SetupButtonsScreen, SetupEffortScreen, SetupLanguageScreen, SetupNoAppScreen, + SetupQrScreen, SetupSensorScanScreen, SetupSensorsScreen, SetupThemeScreen, SetupUnitsScreen, }; pub use start_away::StartAwayScreen; pub use statistics::StatisticsScreen; @@ -938,6 +938,8 @@ screens! { /// Setup's effort step: max heart rate and FTP, each edited in the drawer editor over the page, /// then a row that continues. SetupEffort(SetupEffortScreen) => Caps::modal().blocking(), + /// Setup's last step: what the rider set up. Select ends setup and opens Home. + SetupAllSet(SetupAllSetScreen) => Caps::modal().blocking(), /// The card after Finish on a trip day: today's ledger, then tomorrow's day or the trip's /// totals. OK returns Home. DayDone(DayDoneScreen) => Caps::modal(), diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index ed354bd8f..dd017e0ea 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -7,7 +7,7 @@ //! //! The first step is Hello: a greeting in the four UI languages, because the language is not chosen //! yet. Every step after it is a titled page: [`title_bar`] at its head and the [`hint`] at its -//! foot. +//! foot. The last step, All set, sums up the setup, and its Select opens Home. use core::fmt::Write; @@ -28,7 +28,9 @@ use super::context_drawer::{ContextDrawerScreen, ContextValue}; use super::home::contours; use super::pair_code::code_page; use super::settings::{kind_msg, status_line, wake_msg, LanguageScreen, SensorScanScreen}; -use super::vocab::chrome::{copy_w, row_check, title_frame, wrapped, wrapped_line_pitch, LIST_TOP}; +use super::vocab::chrome::{ + card_check, copy_w, row_check, title_frame, wrapped, wrapped_line_pitch, LIST_TOP, ROW_CHECK_HALF, TITLE_BAR_H, +}; use super::vocab::flags::{FLAG_H, FLAG_W}; use super::vocab::list::on_step; use super::vocab::rows::{ @@ -48,6 +50,7 @@ pub(crate) fn screen(s: &Settings) -> Option { SetupStep::Qr => Some(Screen::SetupQr(SetupQrScreen)), SetupStep::Sensors => Some(Screen::SetupSensors(SetupSensorsScreen::new(s))), SetupStep::Effort => Some(Screen::SetupEffort(SetupEffortScreen::default())), + SetupStep::AllSet => Some(Screen::SetupAllSet(SetupAllSetScreen)), SetupStep::Done => None, } } @@ -370,8 +373,16 @@ pub struct SetupSensorsScreen { /// The Skip row's index, after the slots. const SKIP: usize = SENSOR_SLOTS; +fn sensors_saved(s: &Settings) -> usize { + s.saved_sensors.iter().filter(|s| s.present).count() +} + fn any_sensor_saved(s: &Settings) -> bool { - s.saved_sensors.iter().any(|s| s.present) + sensors_saved(s) > 0 +} + +fn any_limit_set(s: &Settings) -> bool { + s.max_hr != 0 || s.ftp_w != 0 } impl SetupSensorsScreen { @@ -540,7 +551,8 @@ fn swatch(cv: &mut impl Surface, x: i32, y: i32, theme: Theme) { /// The effort step: the two limits the effort zones are cut from, and a row that continues. A /// value row opens the drawer editor as a sheet over the page, as on the Ride settings page, and /// the editor's Select commits and saves the value. The last row reads Skip while neither limit -/// is set. The ride tiles below show the zones a limit gives, and a plain tile without one. +/// is set, and opens the All set page. The ride tiles below show the zones a limit gives, and a +/// plain tile without one. #[derive(Debug, Default)] pub struct SetupEffortScreen { selected: usize, @@ -581,8 +593,7 @@ impl SetupEffortScreen { nav_row(cv, row_rect(y, w, ROW_TWO), rx.t(label), Some(Line2::text(text)), i == self.selected, true, true); y += ROW_TWO + ROW_GAP; } - let set = rx.settings.max_hr != 0 || rx.settings.ftp_w != 0; - continue_row(cv, rx, y, set, self.selected == LIMITS.len()); + continue_row(cv, rx, y, any_limit_set(rx.settings), self.selected == LIMITS.len()); let limits = rx.settings.effort_limits(); for (i, (metric, value)) in SAMPLE_EFFORT.into_iter().enumerate() { @@ -601,6 +612,69 @@ impl SetupEffortScreen { } } +/// The last step: a check over one line each for the phone, the sensors and the zones, so the rider +/// sees what setup holds and what it skipped. Select ends setup and opens Home, and Back returns to +/// the effort step. +#[derive(Debug)] +pub struct SetupAllSetScreen; + +/// The top of the first summary line and the pitch of the lines. +const SUMMARY: (i32, i32) = (TITLE_BAR_H + 102, 32); + +impl SetupAllSetScreen { + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + match g { + Gesture::Press => finish(SetupStep::AllSet, cx), + Gesture::Back => back(SetupStep::AllSet, cx), + _ => Transition::None, + } + } + + pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { + use palette::*; + let (w, h) = (rx.w, rx.h); + title_frame(cv, w, h, rx.t(Msg::SetupAllSet), ""); + card_check(cv, Point::new(w / 2, TITLE_BAR_H + 56), 24); + let lines = summary(rx.settings, rx.state.device.ble_paired); + // The lines are one block centred on the panel, their marks in a column at its left. + let font = Font::Label; + let widest = lines.iter().map(|(_, text)| text_width(text, font) as i32).max().unwrap_or(0); + let left = (w - MARK_W - widest) / 2; + for (i, (done, text)) in lines.iter().enumerate() { + let y = SUMMARY.0 + i as i32 * SUMMARY.1; + let mark = Point::new(left + ROW_CHECK_HALF, y + font.cap_mid() as i32); + let ink = if *done { + row_check(cv, mark, INK); + INK + } else { + cv.fill(rect(mark.x - ROW_CHECK_HALF, mark.y - 1, 2 * ROW_CHECK_HALF, 2), SUBTEXT); + SUBTEXT + }; + cv.text(text, Point::new(left + MARK_W, y), font, TextAlign::Left, ink); + } + hint(cv, w, h, false, rx.t(Msg::SetupContinue), Some(Key::Ok(rx.t(Msg::SetupOk)))); + } +} + +/// The width of a summary line's mark and the gap after it. +const MARK_W: i32 = 2 * ROW_CHECK_HALF + 12; + +/// The All set page's lines, each with whether the rider set that part up: the phone, the saved +/// sensors and the effort limits. +fn summary(s: &Settings, paired: bool) -> [(bool, heapless::String<24>); 3] { + let line = |msg| heapless::String::try_from(t(msg, s.language)).unwrap_or_default(); + let sensors = sensors_saved(s); + let mut counted = heapless::String::new(); + let _ = + write!(counted, "{sensors} {}", t(if sensors == 1 { Msg::SetupSensor } else { Msg::SetupSensors }, s.language)); + let zones = any_limit_set(s); + [ + (paired, line(if paired { Msg::SetupPhonePaired } else { Msg::SetupNoPhone })), + (sensors > 0, if sensors > 0 { counted } else { line(Msg::SetupNoSensors) }), + (zones, line(if zones { Msg::SetupZonesSet } else { Msg::SetupNoZones })), + ] +} + /// A step's last row at `y`: Skip, or Continue once the step holds a `chosen` value. It acts, so it /// has no chevron. fn continue_row(cv: &mut impl Surface, rx: &Render, y: i32, chosen: bool, selected: bool) { @@ -631,11 +705,11 @@ fn preview_tile(rx: &Render, i: usize) -> Rectangle { rect(ROW_X + i as i32 * (tile_w + gap), rx.h - 8 - HINT_H - 12 - tile_h, tile_w, tile_h) } -/// The rider's place among the titled steps, as `(n, of)`. Hello, the first step, has no title -/// bar, so the count starts at the step after it. +/// The rider's place among the counted steps, as `(n, of)`. The count leaves out the first step, +/// Hello, which has no title bar, and the last, All set, which is not a choice. fn place(step: SetupStep) -> (usize, usize) { - let titled = &SetupStep::ORDER[1..]; - (titled.iter().position(|&s| s == step).map_or(0, |i| i + 1), titled.len()) + let counted = &SetupStep::ORDER[1..SetupStep::ORDER.len() - 1]; + (counted.iter().position(|&s| s == step).map_or(0, |i| i + 1), counted.len()) } /// The head of a titled setup page: the framed title bar, with the rider's [`place`] in its right @@ -739,7 +813,25 @@ mod tests { assert_eq!(SetupSensorsScreen::new(&s).selected, SKIP); } - /// The count starts at the first titled step and ends at the last step. + /// The summary reads the bond, the saved sensors and either effort limit. + #[test] + fn the_all_set_summary_reads_what_setup_holds() { + use crate::settings::SavedSensor; + let mut s = Settings::default(); + let lines = summary(&s, false); + let skipped = [(false, "No phone"), (false, "No sensors"), (false, "No zones")]; + assert_eq!(lines.each_ref().map(|(done, text)| (*done, text.as_str())), skipped); + + s.saved_sensors[2] = SavedSensor::saved(0, [1, 2, 3, 4, 5, 6]); + s.ftp_w = 250; + let lines = summary(&s, true); + let set = [(true, "Phone paired"), (true, "1 sensor"), (true, "Zones set")]; + assert_eq!(lines.each_ref().map(|(done, text)| (*done, text.as_str())), set); + s.saved_sensors[0] = SavedSensor::saved(1, [6, 5, 4, 3, 2, 1]); + assert_eq!(summary(&s, true)[1].1, "2 sensors"); + } + + /// The count starts at the first titled step and ends at the last counted step, before All set. #[test] fn the_title_bar_counts_the_titled_steps() { let places = [ @@ -752,5 +844,6 @@ mod tests { SetupStep::Effort, ]; assert_eq!(places.map(place), [(1, 7), (2, 7), (3, 7), (4, 7), (5, 7), (6, 7), (7, 7)]); + assert_eq!(place(SetupStep::AllSet), (0, 7)); } } diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index 1c7c9a536..a11c9dbf2 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -219,12 +219,13 @@ pub enum SetupStep { Sensors = 6, Effort = 7, Qr = 8, + AllSet = 9, } impl SetupStep { /// The steps in the order the rider meets them. The byte values are persisted, so a new step /// takes a new value and its place here. - pub(crate) const ORDER: [SetupStep; 8] = [ + pub(crate) const ORDER: [SetupStep; 9] = [ SetupStep::Hello, SetupStep::Language, SetupStep::Buttons, @@ -233,6 +234,7 @@ impl SetupStep { SetupStep::Qr, SetupStep::Sensors, SetupStep::Effort, + SetupStep::AllSet, ]; fn index(self) -> Option { diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 545b09d64..7f57c5b5c 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -2031,6 +2031,39 @@ script = "p p u d b p p p p b d p u p p d*5 p d p d*10 p d w" expect = "SetupEffort" args = ["--fresh"] +# Setup's last page after the effort step skips: no phone, no sensors and no zones. +[[frame]] +name = "setup-all-set" +map = "{grimsel}" +script = "p p u d b p p p p b d p u p u p" +expect = "SetupAllSet" +args = ["--fresh"] + +[[frame]] +name = "setup-all-set" +map = "{grimsel}" +script = "p p u d b p p p p b d p u p u p" +expect = "SetupAllSet" +args = ["--fresh"] +langs = ["de", "fr", "es"] + +# A paired phone passes the pairing step by, two sensors are saved, and both limits are set: every +# line is checked. +[[frame]] +name = "setup-all-set-full" +map = "{grimsel}" +script = "p p u d b p p p p p p d*5 p d p d*10 p d p" +expect = "SetupAllSet" +args = ["--fresh", "--ble", "connected+paired", "--sensors", "screen"] + +[[frame]] +name = "setup-all-set-full" +map = "{grimsel}" +script = "p p u d b p p p p p p d*5 p d p d*10 p d p" +expect = "SetupAllSet" +args = ["--fresh", "--ble", "connected+paired", "--sensors", "screen"] +langs = ["de", "fr", "es"] + # Route-upload popups (#451), all three variants. `--inject upload[-replace]=ID` raises the upload # event after the script, exactly as the control panel's inject buttons do. specs/vectors holds # two routes: id 0 = route-plain, id 1 = route-waypoints (filename order). diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index e5e3daef4..53b2ba9b2 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -311,6 +311,14 @@ e45d66a5ad980acf5ec2891d0ae89b517466fae60b1caf61b814f988be56a65d settings-es.pn 49286bcd4069b29c6094ebeadb8b35dc3cf2b3c1fc6dc70da98ab5cd7be810ac settings-fr.png 460cddf4695830c5cdececc764d507b4ee2a13fb3660dd66ca6ef20dedb84f37 settings-ride-waypoints.png c1a9b35839d97d9a06da9885587cc2f8b74dff61561d93b5fac55088148f94e5 settings.png +65e63e366b6911129d22b45c3557074cde6176e8f469cfe90ae9f69de4e2f95c setup-all-set-de.png +5d45ec3771f0a8a726b2133031c902837132e9ec6e1fa743073238ba7c197803 setup-all-set-es.png +ceaf549dadb7ba414a08a76ff187302503ed51178aa23cf7bb147b36ad7dc0c8 setup-all-set-fr.png +7756034792cb22b3a4ae1dab14c86a7066853a3a73e562b913c6aa4a199348be setup-all-set-full-de.png +56a9344222d2c02160c4c334d67b41f38e4eec34d28cc77ebf2735b88663bbf8 setup-all-set-full-es.png +709096403295c14ec7b94f67bfdcc3fc4c3c45c955298e1d4e8d9c810272b6de setup-all-set-full-fr.png +5670f68fdeeaebdebd1b9a68facb8a70b9fd3699508f39d0ed56c9d4a74eea21 setup-all-set-full.png +9d9705e12acdee909a2d2d48b9056f3eb307719383d8103db32a6561237105d2 setup-all-set.png 7f2d7b81e43eba3dff167d0fd4f95a21fd32242aec1afb17b4dde8984423e118 setup-buttons-de.png 12cbf9a341b3109a3af251ebbfe5f78f30572991cf903ab11e3395f42213bac9 setup-buttons-done-de.png e9f720180f12726a7ab36782f324ea67d18915a0ff4b44781b85f3c0c783c108 setup-buttons-done-es.png From 171474680b6b162e9a30f97d1bbbf82664e3c939 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:52:20 +0000 Subject: [PATCH 22/37] setup: trim the Paired page, All set ends on "Let's ride" The Paired page shows only the check and "Phone paired", centred between the title bar and the hint. The two "goes on" rows, their strings and the OBC icon are gone. The All set hint reads "Let's ride". When the rider skipped a part, a caption under the summary points to Settings. The summary sits 6 px higher, so the caption stands apart from the lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 4 +- firmware/obc-app/i18n/en.toml | 7 ++-- firmware/obc-app/i18n/es.toml | 4 +- firmware/obc-app/i18n/fr.toml | 4 +- firmware/obc-app/src/screen/setup.rs | 62 ++++++++++++---------------- firmware/ui-snapshots.sha256 | 24 +++++------ 6 files changed, 48 insertions(+), 57 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index 5256ff9a8..7ccac78b3 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -558,8 +558,6 @@ ride_sync = "Fahrten-Sync" show_code = "Code zeigen" paired_title = "GEKOPPELT" phone_paired = "Handy gekoppelt" -app_goes_on = "In der App am Handy weiter" -obc_goes_on = "Hier am OBC weiter" all_set = "ALLES BEREIT" no_phone = "Kein Handy" sensor = "Sensor" @@ -567,6 +565,8 @@ sensors = "Sensoren" no_sensors = "Keine Sensoren" zones_set = "Zonen gesetzt" no_zones = "Keine Zonen" +add_later = "Später: Einstellungen" +lets_ride = "Los geht's" # --- Wiederhergestellte Fahrt beim Start --- [ride_recovery] diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 158753d51..08d6a01b5 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -595,13 +595,12 @@ needs_app = "Needs the app" recording = "Ride recording" ride_sync = "Ride sync" show_code = "Show the code" -# The page a bond opens on the pairing step: what goes on where. +# The page a bond opens on the pairing step. paired_title = "PAIRED" phone_paired = "Phone paired" -app_goes_on = "Your phone goes on in the app" -obc_goes_on = "This OBC goes on here" # The closing page: its title, then a line each for the phone (`phone_paired` or `no_phone`), the # sensors and the zones. The count of saved sensors goes before `sensor` (one) or `sensors`. +# `add_later` shows under the lines when one is skipped, and `lets_ride` is the hint beside OK. all_set = "ALL SET" no_phone = "No phone" sensor = "sensor" @@ -609,6 +608,8 @@ sensors = "sensors" no_sensors = "No sensors" zones_set = "Zones set" no_zones = "No zones" +add_later = "Add later in Settings" +lets_ride = "Let's ride" # --- Boot-time recovered ride --- [ride_recovery] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index cf3dad745..7a0b9ac04 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -557,8 +557,6 @@ ride_sync = "Sync de salidas" show_code = "Ver el código" paired_title = "VINCULADO" phone_paired = "Móvil vinculado" -app_goes_on = "Sigue en la app del móvil" -obc_goes_on = "Sigue aquí en el OBC" all_set = "TODO LISTO" no_phone = "Sin móvil" sensor = "sensor" @@ -566,6 +564,8 @@ sensors = "sensores" no_sensors = "Sin sensores" zones_set = "Zonas fijadas" no_zones = "Sin zonas" +add_later = "Luego en Ajustes" +lets_ride = "¡A rodar!" # --- Salida recuperada al arrancar --- [ride_recovery] diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 5d6ccb58a..50b7ddee1 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -557,8 +557,6 @@ ride_sync = "Synchro sorties" show_code = "Voir le code" paired_title = "APPAIRÉ" phone_paired = "Tél. appairé" -app_goes_on = "Suite dans l'appli du tél." -obc_goes_on = "Suite ici, sur l'OBC" all_set = "TOUT EST PRÊT" no_phone = "Pas de téléphone" sensor = "capteur" @@ -566,6 +564,8 @@ sensors = "capteurs" no_sensors = "Aucun capteur" zones_set = "Zones définies" no_zones = "Pas de zones" +add_later = "Plus tard : Réglages" +lets_ride = "C'est parti" # --- Sortie récupérée au démarrage --- [ride_recovery] diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index a16ae5796..ed5c30ddb 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -29,8 +29,7 @@ use super::home::contours; use super::pair_code::code_page; use super::settings::{kind_msg, status_line, wake_msg, LanguageScreen, SensorScanScreen}; use super::vocab::chrome::{ - card_check, copy_w, row_check, title_frame, wrapped, wrapped_aligned, wrapped_line_pitch, LIST_TOP, ROW_CHECK_HALF, - TITLE_BAR_H, + card_check, copy_w, row_check, title_frame, wrapped, wrapped_line_pitch, LIST_TOP, ROW_CHECK_HALF, TITLE_BAR_H, }; use super::vocab::flags::{FLAG_H, FLAG_W}; use super::vocab::list::on_step; @@ -526,8 +525,8 @@ impl SetupNoAppScreen { } } -/// The page a bond opens over the pairing step: the phone is paired, the app goes on on the phone, -/// and setup goes on here. The bond has already saved the next step, so a power loss resumes there. +/// The page a bond opens over the pairing step: a check over "Phone paired", centred between the +/// title bar and the hint. The bond has already saved the next step, so a power loss resumes there. /// Select goes on, and Back walks to the step before the pairing step, as on the skip-app page. #[derive(Debug)] pub struct SetupPairedScreen; @@ -545,20 +544,9 @@ impl SetupPairedScreen { use palette::*; let (w, h) = (rx.w, rx.h); title_bar(cv, w, h, SetupStep::Qr, rx.t(Msg::SetupPairedTitle)); - card_check(cv, Point::new(w / 2, TITLE_BAR_H + 46), 20); - let mut y = TITLE_BAR_H + 78; - cv.text(rx.t(Msg::SetupPhonePaired), Point::new(w / 2, y), Font::Body, TextAlign::Center, INK); - y += Font::Body.line_height() as i32 + 20; - let (x, text_w) = (ROW_X + 24, w - 2 * ROW_X - 24); - for (i, line) in [Msg::SetupAppGoesOn, Msg::SetupObcGoesOn].into_iter().enumerate() { - let cy = y + Font::Label.cap_mid() as i32; - if i == 0 { - phone(cv, ROW_X + 8, cy); - } else { - obc(cv, ROW_X + 8, cy); - } - y = wrapped_aligned(cv, rx.t(line), x, y, text_w, Font::Label, TextAlign::Left, INK) + 14; - } + let mid = (TITLE_BAR_H + h - 8 - HINT_H) / 2; + card_check(cv, Point::new(w / 2, mid - 24), 24); + cv.text(rx.t(Msg::SetupPhonePaired), Point::new(w / 2, mid + 14), Font::Body, TextAlign::Center, INK); hint(cv, w, h, false, rx.t(Msg::SetupContinue), Some(Key::Ok(rx.t(Msg::SetupOk)))); } } @@ -572,17 +560,6 @@ fn phone(cv: &mut impl Surface, cx: i32, cy: i32) { cv.hline(cx - 1, cy + 4, 2, INK); } -/// The OBC centred on `(cx, cy)`, beside [`phone`] and as tall: a wider body round a filled screen, -/// with a button on each flank. -fn obc(cv: &mut impl Surface, cx: i32, cy: i32) { - use palette::*; - cv.round_outline(rect(cx - 6, cy - 8, 12, 17), 3, INK); - cv.round_outline(rect(cx - 5, cy - 7, 10, 15), 2, INK); - cv.fill(rect(cx - 3, cy - 5, 6, 8), AMBER); - cv.fill(rect(cx - 8, cy - 2, 2, 5), INK); - cv.fill(rect(cx + 6, cy - 2, 2, 5), INK); -} - /// A theme's page in the flag slot: its paper, its ink round the edge, and two lines of text. The /// colours are ones the theme mapping keeps, so each swatch shows its own theme on either page. fn swatch(cv: &mut impl Surface, x: i32, y: i32, theme: Theme) { @@ -662,13 +639,13 @@ impl SetupEffortScreen { } /// The last step: a check over one line each for the phone, the sensors and the zones, so the rider -/// sees what setup holds and what it skipped. Select ends setup and opens Home, and Back returns to -/// the effort step. +/// sees what setup holds and what it skipped. A skipped part adds a line that points to Settings. +/// Select ends setup and opens Home, and Back returns to the effort step. #[derive(Debug)] pub struct SetupAllSetScreen; /// The top of the first summary line and the pitch of the lines. -const SUMMARY: (i32, i32) = (TITLE_BAR_H + 102, 32); +const SUMMARY: (i32, i32) = (TITLE_BAR_H + 96, 32); impl SetupAllSetScreen { pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { @@ -701,7 +678,11 @@ impl SetupAllSetScreen { }; cv.text(text, Point::new(left + MARK_W, y), font, TextAlign::Left, ink); } - hint(cv, w, h, false, rx.t(Msg::SetupContinue), Some(Key::Ok(rx.t(Msg::SetupOk)))); + if skipped(&lines) { + let y = SUMMARY.0 + 3 * SUMMARY.1 + 10; + cv.text(rx.t(Msg::SetupAddLater), Point::new(w / 2, y), Font::Caption, TextAlign::Center, SUBTEXT); + } + hint(cv, w, h, false, rx.t(Msg::SetupLetsRide), Some(Key::Ok(rx.t(Msg::SetupOk)))); } } @@ -724,6 +705,11 @@ fn summary(s: &Settings, paired: bool) -> [(bool, heapless::String<24>); 3] { ] } +/// Whether the rider skipped a part of setup, so the All set page points to Settings. +fn skipped(lines: &[(bool, heapless::String<24>)]) -> bool { + lines.iter().any(|(done, _)| !done) +} + /// A step's last row at `y`: Skip, or Continue once the step holds a `chosen` value. It acts, so it /// has no chevron. fn continue_row(cv: &mut impl Surface, rx: &Render, y: i32, chosen: bool, selected: bool) { @@ -862,20 +848,24 @@ mod tests { assert_eq!(SetupSensorsScreen::new(&s).selected, SKIP); } - /// The summary reads the bond, the saved sensors and either effort limit. + /// The summary reads the bond, the saved sensors and either effort limit, and a skipped part + /// points to Settings. #[test] fn the_all_set_summary_reads_what_setup_holds() { use crate::settings::SavedSensor; let mut s = Settings::default(); let lines = summary(&s, false); - let skipped = [(false, "No phone"), (false, "No sensors"), (false, "No zones")]; - assert_eq!(lines.each_ref().map(|(done, text)| (*done, text.as_str())), skipped); + let none = [(false, "No phone"), (false, "No sensors"), (false, "No zones")]; + assert_eq!(lines.each_ref().map(|(done, text)| (*done, text.as_str())), none); + assert!(skipped(&lines)); s.saved_sensors[2] = SavedSensor::saved(0, [1, 2, 3, 4, 5, 6]); s.ftp_w = 250; let lines = summary(&s, true); let set = [(true, "Phone paired"), (true, "1 sensor"), (true, "Zones set")]; assert_eq!(lines.each_ref().map(|(done, text)| (*done, text.as_str())), set); + assert!(!skipped(&lines), "nothing to add later"); + assert!(skipped(&summary(&s, false)), "the phone alone is skipped"); s.saved_sensors[0] = SavedSensor::saved(1, [6, 5, 4, 3, 2, 1]); assert_eq!(summary(&s, true)[1].1, "2 sensors"); } diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index 0054592e3..0df729ed9 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -311,14 +311,14 @@ e45d66a5ad980acf5ec2891d0ae89b517466fae60b1caf61b814f988be56a65d settings-es.pn 49286bcd4069b29c6094ebeadb8b35dc3cf2b3c1fc6dc70da98ab5cd7be810ac settings-fr.png 460cddf4695830c5cdececc764d507b4ee2a13fb3660dd66ca6ef20dedb84f37 settings-ride-waypoints.png c1a9b35839d97d9a06da9885587cc2f8b74dff61561d93b5fac55088148f94e5 settings.png -65e63e366b6911129d22b45c3557074cde6176e8f469cfe90ae9f69de4e2f95c setup-all-set-de.png -5d45ec3771f0a8a726b2133031c902837132e9ec6e1fa743073238ba7c197803 setup-all-set-es.png -ceaf549dadb7ba414a08a76ff187302503ed51178aa23cf7bb147b36ad7dc0c8 setup-all-set-fr.png -7756034792cb22b3a4ae1dab14c86a7066853a3a73e562b913c6aa4a199348be setup-all-set-full-de.png -56a9344222d2c02160c4c334d67b41f38e4eec34d28cc77ebf2735b88663bbf8 setup-all-set-full-es.png -709096403295c14ec7b94f67bfdcc3fc4c3c45c955298e1d4e8d9c810272b6de setup-all-set-full-fr.png -5670f68fdeeaebdebd1b9a68facb8a70b9fd3699508f39d0ed56c9d4a74eea21 setup-all-set-full.png -9d9705e12acdee909a2d2d48b9056f3eb307719383d8103db32a6561237105d2 setup-all-set.png +38ca4991b6c6ffd771d58d092b094f26ab31f9a6fba3e5845c05ecca7a476bff setup-all-set-de.png +091b75537dc360b9b8fd2344c5830fccea222be62b8676a4a76db533594225ed setup-all-set-es.png +16f9307a34ebee505d10cd8d1f64898e6865335ef195701af96878229e0d3cba setup-all-set-fr.png +d3b4dd373bf812ad226b9416829776ce628099d099580098a0f549c5b2ec76e5 setup-all-set-full-de.png +4175454f14ee66c8d3505c06c1fa3a7a105fb537d2e4d968ead68bf457f99d10 setup-all-set-full-es.png +93bb96434b5a98dda6171d7e545c1a0b367bb87b1daaa516285522cb24a74b70 setup-all-set-full-fr.png +8b3dbedf1bfb778d9303e3f64abd6a9c57ae0ac2d4db9439a71f776cb0f6fd4b setup-all-set-full.png +db499610b9477d1b92a5222740b059cc0eabdcf0432cbef5b403355efd62e4e0 setup-all-set.png 7f2d7b81e43eba3dff167d0fd4f95a21fd32242aec1afb17b4dde8984423e118 setup-buttons-de.png 12cbf9a341b3109a3af251ebbfe5f78f30572991cf903ab11e3395f42213bac9 setup-buttons-done-de.png e9f720180f12726a7ab36782f324ea67d18915a0ff4b44781b85f3c0c783c108 setup-buttons-done-es.png @@ -344,10 +344,10 @@ b58cd8802fb380914be14e60e97e19517de22b6fb392859b31d3e8eb58b4a684 setup-language 5ea022b742fc995d1976e24b088111daf26a045cc9d50f30ad5be44d6996bbd1 setup-no-app-es.png 4e3a8ded3158085680c230ba5906ce6f89b5354bbdcc48910344fa8bfca50216 setup-no-app-fr.png 567ddcce9db29e7a93e768b2d040decb2c5ede8d4d5e93ae5db72c1a95799465 setup-no-app.png -be26d48121e097b8083584f9f3dedb216a73b607f2a8bbb39acb879058533174 setup-paired-de.png -b777653980d448db79959f67483531bb8b0718c4e4774493682e2340af5574df setup-paired-es.png -fdff710a49113fc839cc95b8f593c503ead9abcef3a4fc6ae37d44591200719b setup-paired-fr.png -481aa9f9f120a99a9fb5ae7ff2c5a8fae8a34bdbabea715b10c1abaed0753618 setup-paired.png +69d39d7b3a1173f586d3beb82c7bfbeddf7e46f9e187a256421bf76cc19c3c55 setup-paired-de.png +15513631159e40e237c10fb04790f0946be85151580a6a03fbacca42ccf2b697 setup-paired-es.png +0d24b8ef6fe2994fd8f2a2df7b29c694eb65586142aa659ad1f118bec88d43c9 setup-paired-fr.png +a6c77edef81181bbd3fe84fc4e866c05801e160ddf764b75047e9d9b36274ad1 setup-paired.png bd0c8f638320f465e33b75d537c1c632c561ad411db8cbb94051032ea6d297c7 setup-qr-cable.png 557695044d1badfafa2b1fc021622d79bbee03f798188070c13ed1d03a13cfba setup-qr-dark.png 9c038d20e74c53cb79d9837372920e0d1130c45a967d33bc8a3888da1a73af22 setup-qr-de.png From 1c34f6925902329ae6a56ea63315d8b16ba13414 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:44:15 +0000 Subject: [PATCH 23/37] test: wait for the landing anchor scroll to land The landing page scrolls smoothly to a fragment. The check passed on the first sample while the scroll still moved, and the next resize or fragment jump then interrupted it, so the page stayed at the old position. The check now waits until the anchor sits under the header. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- apps/obc-web-demo/tests/browser/reading-pages.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/obc-web-demo/tests/browser/reading-pages.test.js b/apps/obc-web-demo/tests/browser/reading-pages.test.js index 4197f7a29..02e6839d5 100644 --- a/apps/obc-web-demo/tests/browser/reading-pages.test.js +++ b/apps/obc-web-demo/tests/browser/reading-pages.test.js @@ -85,9 +85,10 @@ test('landing anchors stay below the sticky header', async ({ page }) => { await page.setViewportSize({ width, height: 900 }); for (const anchor of ['demo', 'features']) { await page.goto(`/#${anchor}`); - await expect.poll(() => page.locator(`#${anchor}`).evaluate(element => + // Wait for the smooth scroll to land: the next resize or fragment jump would interrupt it. + await expect.poll(() => page.locator(`#${anchor}`).evaluate(element => Math.abs( element.getBoundingClientRect().top - document.querySelector('.site-head').getBoundingClientRect().bottom - )).toBeGreaterThanOrEqual(-1); + ))).toBeLessThanOrEqual(1); } } }); From b3043a69a73d428283013e329933c2dc55ebc42c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:59:49 +0000 Subject: [PATCH 24/37] setup: route and trip cards wait for setup, a scan runs while its list is up A route or trip card over a setup page led on to a ride, the Route menu or an escape, and left setup unfinished. The upload family now waits while setup runs and lands once it ends. The other card families only pop back to the page under them. The sensor scan level is now read from the stack: a scan runs while a scan list is on it. A list that a card or a Root took off the stack used to leave the scan on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/src/activity.rs | 15 ------ firmware/obc-app/src/app.rs | 45 ++++++++++++++-- firmware/obc-app/src/card_scheduler.rs | 8 ++- .../obc-app/src/screen/settings/sensors.rs | 51 ++++--------------- firmware/obc-app/src/screen/setup.rs | 2 - firmware/obc-app/src/ui_runtime.rs | 3 +- 6 files changed, 58 insertions(+), 66 deletions(-) diff --git a/firmware/obc-app/src/activity.rs b/firmware/obc-app/src/activity.rs index 7d2e86c1c..b9f449e35 100644 --- a/firmware/obc-app/src/activity.rs +++ b/firmware/obc-app/src/activity.rs @@ -144,10 +144,6 @@ pub struct Activity { /// drains to a no-op at the host. delete_trip: Option, pub(crate) cleanup_routes: Option, - /// The sensor scan mode level, raised by the Sensors screen while a scan-list sub-screen is - /// open and lowered on exit. A level, not a drained edge: the host polls it each pass, keeps a - /// discovery scan running while it is `true`, and clears the app scan list when it falls. - sensor_scan: bool, } impl Activity { @@ -194,15 +190,4 @@ impl Activity { pub(crate) fn take_trip_delete(&mut self) -> Option { self.delete_trip.take() } - - /// Set the sensor scan mode level: `true` when the scan-list screen opens on a sensor row, - /// `false` on exit. The host polls it each pass. - pub(crate) fn request_sensor_scan(&mut self, on: bool) { - self.sensor_scan = on; - } - - /// Whether sensor scan mode is on — the host's per-pass read. - pub(crate) fn sensor_scan_active(&self) -> bool { - self.sensor_scan - } } diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 372778d47..b0bcc18a8 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1911,7 +1911,8 @@ impl App { /// card lands in the same frame unless a policy rule defers it. fn sweep_cards(&mut self) { let arrival = self.arrival_view(); - self.ui.run_card_sweep(&self.catalogs, self.recorder.recording(), self.state.pan.is_some(), arrival); + let in_setup = self.settings.setup != crate::settings::SetupStep::Done; + self.ui.run_card_sweep(&self.catalogs, self.recorder.recording(), self.state.pan.is_some(), arrival, in_setup); if self.ui.stack.iter().any(|s| matches!(s, Screen::Journey(_))) || self.ui.find.resume_offer { self.ui.find.review = self.assistant_review_status(); self.ui.find.resume_route = self @@ -1987,11 +1988,12 @@ impl App { self.ui.set_sensor_scan_hits(hits); } - /// Whether the rider is on the scan-list screen and a scan should run. The host reads the - /// level each pass: while `true` it keeps a discovery scan running and feeds the hits back; - /// when it falls it clears the app scan list. + /// Whether a scan list is on the stack, so a scan should run. The host reads the level each + /// pass: while `true` it keeps a discovery scan running and feeds the hits back; when it falls + /// it clears the app scan list. A card over the list keeps the scan, and whatever takes the list + /// off the stack ends it. pub fn sensor_scan_active(&self) -> bool { - self.activity.sensor_scan_active() + self.ui.stack.iter().any(|s| matches!(s, Screen::SensorScan(_) | Screen::SetupSensorScan(_))) } /// A committed route upload: forced adoption on an active replace + the advisory prompt. @@ -6414,6 +6416,39 @@ mod tests { assert!(host.drain(&mut app).is_some(), "setup is saved as done"); } + /// A route card waits while setup runs, because its VIEW leads on to a ride, and lands once + /// setup ends. + #[test] + fn a_route_card_waits_for_setup_to_end() { + use crate::settings::SetupStep; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings { setup: SetupStep::AllSet, ..Settings::FACTORY }); + app.set_routes_with_ids(&[summary("Alpha")], &[10]); + app.on_route_uploaded(10, false, None); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupAllSet(_)]), "the card waits"); + app.apply_gesture(Gesture::Press); + idle_tick(&mut app, 1_000); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::RouteReceived(_)])); + } + + /// The scan runs while a scan list is on the stack, under a card too, and ends when the list + /// leaves the stack in any way. + #[test] + fn the_sensor_scan_ends_when_its_list_leaves_the_stack() { + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.test_mount_store(); + app.set_routes_with_ids(&[summary("Alpha")], &[10]); + let _ = app.ui.stack.push(Screen::Sensors(crate::screen::SensorsScreen::new())); + app.apply_gesture(Gesture::Press); + assert!(app.sensor_scan_active()); + app.on_route_uploaded(10, false, None); + assert!(app.sensor_scan_active(), "a card over the list keeps the scan"); + app.apply_gesture(Gesture::Press); // VIEW + app.apply_gesture(Gesture::Press); // START RIDE + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::Map(_)])); + assert!(!app.sensor_scan_active()); + } + /// A cancel posted while the plan request is still undrained annihilates it: the rider's net /// intent is "no plan", so the host cannot execute a dismissed plan. #[test] diff --git a/firmware/obc-app/src/card_scheduler.rs b/firmware/obc-app/src/card_scheduler.rs index a5ae2fc6c..c6bb8361e 100644 --- a/firmware/obc-app/src/card_scheduler.rs +++ b/firmware/obc-app/src/card_scheduler.rs @@ -241,6 +241,9 @@ pub(crate) struct CardCtx<'a> { /// The arrival level: `Some` from arrival at the route end until the rider rides on, finishes, /// pauses or loads another route. pub(crate) arrival: Option, + /// First-use setup is running. Its pages are the only way out of it, so the upload prompt, + /// which opens routes and rides, waits for setup to end. + pub(crate) in_setup: bool, } /// The named pending slots plus the one sweep. One slot per family and no untyped queue, so what is @@ -492,9 +495,10 @@ impl CardScheduler { /// Upload prompt. Conflict: the incoming prompt replaces the upload family in place, so /// consecutive uploads never stack and selection resets with the fresh screen. Revalidation: - /// the durable id must still resolve in the rescanned catalog, or the prompt is dropped. + /// the durable id must still resolve in the rescanned catalog, or the prompt is dropped. The + /// prompt waits while setup runs. fn deliver_upload(&mut self, stack: &mut Stack, ctx: &CardCtx, outranked: bool) -> bool { - let Some(ev) = self.upload else { return false }; + let Some(ev) = self.upload.filter(|_| !ctx.in_setup) else { return false }; self.upload = None; // delivered or dropped, never queued behind the passkey card if outranked { return false; diff --git a/firmware/obc-app/src/screen/settings/sensors.rs b/firmware/obc-app/src/screen/settings/sensors.rs index e2338477a..12f00f29a 100644 --- a/firmware/obc-app/src/screen/settings/sensors.rs +++ b/firmware/obc-app/src/screen/settings/sensors.rs @@ -2,8 +2,8 @@ //! three kinds, and [`SensorScanScreen`] is the scan list for one kind. //! //! A save or a forget is a plain [`Settings`](crate::Settings) edit. The host reconcile carries the -//! change to the radio and persists it, so there is one durable path. Scan mode is a level -//! ([`Activity::request_sensor_scan`]) the host polls to keep a discovery scan running. +//! change to the radio and persists it, so there is one durable path. A scan runs while a scan list +//! is on the stack ([`App::sensor_scan_active`](crate::App::sensor_scan_active)). use core::fmt::Write; @@ -58,11 +58,7 @@ impl SensorsScreen { self.selected = crate::screen::vocab::list::step_selection(self.selected, n, SENSOR_SLOTS); Transition::None } - // Scan mode makes the host run a discovery scan. The scan screen lowers it on exit. - Gesture::Press => { - cx.activity.request_sensor_scan(true); - Transition::Push(Screen::SensorScan(SensorScanScreen::new(self.selected as u8))) - } + Gesture::Press => Transition::Push(Screen::SensorScan(SensorScanScreen::new(self.selected as u8))), // The guarded hold is the confirmation. There is no popup. Gesture::Hold if self.selection_is_guarded(cx.settings) => { cx.settings.saved_sensors[self.selected] = SavedSensor::EMPTY; @@ -155,16 +151,12 @@ impl SensorScanScreen { let picked = self.hits(cx.sensor_scan_hits).nth(self.selected).map(|h| (h.addr_kind, h.addr)); if let Some((addr_kind, addr)) = picked { cx.settings.saved_sensors[self.slot as usize] = SavedSensor::saved(addr_kind, addr); - cx.activity.request_sensor_scan(false); Transition::Pop } else { Transition::None } } - Gesture::Back => { - cx.activity.request_sensor_scan(false); - Transition::Pop - } + Gesture::Back => Transition::Pop, _ => Transition::None, } } @@ -235,11 +227,11 @@ mod tests { scr: &mut SensorScanScreen, st: &mut AppState, s: &mut Settings, - act: &mut Activity, hits: &[SensorScanHit], g: Gesture, ) -> Transition { - let mut cx = Ctx { sensor_scan_hits: hits, ..test_ctx(st, act, s) }; + let mut act = Activity::new(Mode::Idle); + let mut cx = Ctx { sensor_scan_hits: hits, ..test_ctx(st, &mut act, s) }; scr.handle(g, &mut cx) } @@ -249,14 +241,7 @@ mod tests { let mut s = Settings::default(); let mut scr = SensorsScreen::new(); run(&mut scr, &mut st, &mut s, &[], Gesture::Step(1)); - let t = { - let mut act = Activity::new(Mode::Idle); - let mut cx = test_ctx(&mut st, &mut act, &mut s); - let t = scr.handle(Gesture::Press, &mut cx); - assert!(act.sensor_scan_active(), "entering a row raises scan mode"); - t - }; - match t { + match run(&mut scr, &mut st, &mut s, &[], Gesture::Press) { Transition::Push(Screen::SensorScan(scan)) => { assert_eq!(scan.slot, 1, "the Power slot travels with the scan") } @@ -284,46 +269,30 @@ mod tests { fn picking_a_hit_saves_and_pops() { let mut st = AppState::new(0, 0, 1.0); let mut s = Settings::default(); - let mut act = Activity::new(Mode::Idle); - act.request_sensor_scan(true); let mut scr = SensorScanScreen::new(0); let hits = [hit(1, "PWR", -50), hit(0, "HRM", -60), hit(0, "Watch", -72)]; // The press selects the first heart rate hit. The power hit is filtered out. - let t = run_scan(&mut scr, &mut st, &mut s, &mut act, &hits, Gesture::Press); + let t = run_scan(&mut scr, &mut st, &mut s, &hits, Gesture::Press); assert!(matches!(t, Transition::Pop), "a pick pops back to the row list"); assert!(s.saved_sensors[0].present, "the HR slot now holds a saved sensor"); assert_eq!(s.saved_sensors[0].addr, [1, 2, 3, 4, 5, 6]); - assert!(!act.sensor_scan_active(), "picking leaves scan mode"); } #[test] fn scan_cursor_bounded_to_kind_and_empty_is_safe() { let mut st = AppState::new(0, 0, 1.0); let mut s = Settings::default(); - let mut act = Activity::new(Mode::Idle); let mut scr = SensorScanScreen::new(2); let hits = [hit(0, "HRM", -60), hit(1, "PWR", -50)]; - run_scan(&mut scr, &mut st, &mut s, &mut act, &hits, Gesture::Step(1)); + run_scan(&mut scr, &mut st, &mut s, &hits, Gesture::Step(1)); assert_eq!(scr.selected, 0, "no cadence hits → the cursor can't move"); - let t = run_scan(&mut scr, &mut st, &mut s, &mut act, &hits, Gesture::Press); + let t = run_scan(&mut scr, &mut st, &mut s, &hits, Gesture::Press); assert!(matches!(t, Transition::None), "a press with no hit does nothing"); assert!(!s.saved_sensors[2].present, "and saves nothing"); } - #[test] - fn back_cancels_scan() { - let mut st = AppState::new(0, 0, 1.0); - let mut s = Settings::default(); - let mut act = Activity::new(Mode::Idle); - act.request_sensor_scan(true); - let mut scr = SensorScanScreen::new(0); - let t = run_scan(&mut scr, &mut st, &mut s, &mut act, &[], Gesture::Back); - assert!(matches!(t, Transition::Pop)); - assert!(!act.sensor_scan_active(), "Back leaves scan mode"); - } - #[test] fn status_line_reads_the_phase() { let en = Language::En; diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index ed5c30ddb..30b55a5e5 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -395,9 +395,7 @@ impl SetupSensorsScreen { match g { Gesture::Step(n) => on_step(&mut self.selected, n, SKIP + 1), Gesture::Press if self.selected == SKIP => finish(SetupStep::Sensors, cx), - // Scan mode makes the host run a discovery scan. The scan list lowers it on exit. Gesture::Press => { - cx.activity.request_sensor_scan(true); Transition::Push(Screen::SetupSensorScan(SetupSensorScanScreen::new(self.selected as u8))) } Gesture::Back => back(SetupStep::Sensors, cx), diff --git a/firmware/obc-app/src/ui_runtime.rs b/firmware/obc-app/src/ui_runtime.rs index 01d51840d..f27ef14d7 100644 --- a/firmware/obc-app/src/ui_runtime.rs +++ b/firmware/obc-app/src/ui_runtime.rs @@ -464,9 +464,10 @@ impl UiRuntime { tracking: bool, panning: bool, arrival: Option, + in_setup: bool, ) { let hold_charging = self.hold_charging(); - let ctx = CardCtx { now_ms: self.now_ms, hold_charging, catalogs, tracking, panning, arrival }; + let ctx = CardCtx { now_ms: self.now_ms, hold_charging, catalogs, tracking, panning, arrival, in_setup }; if self.cards.sweep(&mut self.stack, &ctx) { self.map_dirty = true; } From 12fbfb0e5db9a208aaca50c2bc9e0479a744c6ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:02:33 +0000 Subject: [PATCH 25/37] setup: a factory reset saves at once and opens setup The reset page kept a "Reset complete / Restarting" page on top. It is in the settings subtree, so the cleared settings waited there while the phone was forgotten at once. A power loss on that page left the old settings beside a forgotten phone, the idle return and the escape led to Home with setup pending, and nothing restarted. No port restarts the device from the app, so the hold now writes the factory settings and opens setup on the same pass. Setup is outside the subtree, so the save goes out beside the forget. The board opens pairing again once the bond slot is empty, with no restart. The done page and its two strings are gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 2 - firmware/obc-app/i18n/en.toml | 2 - firmware/obc-app/i18n/es.toml | 2 - firmware/obc-app/i18n/fr.toml | 2 - firmware/obc-app/src/app.rs | 32 +++++++++---- firmware/obc-app/src/screen/settings/reset.rs | 46 ++++++------------- 6 files changed, 37 insertions(+), 49 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index 7ccac78b3..8e8fc5236 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -333,8 +333,6 @@ all_added_sub = "Erst eins entfernen" # --- Factory-reset settings screen --- [reset] title = "ZURÜCKSETZEN" -complete = "Zurückgesetzt" -restarting = "Neustart" factory = "Werksreset" erases = "Löscht alle Werte" saved_time = "& die Uhrzeit." diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 08d6a01b5..0a735b463 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -361,8 +361,6 @@ all_added_sub = "Remove one to swap" # --- Factory-reset settings screen --- [reset] title = "RESET" -complete = "Reset complete" -restarting = "Restarting" factory = "Factory reset" erases = "Erases all settings" saved_time = "& saved time." diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 7a0b9ac04..40e206b16 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -332,8 +332,6 @@ all_added_sub = "Quita uno antes" # --- Factory-reset settings screen --- [reset] title = "RESTABLECER" -complete = "Restablecido" -restarting = "Reiniciando" factory = "Restablecer" erases = "Borra los ajustes" saved_time = "y la hora." diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 50b7ddee1..05e075771 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -332,8 +332,6 @@ all_added_sub = "Retirez-en un" # --- Factory-reset settings screen --- [reset] title = "RÉINITIALISER" -complete = "Réinitialisé" -restarting = "Redémarrage" factory = "Réinit. usine" erases = "Efface les réglages" saved_time = "& l'heure." diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index b0bcc18a8..5120e444a 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -4497,7 +4497,7 @@ mod tests { #[test] fn every_settings_screen_holds_a_pending_save_until_exit() { use crate::screen::settings::page; - use crate::screen::{apply, AddFieldScreen, ResetScreen, SettingsPage, StatFieldsScreen, Transition}; + use crate::screen::{apply, AddFieldScreen, SettingsPage, StatFieldsScreen, Transition}; use crate::settings::Units; /// The screens to stack on the Home root (bottom first — parents under children, as the @@ -4508,7 +4508,7 @@ mod tests { let _ = v.push(s); v } - let cases: [Case; 10] = [ + let cases: [Case; 9] = [ // Pure navigation — no edit gesture of its own. ("Settings list", || one(Screen::Settings(SettingsPage::hub())), &[]), // Open the UTC-offset editor sheet over the page, step it and commit; the sheet pops @@ -4548,13 +4548,11 @@ mod tests { ("Power", || one(Screen::Power(SettingsPage::new(&page::POWER))), &[Gesture::Step(1), Gesture::Press]), // Pure navigation — the Firmware page's install action leaves the settings subtree. ("Firmware", || one(Screen::Firmware(SettingsPage::new(&page::FIRMWARE))), &[]), - // Press arms, then the completed hold erases to defaults — a real diff off the seed below. - ("Reset", || one(Screen::Reset(ResetScreen::new())), &[Gesture::Press, Gesture::Hold]), ]; for (name, stack, edits) in cases { let mut app = App::new_idle(AppState::new(0, 0, 1.0)); - // A non-default seed, so the factory Reset's erase-to-defaults really changes something. + // A non-default seed, so a Units edit really changes something. app.set_settings(Settings { units: Units::Imperial, ..Settings::default() }); for s in stack() { apply(&mut app.ui.stack, Transition::Push(s)); @@ -4572,16 +4570,15 @@ mod tests { } assert!(!settings_dirty(&mut app), "{name}: the save is held while the screen is on top"); - // Back out of the subtree, closing any open field on the way. It ends on the Home root, - // or in first-use setup after a reset. + // Back out to the Home root, closing any open field on the way. for _ in 0..MAX_DEPTH_BACKOUT { - if !app.ui.top_is_settings() { + if app.ui.stack.len() == 1 { break; } assert!(!settings_dirty(&mut app), "{name}: still inside the settings subtree — save held"); app.apply_gesture(Gesture::Back); } - assert!(!app.ui.top_is_settings(), "{name}: backed out of the settings subtree"); + assert_eq!(app.ui.stack.len(), 1, "{name}: backed out to the Home root"); assert!(settings_dirty(&mut app), "{name}: leaving the settings subtree flushes the pending save"); assert!(!settings_dirty(&mut app), "{name}: the flag drains — exactly one save"); } @@ -4591,6 +4588,23 @@ mod tests { /// cannot loop forever. const MAX_DEPTH_BACKOUT: usize = crate::screen::MAX_DEPTH; + /// A factory reset saves the cleared settings at once and opens setup, so a power loss after + /// the hold boots into setup, as the forgotten phone expects. + #[test] + fn a_factory_reset_saves_at_once_and_opens_setup() { + use crate::screen::{ResetScreen, SettingsPage}; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings::default()); + let _ = app.ui.stack.push(Screen::Settings(SettingsPage::hub())); + let _ = app.ui.stack.push(Screen::Reset(ResetScreen::new())); + app.apply_gesture(Gesture::Press); + app.apply_gesture(Gesture::Hold); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::Hello(_)])); + assert_eq!(*app.settings(), Settings::FACTORY); + assert!(app.state.ble_forget_requested, "the paired phone is forgotten"); + assert!(settings_dirty(&mut app), "the reset is saved at once"); + } + /// A host-pushed warning still lands over the deepest ordinary mid-ride settings path. This /// walks that one path with gestures; how deep a rider can get at all, and what that leaves, /// belong to `the_deepest_descent_stops_short_of_max_depth`. diff --git a/firmware/obc-app/src/screen/settings/reset.rs b/firmware/obc-app/src/screen/settings/reset.rs index 84a6726d5..1f4522f35 100644 --- a/firmware/obc-app/src/screen/settings/reset.rs +++ b/firmware/obc-app/src/screen/settings/reset.rs @@ -1,7 +1,7 @@ //! The Factory Reset screen. The long-press threshold is about 500 ms, which is too short to feel //! safe alone, so a reset takes two steps: a press to arm, then a hold to erase. A hold on an -//! un-armed screen does nothing. The reset clears the settings and forgets the paired phone, so the -//! setup it starts can pair a phone again. It keeps the files on the card. +//! un-armed screen does nothing. The reset clears the settings and forgets the paired phone, and +//! opens first-use setup, which can pair a phone again. It keeps the files on the card. use embedded_graphics::prelude::Point; use obc_render::{ @@ -11,49 +11,40 @@ use obc_render::{ }; use crate::input::Gesture; -use crate::screen::vocab::chrome::{card_check, card_triangle, copy_w, title_frame, wrapped, TITLE_BAR_H}; +use crate::screen::vocab::chrome::{card_triangle, copy_w, title_frame, wrapped, TITLE_BAR_H}; use crate::screen::{palette, Ctx, Render, Transition}; use crate::settings::Settings; use crate::Msg; -/// `armed` is set by the first press, and only an armed screen can erase. `done` is set when the -/// reset is applied. +/// `armed` is set by the first press, and only an armed screen can erase. #[derive(Debug, Default)] pub struct ResetScreen { armed: bool, - done: bool, } impl ResetScreen { pub fn new() -> Self { - ResetScreen { armed: false, done: false } + ResetScreen { armed: false } } /// True while the hold-to-erase bar is on screen and fills with the live hold progress. pub(crate) fn hold_fill_active(&self) -> bool { - self.armed && !self.done + self.armed } pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { - if self.done { - // Any key starts setup again. The device would reboot here. - return match g { - Gesture::Press | Gesture::Back => crate::screen::setup::go_to(cx.settings), - _ => Transition::None, - }; - } match g { Gesture::Press if !self.armed => { self.armed = true; Transition::None } - // `apply_gesture` sees the change and flags the host to persist the cleared settings. - // The forget takes the Forget phone path, and does nothing while no phone is paired. + // Setup leaves the settings subtree, so the cleared settings are saved on this pass, + // beside the forget. The forget takes the Forget phone path, and does nothing while no + // phone is paired. Gesture::Hold if self.armed => { *cx.settings = Settings::FACTORY; cx.state.ble_forget_requested = true; - self.done = true; - Transition::None + crate::screen::setup::go_to(cx.settings) } Gesture::Back => Transition::Pop, _ => Transition::None, @@ -65,13 +56,6 @@ impl ResetScreen { let (w, h) = (rx.w, rx.h); title_frame(cv, w, h, rx.t(Msg::ResetTitle), ""); - if self.done { - card_check(cv, Point::new(w / 2, TITLE_BAR_H + 64), 26); - let y = wrapped(cv, rx.t(Msg::ResetComplete), w / 2, TITLE_BAR_H + 110, copy_w(w), Font::Body, INK); - wrapped(cv, rx.t(Msg::ResetRestarting), w / 2, y + 9, copy_w(w), Font::Label, SUBTEXT); - return; - } - card_triangle(cv, Point::new(w / 2, TITLE_BAR_H + 50), 24); wrapped(cv, rx.t(Msg::ResetFactory), w / 2, TITLE_BAR_H + 90, copy_w(w), Font::Body, WARNING); @@ -126,18 +110,16 @@ mod tests { let mut st = AppState::new(0, 0, 1.0); let mut scr = ResetScreen::new(); - run_in(&mut scr, &mut s, &mut st, Gesture::Hold); - assert!(!scr.done, "an un-armed hold does nothing"); + let t = run_in(&mut scr, &mut s, &mut st, Gesture::Hold); + assert!(matches!(t, Transition::None), "an un-armed hold does nothing"); assert_eq!((s, st.ble_forget_requested), (before, false), "and changes no settings"); run_in(&mut scr, &mut s, &mut st, Gesture::Press); - assert!(scr.armed && !scr.done); + assert!(scr.armed); let t = run_in(&mut scr, &mut s, &mut st, Gesture::Hold); - assert!(matches!(t, Transition::None), "stays to show the done message"); + assert!(matches!(t, Transition::Root(crate::Screen::Hello(_))), "setup opens at once"); assert_eq!(s, Settings::FACTORY, "settings were cleared to factory defaults"); assert!(st.ble_forget_requested, "the paired phone is forgotten"); - assert!(scr.done); - assert!(matches!(run(&mut scr, &mut s, Gesture::Press), Transition::Root(crate::Screen::Hello(_)))); } #[test] From 2eb09964f0f6702dd29239a5aa57e356d88fc755 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:05:53 +0000 Subject: [PATCH 26/37] settings: hide the Reset door while a ride records A reset during a ride opened setup over the ride, and setup has no way to the Map or the ride controls. The System page now shows the Reset door only while no ride records. The page rows read the context facts, which carry the recording level. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/src/screen/mod.rs | 4 +- firmware/obc-app/src/screen/settings/page.rs | 66 ++++++++++++++------ 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index 9e6c9d1a4..e31a6931a 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -1174,7 +1174,9 @@ impl Screen { Screen::Arrival(s) => s.selection_is_guarded(), Screen::Reset(s) => s.hold_fill_active(), Screen::StatFields(s) => s.selection_is_deletable(settings), - Screen::Connections(s) => s.selection_is_guarded(state), + Screen::Connections(s) => { + s.selection_is_guarded(&context_drawer::ContextFacts { state, navigation, settings, recording }) + } Screen::QuickDrawer(s) => s.selection_is_guarded(), Screen::Sensors(s) => s.selection_is_guarded(settings), Screen::RouteOverview(s) => s.selection_is_guarded(navigation, recording, routes), diff --git a/firmware/obc-app/src/screen/settings/page.rs b/firmware/obc-app/src/screen/settings/page.rs index 77ac9d813..21c1860ed 100644 --- a/firmware/obc-app/src/screen/settings/page.rs +++ b/firmware/obc-app/src/screen/settings/page.rs @@ -18,7 +18,7 @@ use crate::screen::vocab::list::{list_frame, scrollbar}; use crate::screen::vocab::rows::{self, Line2, RowIcon, ROW_GAP}; use crate::screen::{Ctx, Render, Screen, Transition}; use crate::sensors::SensorPhase; -use crate::{t, AppState, Msg}; +use crate::{t, Msg}; /// A page the hub or another page opens. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -185,13 +185,16 @@ pub(crate) static FIRMWARE: Menu = Menu { impl Item { /// Whether the row is on the page at all. The phone's Forget row is drawn only while there is a - /// bond to drop, the pairing code's door only while the radio is on and no phone is paired, and - /// the Sound door only on a platform that can make a sound. - fn shown(self, state: &AppState) -> bool { + /// bond to drop, the pairing code's door only while the radio is on and no phone is paired, the + /// Sound door only on a platform that can make a sound, and the Reset door only while no ride + /// records, because the setup it opens has no way to the ride. + fn shown(self, f: &ContextFacts) -> bool { + let state = f.state; match self { Item::Act(Act::ForgetPhone) => state.bond_status.can_forget(state.device.ble_paired), Item::Door(Door::PairPhone) => state.device.ble_link != BleLink::Off && !state.device.ble_paired, Item::Door(Door::Sound) => state.sound_available, + Item::Door(Door::Reset) => !f.recording, _ => true, } } @@ -249,24 +252,24 @@ impl SettingsPage { /// The cursor, on a row that is shown and selectable. Walks forward, then wraps, so a hidden /// row under the cursor yields to the next one. - fn resolved(&self, state: &AppState) -> usize { + fn resolved(&self, f: &ContextFacts) -> usize { let rows = self.menu.rows; - let ok = |i: usize| rows[i].item.shown(state) && rows[i].item.selectable(); + let ok = |i: usize| rows[i].item.shown(f) && rows[i].item.selectable(); (0..rows.len()).map(|k| (self.selected + k) % rows.len()).find(|&i| ok(i)).unwrap_or(self.selected) } /// Move the cursor `n` selectable shown rows, wrapping at both ends. - fn step(&mut self, n: i32, state: &AppState) { + fn step(&mut self, n: i32, f: &ContextFacts) { let rows = self.menu.rows; let len = rows.len() as i32; let dir = n.signum(); - let mut i = self.resolved(state) as i32; + let mut i = self.resolved(f) as i32; for _ in 0..n.unsigned_abs() { // Step at least one row, then on to the next selectable shown row, at most one lap. for _ in 0..len { i = (i + dir).rem_euclid(len); let item = rows[i as usize].item; - if item.selectable() && item.shown(state) { + if item.selectable() && item.shown(f) { break; } } @@ -275,17 +278,17 @@ impl SettingsPage { } /// True while the cursor is on a guarded act, so its hold fill draws. - pub(crate) fn selection_is_guarded(&self, state: &AppState) -> bool { - matches!(self.menu.rows[self.resolved(state)].item, Item::Act(Act::ForgetPhone)) + pub(crate) fn selection_is_guarded(&self, f: &ContextFacts) -> bool { + matches!(self.menu.rows[self.resolved(f)].item, Item::Act(Act::ForgetPhone)) } pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { - let i = self.resolved(cx.state); - let row = &self.menu.rows[i]; - let live = row.item.live(&cx.context_facts()); + let facts = cx.context_facts(); + let row = &self.menu.rows[self.resolved(&facts)]; + let live = row.item.live(&facts); match g { Gesture::Step(n) => { - self.step(n, cx.state); + self.step(n, &facts); Transition::None } Gesture::Press => match row.item { @@ -318,9 +321,9 @@ impl SettingsPage { pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { let (w, h) = (rx.w, rx.h); let facts = rx.context_facts(); - let selected = self.resolved(rx.state); + let selected = self.resolved(&facts); let shown: heapless::Vec = - (0..self.menu.rows.len()).filter(|&i| self.menu.rows[i].item.shown(rx.state)).collect(); + (0..self.menu.rows.len()).filter(|&i| self.menu.rows[i].item.shown(&facts)).collect(); let heights: heapless::Vec = shown.iter().map(|&i| self.menu.rows[i].item.height()).collect(); let avail = h - LIST_TOP - 6; let sel_pos = shown.iter().position(|&i| i == selected).unwrap_or(0); @@ -497,6 +500,29 @@ mod tests { (AppState::new(0, 0, 1.0), Settings::default()) } + fn facts<'a>(state: &'a AppState, settings: &'a Settings) -> ContextFacts<'a> { + const IDLE: crate::navigator::RouteState = crate::navigator::RouteState::new(); + ContextFacts { state, navigation: &IDLE, settings, recording: false } + } + + /// The Reset door hides while a ride records, because the setup it opens has no way back to + /// the ride. The cursor wraps from the first row onto the last one shown. + #[test] + fn the_reset_door_hides_while_a_ride_records() { + let (mut st, mut s) = world(); + let mut act = Activity::new(Mode::Idle); + let mut recorder = crate::RecorderMachine::new(); + let mut cx = Ctx { recorder: &mut recorder, ..test_ctx(&mut st, &mut act, &mut s) }; + let last = |cx: &mut Ctx| { + let mut system = SettingsPage::new(&SYSTEM); + system.handle(Gesture::Step(-1), cx); + system.handle(Gesture::Press, cx) + }; + assert!(matches!(last(&mut cx), Transition::Push(Screen::Reset(_)))); + cx.recorder.test_open(); + assert!(matches!(last(&mut cx), Transition::Push(Screen::About(_)))); + } + #[test] fn every_hub_door_opens_its_page_and_the_tables_fit_a_page() { let (mut st, mut s) = world(); @@ -572,15 +598,15 @@ mod tests { st.device.ble_paired = true; run(&mut conn, &mut st, &mut s, Gesture::Step(1)); assert_eq!(conn.selected, 4, "paired: the Forget row replaces the pairing code's"); - assert!(conn.selection_is_guarded(&st)); + assert!(conn.selection_is_guarded(&facts(&st, &s))); run(&mut conn, &mut st, &mut s, Gesture::Press); assert!(!st.ble_forget_requested, "a plain press never forgets"); run(&mut conn, &mut st, &mut s, Gesture::Hold); assert!(st.ble_forget_requested, "the completed hold records the forget request"); st.device.ble_paired = false; - assert_eq!(conn.resolved(&st), 0, "the row under the cursor vanished: it moves on"); - assert!(!conn.selection_is_guarded(&st)); + assert_eq!(conn.resolved(&facts(&st, &s)), 0, "the row under the cursor vanished: it moves on"); + assert!(!conn.selection_is_guarded(&facts(&st, &s))); let mut dt = SettingsPage::new(&DATETIME); assert_eq!(dt.selected, 2, "Date & time parks on its one editable row"); From 1ce5e5583d4fcdc9b1dcb356debdda32e7d02462 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:08:38 +0000 Subject: [PATCH 27/37] setup: the quick drawer opens over setup, without its Settings row Setup refused every chord, and the quick drawer holds the only power off and the brightness. Setup pages now refuse only the escape. The quick drawer opens over them without its Settings control, and the Assistant chord is refused while setup runs, so nothing in reach leaves setup. The theme step keeps its preview under the drawer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/src/app.rs | 35 +++++++++++++++------ firmware/obc-app/src/screen/mod.rs | 27 ++++++++-------- firmware/obc-app/src/screen/quick_drawer.rs | 26 ++++++++++----- firmware/obc-app/src/settings.rs | 5 +++ 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 5120e444a..904168ca5 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1911,7 +1911,7 @@ impl App { /// card lands in the same frame unless a policy rule defers it. fn sweep_cards(&mut self) { let arrival = self.arrival_view(); - let in_setup = self.settings.setup != crate::settings::SetupStep::Done; + let in_setup = self.settings.in_setup(); self.ui.run_card_sweep(&self.catalogs, self.recorder.recording(), self.state.pan.is_some(), arrival, in_setup); if self.ui.stack.iter().any(|s| matches!(s, Screen::Journey(_))) || self.ui.find.resume_offer { self.ui.find.review = self.assistant_review_status(); @@ -2177,10 +2177,13 @@ impl App { /// It is resolved here, not in a screen, because the recogniser already swallowed the /// chord's constituents and the sheet must open over whatever the rider is on. Two rules /// live here only: a modal that declares [`Caps::blocks_chords`](crate::screen::Caps) stops - /// every chord, also under a sheet it opened, and one drawer is open at a time, so the same - /// chord again closes it. + /// every chord, and one drawer is open at a time, so the same chord again closes it. pub fn apply_chord(&mut self, chord: Chord) -> bool { - if screen::base_screen(&self.ui.stack).is_some_and(|s| s.caps().blocks_chords) { + if self.ui.stack.last().is_some_and(|s| s.caps().blocks_chords) { + return false; + } + // Setup ends only on its own pages, and the Assistant is a place of its own. + if chord == Chord::Assistant && self.settings.in_setup() { return false; } // The powering-off frame refuses a squeeze. It cannot be said in `Caps`, because the @@ -2410,10 +2413,10 @@ impl App { self.settings.theme = theme; } - /// The theme the frame draws in. Setup's theme step previews the theme under its cursor, and - /// the setting changes only when the rider commits it. + /// The theme the frame draws in. Setup's theme step previews the theme under its cursor, also + /// under a drawer, and the setting changes only when the rider commits it. pub(crate) fn theme(&self) -> crate::settings::Theme { - match self.ui.stack.last() { + match screen::base_screen(&self.ui.stack) { Some(Screen::SetupTheme(step)) => step.0, _ => self.settings.theme, } @@ -6356,7 +6359,7 @@ mod tests { /// The effort step edits each limit in the drawer editor over the page: the editor's Select /// commits and saves the value, and its Back discards. The sheet keeps setup's refusals of the - /// chords, the escape and the idle return. Back on the page returns to the sensors step with the + /// Assistant chord, the escape and the idle return. Back on the page returns to the sensors step with the /// committed limits kept, and the last row opens the All set page. #[test] fn the_effort_step_edits_its_limits_in_the_drawer_editor() { @@ -6372,7 +6375,7 @@ mod tests { matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupEffort(_), Screen::ContextDrawer(_)]) }; assert!(editing(&app), "Select on Max heart rate opens its editor"); - assert!(!app.apply_chord(Chord::Quick)); + assert!(!app.apply_chord(Chord::Assistant)); app.apply_gesture(Gesture::BackHold); idle_tick(&mut app, 60_000); idle_tick(&mut app, 120_000); @@ -6430,6 +6433,20 @@ mod tests { assert!(host.drain(&mut app).is_some(), "setup is saved as done"); } + /// The quick drawer opens over setup for the power and the light, and holds no way out of it. + /// The Assistant chord and the escape stay refused. + #[test] + fn the_quick_drawer_opens_over_setup() { + use crate::input::Chord; + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + app.set_settings(Settings::FACTORY); + assert!(!app.apply_chord(Chord::Assistant)); + assert!(app.apply_chord(Chord::Quick)); + assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::Hello(_), Screen::QuickDrawer(_)])); + app.apply_gesture(Gesture::BackHold); + assert!(matches!(app.ui.stack.get(1), Some(Screen::Hello(_))), "setup cannot be escaped"); + } + /// A route card waits while setup runs, because its VIEW leads on to a ride, and lands once /// setup ends. #[test] diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index e31a6931a..8eff7fdfe 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -913,36 +913,37 @@ screens! { /// dismiss it; Continue preserves restored totals, while Discard is hold-guarded. RideRecovery(RideRecoveryScreen) => Caps::modal().blocks_escape(), /// First-use setup's greeting in the four UI languages. Setup refuses the escape and the - /// drawers: it ends only when its last step is done. - Hello(HelloScreen) => Caps::modal().blocking(), + /// Assistant chord: it ends only when its last step is done. The quick drawer opens over it + /// without its Settings control. + Hello(HelloScreen) => Caps::modal().blocks_escape(), /// Setup's language step: the Language pick list, where Select ends the step and Back returns /// to Hello. - SetupLanguage(SetupLanguageScreen) => Caps::modal().blocking(), + SetupLanguage(SetupLanguageScreen) => Caps::modal().blocks_escape(), /// Setup's button lesson: each press fills its button's board. Once all four are filled, /// Select ends the step and Back returns to the language step. - SetupButtons(SetupButtonsScreen) => Caps::modal().blocking(), + SetupButtons(SetupButtonsScreen) => Caps::modal().blocks_escape(), /// Setup's units step: Metric or Imperial over a preview of the ride tiles. - SetupUnits(SetupUnitsScreen) => Caps::modal().blocking(), + SetupUnits(SetupUnitsScreen) => Caps::modal().blocks_escape(), /// Setup's theme step: Light or Dark. The frame draws in the theme under its cursor. - SetupTheme(SetupThemeScreen) => Caps::modal().blocking(), + SetupTheme(SetupThemeScreen) => Caps::modal().blocks_escape(), /// Setup's pairing step: the QR code of the pairing link. A bond ends the step, and Back opens /// the page that asks whether to ride without the app. - SetupQr(SetupQrScreen) => Caps::modal().blocking(), + SetupQr(SetupQrScreen) => Caps::modal().blocks_escape(), /// Ride without the app: a row back to the code and Skip, which ends the pairing step. - SetupNoApp(SetupNoAppScreen) => Caps::modal().blocking(), + SetupNoApp(SetupNoAppScreen) => Caps::modal().blocks_escape(), /// The page a bond opens on the pairing step: the phone is paired, and each side goes on with /// its own steps. Select opens the next step. - SetupPaired(SetupPairedScreen) => Caps::modal().blocking(), + SetupPaired(SetupPairedScreen) => Caps::modal().blocks_escape(), /// Setup's sensors step: the three sensor slots with their live status, then Skip or Continue. - SetupSensors(SetupSensorsScreen) => Caps::modal().blocking().key(RenderKeyKind::SensorSettings), + SetupSensors(SetupSensorsScreen) => Caps::modal().blocks_escape().key(RenderKeyKind::SensorSettings), /// The Settings scan list for one slot in the setup chrome, opened from the sensors step. It /// blocks the escape, because the step it returns to does. - SetupSensorScan(SetupSensorScanScreen) => Caps::modal().blocking().key(RenderKeyKind::SensorSettings), + SetupSensorScan(SetupSensorScanScreen) => Caps::modal().blocks_escape().key(RenderKeyKind::SensorSettings), /// Setup's effort step: max heart rate and FTP, each edited in the drawer editor over the page, /// then a row that continues. - SetupEffort(SetupEffortScreen) => Caps::modal().blocking(), + SetupEffort(SetupEffortScreen) => Caps::modal().blocks_escape(), /// Setup's last step: what the rider set up. Select ends setup and opens Home. - SetupAllSet(SetupAllSetScreen) => Caps::modal().blocking(), + SetupAllSet(SetupAllSetScreen) => Caps::modal().blocks_escape(), /// The card after Finish on a trip day: today's ledger, then tomorrow's day or the trip's /// totals. OK returns Home. DayDone(DayDoneScreen) => Caps::modal(), diff --git a/firmware/obc-app/src/screen/quick_drawer.rs b/firmware/obc-app/src/screen/quick_drawer.rs index 43325ca89..36fbb28eb 100644 --- a/firmware/obc-app/src/screen/quick_drawer.rs +++ b/firmware/obc-app/src/screen/quick_drawer.rs @@ -54,14 +54,16 @@ enum Control { /// The controls this device actually has, in row order. On a platform whose panel has no light the /// brightness row is dropped, because a slider that moves over zero photons is a lie. The row comes -/// back the moment the hardware does, and nothing else about the sheet changes. -fn controls(backlight: bool) -> &'static [Control] { - const WITH_LIGHT: [Control; 4] = [Control::Brightness, Control::Ble, Control::Settings, Control::Power]; - const NO_LIGHT: [Control; 3] = [Control::Ble, Control::Settings, Control::Power]; +/// back the moment the hardware does, and nothing else about the sheet changes. While setup runs +/// the Settings row is dropped, because setup ends only on its own pages. +fn controls(backlight: bool, in_setup: bool) -> &'static [Control] { + const ALL: [Control; 4] = [Control::Brightness, Control::Ble, Control::Settings, Control::Power]; + const SETUP: [Control; 3] = [Control::Brightness, Control::Ble, Control::Power]; + let row: &'static [Control] = if in_setup { &SETUP } else { &ALL }; if backlight { - &WITH_LIGHT + row } else { - &NO_LIGHT + &row[1..] } } @@ -168,7 +170,7 @@ impl QuickDrawerScreen { } fn handle_root(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { - let row = controls(cx.backlight); + let row = controls(cx.backlight, cx.settings.in_setup()); match g { Gesture::Step(n) => { self.selected = super::vocab::list::step_selection(self.selected as usize, n, row.len()) as u8; @@ -293,7 +295,7 @@ impl QuickDrawerScreen { /// The unlabelled icons, plus one line naming the selected one. fn draw_root(&self, cv: &mut impl Surface, rx: &Render, top: i32, x: i32) { const STEP: i32 = 57; - let row = controls(rx.backlight); + let row = controls(rx.backlight, rx.settings.in_setup()); let first = x + (rx.w - STEP * (row.len() as i32 - 1)) / 2; for (i, control) in row.iter().enumerate() { let c = Point::new(first + i as i32 * STEP, top + 32); @@ -460,6 +462,14 @@ mod tests { use crate::screen::test_ctx; use crate::{Activity, AppState, Settings}; + /// While setup runs the row has no Settings control, because setup ends only on its own pages. + #[test] + fn setup_drops_the_settings_control() { + assert!(controls(true, false).contains(&Control::Settings)); + assert_eq!(controls(true, true), [Control::Brightness, Control::Ble, Control::Power]); + assert_eq!(controls(false, true), [Control::Ble, Control::Power]); + } + /// A drawer with its animations already finished, so `handle` acts immediately. The first tick /// is the open's origin, so it is taken a whole [`OPEN_MS`] before `now_ms`. fn settled(now_ms: u32) -> QuickDrawerScreen { diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index a11c9dbf2..50d4da37f 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -575,6 +575,11 @@ impl Settings { /// store holds no valid blob, and a factory reset writes it. pub const FACTORY: Settings = Settings { setup: SetupStep::Hello, ..Settings::DEFAULT }; + /// Whether first-use setup is running. + pub(crate) fn in_setup(&self) -> bool { + self.setup != SetupStep::Done + } + pub(crate) fn find_hours_filter(&self) -> obc_reader::reader::places::HoursFilter { use obc_reader::reader::places::HoursFilter; if self.find_hide_closed { From 44b08a4c9a38383052790071fca98c7705722102 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:12:35 +0000 Subject: [PATCH 28/37] setup: skip the pairing step on a platform that does not bond The iOS host does not bond, but setup showed its pairing code and Connections offered Pair phone, and neither could finish. The platform support's bonding fact now reaches AppState before the input of every pass. Setup passes the pairing step by and Connections hides the door when it is false. The iOS host feeds a factory name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- apps/obc-ios-host/src/lib.rs | 4 ++++ apps/obc-ios-host/src/tests.rs | 20 ++++++++++++++++++ firmware/obc-app/src/app.rs | 7 ++++++- firmware/obc-app/src/device_core/pass.rs | 1 + firmware/obc-app/src/screen/settings/page.rs | 15 +++++++++---- firmware/obc-app/src/screen/setup.rs | 22 ++++++++++---------- 6 files changed, 53 insertions(+), 16 deletions(-) diff --git a/apps/obc-ios-host/src/lib.rs b/apps/obc-ios-host/src/lib.rs index 644407508..a9b7ab2e0 100644 --- a/apps/obc-ios-host/src/lib.rs +++ b/apps/obc-ios-host/src/lib.rs @@ -35,6 +35,9 @@ use std::path::Path; pub const FRAME_W: u32 = obc_display::ls021::FRAME_W as u32; pub const FRAME_H: u32 = obc_display::ls021::FRAME_H as u32; +/// The phone's stand-in for the board's factory name, which the board derives from its device id. +const FACTORY_NAME: &str = "OBC-IOS"; + /// What the phone honestly is. It has a card and a settings file, so detours and persisted /// settings are real. It is not a BLE peripheral, carries no staged firmware and reports no free /// space, so the screens behind those hide rather than offer a control that answers nothing. @@ -181,6 +184,7 @@ impl Host { // The phone runs the settings a rider runs: whatever was saved, or a factory-fresh device. app.set_settings(boot_settings); app.set_sound_available(true); + app.set_factory_name(FACTORY_NAME); tracks.offer_recovery(&mut app); Ok(Box::new(Host { diff --git a/apps/obc-ios-host/src/tests.rs b/apps/obc-ios-host/src/tests.rs index 4fdd2a9f7..ea4d0f436 100644 --- a/apps/obc-ios-host/src/tests.rs +++ b/apps/obc-ios-host/src/tests.rs @@ -152,6 +152,26 @@ fn a_select_tap_presses_and_a_held_select_holds() { std::fs::remove_dir_all(directory).unwrap(); } +/// The phone does not bond, so setup passes the pairing step by: Select on the theme step opens +/// the sensors step. +#[test] +fn setup_on_the_phone_passes_the_pairing_step_by() { + use obc_app::settings::SetupStep; + let (card, directory) = card("setup"); + let settings = Settings { setup: SetupStep::Theme, ..Settings::FACTORY }; + FileSettingsStore::open(directory.join("settings")).save(&settings).expect("the settings file is written"); + let mut host = open(&card, &directory); + host.tick(0.0); + assert_eq!(host.screen(), "SetupTheme"); + host.push_button(Button::Select, true); + host.tick(16.0); + host.push_button(Button::Select, false); + host.tick(100.0); + assert_eq!(host.screen(), "SetupSensors"); + drop(host); + std::fs::remove_dir_all(directory).unwrap(); +} + /// A GPX route converts through the one shared conversion, attributed against the card's map. #[test] fn a_gpx_route_imports_as_the_shared_conversion_attributed_to_the_card_map() { diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 904168ca5..573e0ccc2 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -124,6 +124,10 @@ pub struct AppState { /// Whether this platform can make a sound, declared once by the host at composition through /// [`App::set_sound_available`]. `false` hides the Sound settings page and plays no cue. pub sound_available: bool, + /// Whether this platform bonds a phone: [`PlatformSupport::bonding`](crate::device_core::PlatformSupport), + /// fed before the input of every pass. It reads `true` until the first pass. `false` hides the + /// pairing code in setup and in Connections. + pub bonding: bool, /// The Up-ahead timeline's category filter. It resets to Everything on each entry to the /// list. It lives here, not on the list screen, because the sheet that edits it sits above @@ -155,6 +159,7 @@ impl AppState { bond_status: crate::ble::BondStatus::Idle, has_nav_graph: false, sound_available: false, + bonding: true, up_ahead_filter: obc_reader::PoiCategorySet::ALL, } @@ -1890,7 +1895,7 @@ impl App { return; }; if matches!(self.ui.stack[i], Screen::SetupQr(_)) { - self.settings.setup = screen::setup::after(crate::settings::SetupStep::Qr, true); + self.settings.setup = screen::setup::after(crate::settings::SetupStep::Qr, &self.state); self.settings_ops.note_edited(); screen::apply(&mut self.ui.stack, screen::Transition::Root(Screen::SetupPaired(screen::SetupPairedScreen))); } else { diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index 3662e3bed..464b87cf4 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -226,6 +226,7 @@ impl App { self.stage_outcomes(outcomes, now.ui.0); self.stage_facts(facts, derived, targets); + self.state.bonding = support.bonding; self.stage_input(now, gestures, sensors, route); self.stage_ui(now); diff --git a/firmware/obc-app/src/screen/settings/page.rs b/firmware/obc-app/src/screen/settings/page.rs index 21c1860ed..27e3b847d 100644 --- a/firmware/obc-app/src/screen/settings/page.rs +++ b/firmware/obc-app/src/screen/settings/page.rs @@ -185,14 +185,16 @@ pub(crate) static FIRMWARE: Menu = Menu { impl Item { /// Whether the row is on the page at all. The phone's Forget row is drawn only while there is a - /// bond to drop, the pairing code's door only while the radio is on and no phone is paired, the - /// Sound door only on a platform that can make a sound, and the Reset door only while no ride - /// records, because the setup it opens has no way to the ride. + /// bond to drop, the pairing code's door only on a platform that bonds while the radio is on and + /// no phone is paired, the Sound door only on a platform that can make a sound, and the Reset + /// door only while no ride records, because the setup it opens has no way to the ride. fn shown(self, f: &ContextFacts) -> bool { let state = f.state; match self { Item::Act(Act::ForgetPhone) => state.bond_status.can_forget(state.device.ble_paired), - Item::Door(Door::PairPhone) => state.device.ble_link != BleLink::Off && !state.device.ble_paired, + Item::Door(Door::PairPhone) => { + state.bonding && state.device.ble_link != BleLink::Off && !state.device.ble_paired + } Item::Door(Door::Sound) => state.sound_available, Item::Door(Door::Reset) => !f.recording, _ => true, @@ -594,6 +596,11 @@ mod tests { run(&mut conn, &mut st, &mut s, Gesture::Step(-1)); assert_eq!(conn.selected, 1, "with the radio off, no code is offered"); st.device.ble_link = BleLink::Advertising; + st.bonding = false; + run(&mut conn, &mut st, &mut s, Gesture::Step(1)); + assert_eq!(conn.selected, 0, "a platform that does not bond offers no code"); + run(&mut conn, &mut st, &mut s, Gesture::Step(-1)); + st.bonding = true; st.device.ble_paired = true; run(&mut conn, &mut st, &mut s, Gesture::Step(1)); diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index 30b55a5e5..d9eaadf0d 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -22,7 +22,7 @@ use crate::effort::Metric; use crate::i18n::t; use crate::input::Gesture; use crate::settings::{Language, Settings, SetupStep, Theme, Units, SENSOR_SLOTS}; -use crate::Msg; +use crate::{AppState, Msg}; use super::context_drawer::{ContextDrawerScreen, ContextValue}; use super::home::contours; @@ -60,36 +60,36 @@ pub(crate) fn go_to(s: &Settings) -> Transition { screen(s).map_or(Transition::Home, Transition::Root) } -/// Whether `step` shows. The pairing step shows its code only while the bond slot is empty (BLE -/// spec §9.3), so a bonded device passes it by. -fn shows(step: SetupStep, paired: bool) -> bool { - step != SetupStep::Qr || !paired +/// Whether `step` shows. The pairing step shows only on a platform that bonds, and only while the +/// bond slot is empty (BLE spec §9.3), so a bonded device passes it by. +fn shows(step: SetupStep, state: &AppState) -> bool { + step != SetupStep::Qr || (state.bonding && !state.device.ble_paired) } /// The first step that shows as `walk` leaves `step`. -fn walk_to_shown(step: SetupStep, paired: bool, walk: fn(SetupStep) -> SetupStep) -> SetupStep { +fn walk_to_shown(step: SetupStep, state: &AppState, walk: fn(SetupStep) -> SetupStep) -> SetupStep { let mut to = walk(step); - while !shows(to, paired) { + while !shows(to, state) { to = walk(to); } to } /// The first step after `step` that shows. -pub(crate) fn after(step: SetupStep, paired: bool) -> SetupStep { - walk_to_shown(step, paired, SetupStep::next) +pub(crate) fn after(step: SetupStep, state: &AppState) -> SetupStep { + walk_to_shown(step, state, SetupStep::next) } /// End setup step `step`: persist the next step that shows and go to it. fn finish(step: SetupStep, cx: &mut Ctx) -> Transition { - cx.settings.setup = after(step, cx.state.device.ble_paired); + cx.settings.setup = after(step, cx.state); go_to(cx.settings) } /// Leave setup step `step` for the one before it that shows. The persisted step follows, so a /// power loss resumes on the step the rider sees. fn back(step: SetupStep, cx: &mut Ctx) -> Transition { - cx.settings.setup = walk_to_shown(step, cx.state.device.ble_paired, SetupStep::prev); + cx.settings.setup = walk_to_shown(step, cx.state, SetupStep::prev); go_to(cx.settings) } From 7ec86525975e67f6ddbf8ba0b061a4f9fbc0dbb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:14:14 +0000 Subject: [PATCH 29/37] sensors: Select picks the drawn row after the scan list shrinks The draw clamps the cursor to a list that refreshed shorter, but Select read the raw index and picked nothing. The scan list now clamps the cursor before it handles a gesture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- .../obc-app/src/screen/settings/sensors.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/firmware/obc-app/src/screen/settings/sensors.rs b/firmware/obc-app/src/screen/settings/sensors.rs index 12f00f29a..e42166c7d 100644 --- a/firmware/obc-app/src/screen/settings/sensors.rs +++ b/firmware/obc-app/src/screen/settings/sensors.rs @@ -138,11 +138,14 @@ impl SensorScanScreen { } pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + // The list refreshes under the cursor, so a shorter one moves it onto its last row, where + // the draw puts it. + let len = self.count(cx.sensor_scan_hits); + self.selected = self.selected.min(len.saturating_sub(1)); match g { Gesture::Step(n) => { - let len = self.count(cx.sensor_scan_hits); if len > 0 { - self.selected = crate::screen::vocab::list::step_selection(self.selected.min(len - 1), n, len); + self.selected = crate::screen::vocab::list::step_selection(self.selected, n, len); } Transition::None } @@ -279,6 +282,20 @@ mod tests { assert_eq!(s.saved_sensors[0].addr, [1, 2, 3, 4, 5, 6]); } + /// A list that shrinks under the cursor leaves it on the last row, and Select picks that row. + #[test] + fn select_picks_the_last_row_after_the_list_shrinks_under_the_cursor() { + let mut st = AppState::new(0, 0, 1.0); + let mut s = Settings::default(); + let mut scr = SensorScanScreen::new(1); + let hits: Vec = + (0..6u8).map(|i| SensorScanHit::new(1, 0, [i, 2, 3, 4, 5, 6], "PWR", -40)).collect(); + run_scan(&mut scr, &mut st, &mut s, &hits, Gesture::Step(-1)); + let t = run_scan(&mut scr, &mut st, &mut s, &hits[..3], Gesture::Press); + assert!(matches!(t, Transition::Pop)); + assert_eq!(s.saved_sensors[1], SavedSensor::saved(0, [2, 2, 3, 4, 5, 6])); + } + #[test] fn scan_cursor_bounded_to_kind_and_empty_is_safe() { let mut st = AppState::new(0, 0, 1.0); From 533f8e92fa4a0d082ad2e221fe5e7ca9bd6e2c98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:18:46 +0000 Subject: [PATCH 30/37] setup: short capital titles for the scan lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup scan list took the Settings row label as its title. Beside the step count it was cut in German, Spanish and French ("Trittfrequ..", "Frec. card..", "Fréq. card.."), and it was in mixed case while every other setup title is in capitals. Each kind now has a short capital setup title that fits in all four languages. The copy-fit harness seeds all three kinds and checks that each title draws whole. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/i18n/de.toml | 3 +++ firmware/obc-app/i18n/en.toml | 4 ++++ firmware/obc-app/i18n/es.toml | 3 +++ firmware/obc-app/i18n/fr.toml | 3 +++ firmware/obc-app/src/harness/copy_fit.rs | 17 +++++++++++++++++ firmware/obc-app/src/screen/setup.rs | 12 +++++++++++- firmware/ui-snapshots.sha256 | 8 ++++---- 7 files changed, 45 insertions(+), 5 deletions(-) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index 8e8fc5236..ec86e187f 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -546,6 +546,9 @@ skip = "Überspringen" units = "EINHEITEN" theme = "FARBSCHEMA" effort = "ZONEN" +heart_rate = "HERZFREQUENZ" +power = "LEISTUNG" +cadence = "TRITTFREQ." qr = "APP HOLEN" no_app = "Ohne App?" no_app_title = "OHNE APP" diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 0a735b463..8c4652e3e 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -584,6 +584,10 @@ skip = "Skip" units = "UNITS" theme = "THEME" effort = "ZONES" +# The sensors step's scan list titles, one per kind. Each fits beside the step count. +heart_rate = "HEART RATE" +power = "POWER" +cadence = "CADENCE" # The pairing step: its title, its Back hint, and the page that asks to ride without the app. qr = "GET THE APP" no_app = "No app?" diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 40e206b16..b39261b20 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -545,6 +545,9 @@ skip = "Omitir" units = "UNIDADES" theme = "TEMA" effort = "ZONAS" +heart_rate = "PULSO" +power = "POTENCIA" +cadence = "CADENCIA" qr = "OBTÉN LA APP" no_app = "¿Sin app?" no_app_title = "SIN APP" diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 05e075771..0992e05e2 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -545,6 +545,9 @@ skip = "Passer" units = "UNITÉS" theme = "THÈME" effort = "ZONES" +heart_rate = "CARDIO" +power = "PUISSANCE" +cadence = "CADENCE" qr = "L'APPLI OBC" no_app = "Sans appli" no_app_title = "SANS APPLI" diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 70b8634ca..7e35de32f 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -125,7 +125,9 @@ fn seeds(language: Language) -> Vec { Screen::SetupPaired(SetupPairedScreen), Screen::PairPhone(PairPhoneScreen), Screen::SetupSensors(SetupSensorsScreen::default()), + Screen::SetupSensorScan(SetupSensorScanScreen::new(0)), Screen::SetupSensorScan(SetupSensorScanScreen::new(1)), + Screen::SetupSensorScan(SetupSensorScanScreen::new(2)), Screen::SetupAllSet(SetupAllSetScreen), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Receiving { received_kib: 1_024, total_kib: 65_536 })), Screen::MapTransfer(MapTransferScreen::new(MapTransfer::Installed)), @@ -369,6 +371,21 @@ fn every_string_fits_the_panel_in_every_language() { report(offenders); } +/// A setup scan list's title fits beside the step count whole in every language: the title bar +/// cuts a title that does not, and the cut line still fits the panel. +#[test] +fn every_setup_scan_title_is_drawn_whole() { + let bytes = build_min_obcm(0xF800); + for language in Language::ALL { + for slot in 0..crate::settings::SENSOR_SLOTS { + let title = crate::i18n::t(crate::screen::setup::scan_title(slot), language); + let seed = plain(vec![Screen::SetupSensorScan(SetupSensorScanScreen::new(slot as u8))]).remove(0); + let drawn = walk(seed, language, &bytes, |_| {}); + assert!(drawn.iter().any(|(_, d)| d.text == title), "{language:?}: {title:?} is cut"); + } + } +} + /// The reading page, which `Landmarks`, `LandmarkSources` and `PeakArticle` all draw. It needs a /// map with a landmark section under it, so it is its own sweep: the minimal fixture leaves the /// page on its status line, which draws none of the article's copy. diff --git a/firmware/obc-app/src/screen/setup.rs b/firmware/obc-app/src/screen/setup.rs index d9eaadf0d..ea6cd106a 100644 --- a/firmware/obc-app/src/screen/setup.rs +++ b/firmware/obc-app/src/screen/setup.rs @@ -429,6 +429,16 @@ impl SetupSensorsScreen { #[derive(Debug)] pub struct SetupSensorScanScreen(SensorScanScreen); +/// A scan list's title: its sensor kind, short and in capitals as every setup title is, so it fits +/// beside the step count. +pub(crate) fn scan_title(slot: usize) -> Msg { + match slot { + 0 => Msg::SetupHeartRate, + 1 => Msg::SetupPower, + _ => Msg::SetupCadence, + } +} + impl SetupSensorScanScreen { pub fn new(slot: u8) -> Self { SetupSensorScanScreen(SensorScanScreen::new(slot)) @@ -440,7 +450,7 @@ impl SetupSensorScanScreen { pub fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { let (w, h) = (rx.w, rx.h); - title_bar(cv, w, h, SetupStep::Sensors, rx.t(kind_msg(self.0.slot as usize))); + title_bar(cv, w, h, SetupStep::Sensors, rx.t(scan_title(self.0.slot as usize))); self.0.draw_list(cv, rx, h - 8 - HINT_H - 12); hint(cv, w, h, true, rx.t(Msg::SetupChoose), Some(Key::Ok(rx.t(Msg::SetupOk)))); } diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index 0df729ed9..75462fa85 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -358,10 +358,10 @@ ae0575193829cd7b6a566cc9e9e0c85a856ecc48f27ea9ee8fc1f5f5a8ba61f4 setup-qr.png 57498cf0d19c72506f58fcd53665f4677b0b54466e8d5b359efaee4c06e60e61 setup-sensors-de.png 9471b2d517e513d659f3049cd4d79dd394424386244524d50525620d2d14ccc5 setup-sensors-es.png 1de423a245c6c7d1c89ecfdf17b2df2223f198e7db2580e42805a7b9ef37ed90 setup-sensors-fr.png -f9f345307ae8bc920c9251e1eed001dfa68053a5fccb2ea90ba3e7e9c0085b13 setup-sensors-scanning-de.png -654e2840d4649616382e211afe345b7ae087055cc0e9210f5951690b9ed30e11 setup-sensors-scanning-es.png -ec158c2c45f08030ad6f5e4bd4c059419c7cfb548a201021d0ff085ccaf9ba30 setup-sensors-scanning-fr.png -0131b15a77837ec989b70cf600fd7ec1c08370a42019f10eeb55da758fc1c7e9 setup-sensors-scanning.png +0ea991f685a5b8f8e69fd0ba5f9f32ca2b75c1f844680df53e33234437fc95ab setup-sensors-scanning-de.png +a338af0dd6c94115a83c39dd67ce69ab76851c39a3718d4f4499ee34fbea02b0 setup-sensors-scanning-es.png +081f709f2b1d6d5dd5625544011000f2436906d2e13d465c54453f674068c5e4 setup-sensors-scanning-fr.png +dd256bead91e9dc62ef671f2685e62621213d2ac070f88eaa75b095b1039775f setup-sensors-scanning.png 28bcf36d26a203ec67dc68851e2d899d86628f60c2097ee649e63377c3f8d937 setup-sensors.png b81eece169e430fb9f7059b1000362420217421ff337e263e87c2b955a965cec setup-theme-dark.png cddb456e5109ae9f21ba2aeed625a11411a21e0d87903793dfcb93c5f1f1e6e3 setup-theme-de.png From f4d919b21df22b8856507165e05ada758916801e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:20:50 +0000 Subject: [PATCH 31/37] setup: open setup once a recovered ride is answered The board offers a recovered ride before it seeds the settings, so setup waited for the next boot. The iOS host offers it after, and the recovery card replaced setup. The recovery card's answers now open the saved setup step while setup is unfinished, whatever the order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-app/src/app.rs | 26 +++++++++++++++++++- firmware/obc-app/src/screen/ride_recovery.rs | 14 ++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 573e0ccc2..9d30813d0 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -2394,7 +2394,7 @@ impl App { // Clean. A pending edit is discarded, because seeding is a boot operation, not an edit. self.settings_ops.note_seeded(); // An unfinished setup opens at its step. A recovered-ride decision already on top stays - // there, and setup opens on the next boot instead. + // there, and setup opens once it is answered. let decision_open = self.ui.stack.last().is_some_and(|top| top.caps().blocks_escape); if let Some(step) = screen::setup::screen(&self.settings).filter(|_| !decision_open) { screen::apply(&mut self.ui.stack, screen::Transition::Root(step)); @@ -6438,6 +6438,30 @@ mod tests { assert!(host.drain(&mut app).is_some(), "setup is saved as done"); } + /// The recovered ride is decided first, whichever the host offers first, and setup opens on + /// its saved step once the decision is answered. + #[test] + fn setup_opens_once_the_recovered_ride_is_answered() { + use crate::settings::SetupStep; + let saved = Settings { setup: SetupStep::Units, ..Settings::FACTORY }; + // The board offers the ride before it seeds the settings. + let mut board = App::new_idle(AppState::new(0, 0, 1.0)); + assert!(board.offer_recovered_ride(crate::RideContinuation::default())); + board.set_settings(saved); + assert!(matches!(board.top_screen(), Screen::RideRecovery(_))); + board.apply_gesture(Gesture::Press); + assert!(matches!(board.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupUnits(_)]), "Continue"); + + // The iOS host seeds the settings first. + let mut phone = App::new_idle(AppState::new(0, 0, 1.0)); + phone.set_settings(saved); + assert!(phone.offer_recovered_ride(crate::RideContinuation::default())); + assert!(matches!(phone.top_screen(), Screen::RideRecovery(_))); + phone.apply_gesture(Gesture::Step(1)); + phone.apply_gesture(Gesture::Hold); + assert!(matches!(phone.ui.stack.as_slice(), [Screen::Home(_), Screen::SetupUnits(_)]), "Discard"); + } + /// The quick drawer opens over setup for the power and the light, and holds no way out of it. /// The Assistant chord and the escape stay refused. #[test] diff --git a/firmware/obc-app/src/screen/ride_recovery.rs b/firmware/obc-app/src/screen/ride_recovery.rs index 204e15a84..19f74e5a4 100644 --- a/firmware/obc-app/src/screen/ride_recovery.rs +++ b/firmware/obc-app/src/screen/ride_recovery.rs @@ -8,7 +8,8 @@ //! attempt: Repair failed offers one more guarded Retry, and Card needs service offers none, //! because there is no safe object-level repair. Both terminal modes carry a labelled Back row, //! because the rider must see a way out. The global escape stays refused and Back stays inert, so -//! the card cannot be dismissed by accident. +//! the card cannot be dismissed by accident. An unfinished first-use setup opens on its saved step +//! once the card is answered. use core::fmt::Write; @@ -111,6 +112,11 @@ impl RecoveryMode { } } +/// Where an answer goes: to `to`, or to the saved setup step while setup is unfinished. +fn answered(to: Transition, s: &crate::Settings) -> Transition { + super::setup::screen(s).map_or(to, Transition::Root) +} + #[derive(Debug, Default)] pub struct RideRecoveryScreen { selected: usize, @@ -143,15 +149,15 @@ impl RideRecoveryScreen { cx.activity.mode = crate::activity::Mode::Riding; cx.navigator.suspend_for_recording_recovery(); cx.recorder.continue_recovered(); - Transition::Root(Screen::Map(MapScreen::new())) + answered(Transition::Root(Screen::Map(MapScreen::new())), cx.settings) } // The only way out of a terminal mode. The decision waits for the rider. - (Gesture::Press, Row::Leave) => Transition::Home, + (Gesture::Press, Row::Leave) => answered(Transition::Home, cx.settings), (Gesture::Hold, Row::Discard | Row::Retry) => { cx.recorder.request(RecorderIntent::Discard); cx.activity.mode = crate::activity::Mode::Idle; cx.navigator.suspend_for_recording_recovery(); - Transition::Home + answered(Transition::Home, cx.settings) } // Press on a guarded row is deliberately inert, and Back cannot bypass the decision. _ => Transition::None, From 99724c64cb89f9c1da49c74401694478fb6a5f62 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:21:17 +0000 Subject: [PATCH 32/37] docs: a factory reset also clears the bond The BLE spec and the board README named Forget phone as the only device-side clear of the bond slot. A factory reset now runs the same clear, so both name it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-fw-nrf54l/README.md | 2 +- specs/obc-ble-interface-spec.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/firmware/obc-fw-nrf54l/README.md b/firmware/obc-fw-nrf54l/README.md index 58db95543..6f1a3bbcf 100644 --- a/firmware/obc-fw-nrf54l/README.md +++ b/firmware/obc-fw-nrf54l/README.md @@ -192,7 +192,7 @@ The radio's contract is canonical in - **Config and the bond live in the RRAM SETTINGS carve** (`src/settings.rs`), which survives a power cycle and a reflash, because it sits above the app image. There is one bond slot; while it is occupied the device rejects every new pairing. The hold-guarded **Forget phone** in - Settings ▸ Connections is the only device-side clear. + Settings ▸ Connections and a factory reset are the only device-side clears. - **DIS Firmware Revision is the installed OBCU container's version**, falling back to the bare git short hash `build.rs` emits. A probe-flashed board reports a hash, not a version, so no host offers it an auto-update. diff --git a/specs/obc-ble-interface-spec.md b/specs/obc-ble-interface-spec.md index ff8f9988f..90780c092 100644 --- a/specs/obc-ble-interface-spec.md +++ b/specs/obc-ble-interface-spec.md @@ -415,8 +415,8 @@ Days count from 0 in the object; the rider sees Day 1 for day 0. device refuses every new pairing attempt** — from a stranger and from a peer claiming the bonded identity alike. A stored bond can only be cleared by the rider: the hold-guarded **Forget phone** action in Settings ▸ Connections zeroes the bond slot, removes the peer from the host's bond table - and resolving list, and drops the connection if that peer is connected. After Forget, the next - pairing is open again. Physical possession guards the *clear* step, so a stranger who can see the + and resolving list, and drops the connection if that peer is connected. A factory reset runs the + same clear. After the clear, the next pairing is open again. Physical possession guards the *clear* step, so a stranger who can see the screen cannot silently evict the rider's phone by pairing. - **Reject mechanics.** The pairing link is not bondable while a bond is stored, and the device refuses the attempt at its first SMP surface: it suppresses the passkey display and drops the @@ -424,7 +424,7 @@ Days count from 0 in the object; the rider sees Day 1 for day 0. SMP Pairing Request before the application sees it, and iOS does not surface an SMP reason code to the app. The app infers "already bonded elsewhere" from context, not from a code. A phone that forgets the device **while offline** is rejected like any other until the rider runs Forget phone - on the device; a forget **while connected** uses `forgetBond` (§4.4) and needs no on-device step. + or a factory reset on the device; a forget **while connected** uses `forgetBond` (§4.4) and needs no on-device step. - **Reconnect policy**: the device keeps a **stable static random address** and does **not** enable device-side privacy. The phone stores that identity and reconnects on any advertising contact. From 15c4f943c54cee2f82e86a4b9b7d11fe295f73c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:25:41 +0000 Subject: [PATCH 33/37] board: the settings load logs that it boots factory settings A blank or invalid slot and a failed read both boot the factory settings, which start setup, but the log still said "booting defaults". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- firmware/obc-fw-nrf54l/src/settings.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firmware/obc-fw-nrf54l/src/settings.rs b/firmware/obc-fw-nrf54l/src/settings.rs index 85008ce6f..770027374 100644 --- a/firmware/obc-fw-nrf54l/src/settings.rs +++ b/firmware/obc-fw-nrf54l/src/settings.rs @@ -394,12 +394,12 @@ impl SettingsStore for RramSettingsStore { if settings.is_some() { defmt::info!("settings: loaded {=usize} B from RRAM @ {=u32:#010x}", SLOT_LEN, off); } else { - defmt::info!("settings: RRAM slot @ {=u32:#010x} blank/invalid → booting defaults", off); + defmt::info!("settings: RRAM slot @ {=u32:#010x} blank/invalid → booting factory settings", off); } settings } Err(e) => { - defmt::warn!("settings: RRAM read failed: {} → booting defaults", e); + defmt::warn!("settings: RRAM read failed: {} → booting factory settings", e); None } } From ba2f5b057cac8f51fcbe72ee30f49a5726e82a2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:31:42 +0000 Subject: [PATCH 34/37] setup: step bytes follow the step order, one net resource note The persisted setup step bytes now follow the order the rider meets the steps. Settings v26 is not released, so no blob carries the old bytes. The resource baseline keeps one note, with the net change against develop from one head build: App +8 B, the BLE settings cache +4 B. The root lock keeps develop's resolution of iana-time-zone, and only adds the QR test dependency. The board comment on bondable links names the factory reset as a re-pair path too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WFeaY7cFELXdncsXZkwFeB --- Cargo.lock | 2 +- firmware/obc-app/src/settings.rs | 6 +++--- firmware/obc-fw-nrf54l/src/ble/mod.rs | 5 +++-- firmware/tools/resource_baseline.json | 14 ++++---------- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0d7d88ff..3e183ff45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2069,7 +2069,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] diff --git a/firmware/obc-app/src/settings.rs b/firmware/obc-app/src/settings.rs index 50d4da37f..972fc66dc 100644 --- a/firmware/obc-app/src/settings.rs +++ b/firmware/obc-app/src/settings.rs @@ -216,9 +216,9 @@ pub enum SetupStep { Buttons = 3, Units = 4, Theme = 5, - Sensors = 6, - Effort = 7, - Qr = 8, + Qr = 6, + Sensors = 7, + Effort = 8, AllSet = 9, } diff --git a/firmware/obc-fw-nrf54l/src/ble/mod.rs b/firmware/obc-fw-nrf54l/src/ble/mod.rs index a73101e32..b8dab1773 100644 --- a/firmware/obc-fw-nrf54l/src/ble/mod.rs +++ b/firmware/obc-fw-nrf54l/src/ble/mod.rs @@ -419,8 +419,9 @@ pub async fn run( // The link is bondable only while no bond is stored. With a bond present the control // plane rejects the pairing attempt outright, so a stranger can never mint a - // replacement bond; Forget phone is the only re-pair path. A bonded phone's silent - // reconnect is encryption resumption, not pairing, so neither knob touches it. + // replacement bond; Forget phone and a factory reset are the only re-pair paths. A + // bonded phone's silent reconnect is encryption resumption, not pairing, so neither + // knob touches it. let open_pairing = !state::status().paired; if let Err(e) = conn.raw().set_bondable(open_pairing) { warn!("ble: set_bondable failed: {:?}", defmt::Debug2Format(&e)); diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index fe148b749..1e1f8ed6a 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -88,17 +88,11 @@ "_deep_ride_margin_note": "A durable raw 8,704 B floor leaves 8,192 B effective after the 512 B below MSPLIM reserved for fault handling; it is deliberately not pinned to today's exact margin. The shipping image measured beside the run above has 46,328 B residual, so it clears the 31,340 B peak by 14,988 B raw / 14,476 B effective.", "_deep_ride_high_water_note": "Read off the board's own stackmeter on the nRF54LM20-DK over the VCOM harness, with the grimsel-demo map on the card. One walk reached the peak and a second boot reproduced it exactly. What the walk covered: a route opened from the Route menu and a ride started from it; map redraws under an injected 1 Hz fix; a Find a place search and its return; three planner runs through the debug N line, each released; Peak View generation, a peak article and its photo page, and the return to Peak View; the ride finished and saved; and, across a warm reset taken while recording, the section 7.3 ride recovery, its Continue and a second finish. The peak lands on ride entry; nothing after it goes deeper. The image is the debug-uart one, because the host has to inject the buttons and the fixes, and that image compiles the ride loop differently: it binds the debug sensor sources where the shipping image builds the hub drains as call temporaries, and it never spawns sensor_task. The gated image is therefore not the measured one, so the static frames were compared before this row moved down. On the same two ELFs, resource_guard.py frames --match run_app gives 4,640 B shipping against 4,656 B debug-uart, --match ride gives the same 16,184 B largest frame in both, the largest guarded poll frame is 9,776 B in both, and the largest task body is 4,816 B shipping against 4,808 B. The measured compilation of the ride loop is therefore 16 B DEEPER than the shipping one, and the whole shipping-only sensor path fits in frames of at most 200 B inside its own task. Not covered, and therefore not claimed: the real-sensor paths, because the measured image runs none of them - the GPS, altimeter and compass drains and sensor_task itself; a plan that finds a path (this map answers no-path); an off-route rejoin; an archive receipt; and BLE or USB traffic during the ride.", "_boot_chain_roots_note": "The mount_in_place root is unscoped because toolchains demangle FlatStore differently. The symbol resolver rejects missing or ambiguous roots; a second matching constructor must be resolved with an explicit, portable needle.", - "_setup_step_allocation_measurement": { - "source_commit": "first-use setup on claude/zu-skills-install-x0qn6t 8ae047ec", - "build": "cargo build --release --locked --features resource-report on x86_64-unknown-linux-gnu, rustc 1.98.1 (ble_object_store 132 B)", - "changes": "BLE settings cache +4 B (Settings +2 B for the persisted setup step, padded), also counted in ble_total. All other named allocations and ceilings are unchanged.", - "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,964,500 B of the 2,019,328 B slot limit." - }, - "_pairing_code_allocation_measurement": { - "source_commit": "fixed app link on claude/zu-skills-install-x0qn6t fc008482", + "_first_use_setup_allocation_measurement": { + "source_commit": "claude/zu-skills-install-x0qn6t on develop 532ef127f", "build": "cargo build --release --locked --features resource-report on x86_64-unknown-linux-gnu, rustc 1.98.1 (App 58,448 B)", - "changes": "App 58,448 B, unchanged: App keeps the 12 B factory name that the host declares, AppState drops the 8 B factory serial, and alignment padding takes the difference. The pairing code is a 116 B constant module table; no QR encoder links into the image. All other named allocations and ceilings are unchanged.", - "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,952,188 B of the 2,019,328 B slot limit, which is 12,312 B below the 1,964,500 B of the setup-step build." + "changes": "Against develop: App +8 B over 58,440 B, because App holds the factory name that the host declares. BLE settings cache +4 B over 128 B (Settings +2 B for the persisted setup step, padded), also counted in ble_total. The pairing code is a constant module table; no QR encoder links into the image. All other named allocations and ceilings are unchanged.", + "verification": "One head build compared with the recorded baseline; no base rebuild. On the shipping link of the same head the board guards passed: 315,744 B linked resident inside the band, residual main stack 43,680 B, poll frame 9,864 B, init_idle frame 1,856 B, image 1,956,188 B of the 2,019,328 B slot limit." }, "_ride_zone_limits_allocation_measurement": { "source_commit": "claude/ride-zone-limits on develop 0a0b15804", From 44903a6097051319b6d04b6d451c922138ec6f04 Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:16:36 +0200 Subject: [PATCH 35/37] fix: complete phone removal before factory setup --- apps/obc-sim/src/main.rs | 22 +++++- firmware/obc-app/i18n/de.toml | 4 ++ firmware/obc-app/i18n/en.toml | 4 ++ firmware/obc-app/i18n/es.toml | 4 ++ firmware/obc-app/i18n/fr.toml | 4 ++ firmware/obc-app/src/app.rs | 37 ++++++++-- firmware/obc-app/src/card_scheduler.rs | 4 +- firmware/obc-app/src/device_core/pass.rs | 53 ++++++++++++++ firmware/obc-app/src/harness/copy_fit.rs | 31 ++++++-- firmware/obc-app/src/screen/mod.rs | 3 +- firmware/obc-app/src/screen/pair_code.rs | 9 ++- firmware/obc-app/src/screen/quick_drawer.rs | 5 ++ firmware/obc-app/src/screen/settings/reset.rs | 71 ++++++++++++++++--- firmware/obc-fw-nrf54l/src/ble/mod.rs | 14 ++-- firmware/obc-fw-nrf54l/src/link_control.rs | 16 +++-- firmware/obc-fw-nrf54l/src/ride.rs | 3 - firmware/obc-fw-nrf54l/src/settings.rs | 2 + firmware/ui-frames.toml | 21 ++++++ firmware/ui-snapshots.sha256 | 3 + 19 files changed, 269 insertions(+), 41 deletions(-) diff --git a/apps/obc-sim/src/main.rs b/apps/obc-sim/src/main.rs index 1a4628352..fd2403cb3 100644 --- a/apps/obc-sim/src/main.rs +++ b/apps/obc-sim/src/main.rs @@ -75,6 +75,7 @@ enum Injection { TripUpload { id: obc_app::CatalogObjectId }, MapTransfer(obc_app::screen::MapTransfer), Warning(obc_app::Alerts), + BondClear(Result), } #[derive(Clone)] @@ -431,7 +432,7 @@ fn parse_ble(s: &str) -> Result { /// it. const INJECT_FORMS: &str = "--inject needs nav-fail=KIND|detour-fail=KIND|upload=ID|upload-replace=ID|\ trip-upload=N|map-transfer=receiving:RECEIVED/TOTAL|map-transfer=installed|map-transfer=failed:KIND|\ - warning=LIST"; + warning=LIST|bond-clear=confirmed|restart|failed"; /// The `--inject map-transfer` forms, stated once. const MAP_TRANSFER_FORMS: &str = @@ -487,6 +488,12 @@ fn parse_injection(s: &str) -> Result { } "map-transfer" => Ok(Injection::MapTransfer(parse_map_transfer(value)?)), "warning" => Ok(Injection::Warning(parse_warning(value)?)), + "bond-clear" => Ok(Injection::BondClear(match value { + "confirmed" => Ok(obc_app::ble::ControllerClearance::Confirmed), + "restart" => Ok(obc_app::ble::ControllerClearance::Unconfirmed), + "failed" => Err(obc_app::ble::BondError::StoreWriteFailed), + _ => return Err("--inject bond-clear needs confirmed|restart|failed".into()), + })), _ => Err(INJECT_FORMS.into()), } } @@ -720,6 +727,7 @@ struct Stores<'a> { /// settle instead of parking. #[derive(Default)] struct HeadlessPlatform { + bond_clear: Option>, /// The `--dfu` scan answer, taken by the first scan the flow asks for. scan: Option>, /// The `--dfu` install answer. `None` leaves the arm in flight, which is the progress @@ -728,6 +736,10 @@ struct HeadlessPlatform { } impl HostPlatform for HeadlessPlatform { + fn forget_bond(&mut self) -> Result { + self.bond_clear.unwrap_or(Err(obc_app::ble::BondError::Unsupported)) + } + fn measure_free_space(&mut self) -> Result { Ok(SIM_CARD_FREE) } @@ -1408,6 +1420,9 @@ fn main() { // operation and park the flow. `Progress` stages no install answer, because that unanswered // arm is the spinner. let mut platform = HeadlessPlatform::default(); + if let Some(Injection::BondClear(result)) = args.inject { + platform.bond_clear = Some(result); + } if let Some(dfu) = &args.dfu { platform.scan = match dfu { DfuSeed::Scan(kind) | DfuSeed::Progress(kind) | DfuSeed::Installing(kind) => Some(kind.report()), @@ -1924,6 +1939,11 @@ mod cli_tests { let linked_bond = parse(&["--ble", "connected+paired"]).unwrap().ble.unwrap(); assert_eq!(linked_bond.link, obc_app::BleLink::Connected); assert!(linked_bond.paired); + assert!(matches!( + parse(&["--inject", "bond-clear=restart"]).unwrap().inject, + Some(Injection::BondClear(Ok(obc_app::ble::ControllerClearance::Unconfirmed))) + )); + assert!(parse(&["--inject", "bond-clear=unknown"]).is_err()); assert!(matches!( parse(&["--inject", "upload-replace=7"]).unwrap().inject, Some(Injection::Upload { id: 7, replaced: true }) diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index ec86e187f..d0445bb42 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -332,6 +332,9 @@ all_added_sub = "Erst eins entfernen" # --- Factory-reset settings screen --- [reset] +retry = "Wiederholen" +power_cycle = "Ausschalten und den OBC wieder einschalten." +phone_failed = "Telefon nicht entfernt" title = "ZURÜCKSETZEN" factory = "Werksreset" erases = "Löscht alle Werte" @@ -385,6 +388,7 @@ on_phone = "am Handy" # --- The pairing code: the QR code of the pairing link --- [pair] +enable_bluetooth = "Bluetooth einschalten" scan = "Mit dem Handy scannen" unplug = "Zum Koppeln abstecken" diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index 8c4652e3e..a59f8ece2 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -367,6 +367,9 @@ saved_time = "& saved time." confirm = "Confirm" keep_holding = "Keep holding" hold_to_erase = "Hold to erase" +retry = "Retry" +power_cycle = "Switch off, then turn the OBC on again." +phone_failed = "Phone not removed" # --- The six POI categories (obcm §7.4): the browser's rows, the POI list's title, and the # "Up ahead" category picker all draw these --- @@ -415,6 +418,7 @@ on_phone = "on your phone" [pair] scan = "Scan with your phone" unplug = "Unplug cable to pair" +enable_bluetooth = "Turn Bluetooth on" # --- Rides list screen --- [rides] diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index b39261b20..847024b52 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -331,6 +331,9 @@ all_added_sub = "Quita uno antes" # --- Factory-reset settings screen --- [reset] +retry = "Reintentar" +power_cycle = "Apaga y vuelve a encender el OBC." +phone_failed = "Teléfono no eliminado" title = "RESTABLECER" factory = "Restablecer" erases = "Borra los ajustes" @@ -384,6 +387,7 @@ on_phone = "en el móvil" # --- The pairing code: the QR code of the pairing link --- [pair] +enable_bluetooth = "Activa Bluetooth" scan = "Escanea con el móvil" unplug = "Desconecta el cable" diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index 0992e05e2..a2744166e 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -331,6 +331,9 @@ all_added_sub = "Retirez-en un" # --- Factory-reset settings screen --- [reset] +retry = "Réessayer" +power_cycle = "Éteignez puis rallumez l'OBC." +phone_failed = "Téléphone non supprimé" title = "RÉINITIALISER" factory = "Réinit. usine" erases = "Efface les réglages" @@ -384,6 +387,7 @@ on_phone = "sur le tél." # --- The pairing code: the QR code of the pairing link --- [pair] +enable_bluetooth = "Activez le Bluetooth" scan = "Scannez avec le tél." unplug = "Débranchez le câble" diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 9d30813d0..4030f540e 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1905,6 +1905,29 @@ impl App { self.ui.map_dirty = true; } + /// Commit a confirmed factory reset only after the bond store acknowledges removal. The + /// board's unconfirmed controller clearance keeps the restart instruction on screen. + pub(crate) fn finish_factory_reset(&mut self) { + use crate::ble::BondStatus; + if !matches!(self.state.bond_status, BondStatus::Removed | BondStatus::RestartRequired) + || !self.factory_reset_pending() + { + return; + } + self.settings = Settings::FACTORY; + self.wall_clock.set(self.settings.local_clock(), self.ui.now_ms); + self.sync_find_preferences(); + self.settings_ops.note_edited(); + if self.state.bond_status == BondStatus::Removed { + screen::apply(&mut self.ui.stack, screen::setup::go_to(&self.settings)); + self.ui.cancel_holds(); + } + } + + fn factory_reset_pending(&self) -> bool { + self.ui.stack.iter().any(|s| matches!(s, Screen::Reset(r) if r.removing())) + } + /// Whether the passkey card is currently up. A route-upload popup is dropped, not queued, /// while the card shows. pub fn passkey_card_up(&self) -> bool { @@ -1916,7 +1939,7 @@ impl App { /// card lands in the same frame unless a policy rule defers it. fn sweep_cards(&mut self) { let arrival = self.arrival_view(); - let in_setup = self.settings.in_setup(); + let in_setup = self.settings.in_setup() || self.factory_reset_pending(); self.ui.run_card_sweep(&self.catalogs, self.recorder.recording(), self.state.pan.is_some(), arrival, in_setup); if self.ui.stack.iter().any(|s| matches!(s, Screen::Journey(_))) || self.ui.find.resume_offer { self.ui.find.review = self.assistant_review_status(); @@ -2184,6 +2207,9 @@ impl App { /// live here only: a modal that declares [`Caps::blocks_chords`](crate::screen::Caps) stops /// every chord, and one drawer is open at a time, so the same chord again closes it. pub fn apply_chord(&mut self, chord: Chord) -> bool { + if self.factory_reset_pending() { + return false; + } if self.ui.stack.last().is_some_and(|s| s.caps().blocks_chords) { return false; } @@ -2658,7 +2684,7 @@ impl App { // Asked of the base, not of `stack.last()`: a sheet opened over a card the rider must // answer is not consent to walk away from the card. let base = screen::base_screen(&self.ui.stack); - if base.is_some_and(|s| s.caps().blocks_escape) || self.power_off_requested() { + if base.is_some_and(|s| s.caps().blocks_escape) || self.power_off_requested() || self.factory_reset_pending() { return false; } // No explicit sheet-popping: a rewind truncates to the Menu, which is under every @@ -4596,10 +4622,9 @@ mod tests { /// cannot loop forever. const MAX_DEPTH_BACKOUT: usize = crate::screen::MAX_DEPTH; - /// A factory reset saves the cleared settings at once and opens setup, so a power loss after - /// the hold boots into setup, as the forgotten phone expects. + /// An unpaired device needs no bond receipt: reset saves at once and opens setup. #[test] - fn a_factory_reset_saves_at_once_and_opens_setup() { + fn an_unpaired_factory_reset_saves_at_once_and_opens_setup() { use crate::screen::{ResetScreen, SettingsPage}; let mut app = App::new_idle(AppState::new(0, 0, 1.0)); app.set_settings(Settings::default()); @@ -4609,7 +4634,7 @@ mod tests { app.apply_gesture(Gesture::Hold); assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::Hello(_)])); assert_eq!(*app.settings(), Settings::FACTORY); - assert!(app.state.ble_forget_requested, "the paired phone is forgotten"); + assert!(!app.state.ble_forget_requested, "there is no phone to forget"); assert!(settings_dirty(&mut app), "the reset is saved at once"); } diff --git a/firmware/obc-app/src/card_scheduler.rs b/firmware/obc-app/src/card_scheduler.rs index c6bb8361e..1ce38c53c 100644 --- a/firmware/obc-app/src/card_scheduler.rs +++ b/firmware/obc-app/src/card_scheduler.rs @@ -241,8 +241,8 @@ pub(crate) struct CardCtx<'a> { /// The arrival level: `Some` from arrival at the route end until the rider rides on, finishes, /// pauses or loads another route. pub(crate) arrival: Option, - /// First-use setup is running. Its pages are the only way out of it, so the upload prompt, - /// which opens routes and rides, waits for setup to end. + /// First-use setup or a confirmed factory reset is running. The upload prompt, which opens + /// routes and rides, waits for it to end. pub(crate) in_setup: bool, } diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index 464b87cf4..cf3368ba7 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -343,6 +343,7 @@ impl App { if let Some(outcome) = outcomes.bond.take() { if self.bond.apply_outcome(outcome) { self.state.bond_status = self.bond.status(); + self.finish_factory_reset(); self.ui.map_dirty = true; } } @@ -777,6 +778,58 @@ mod tests { pass_with(app, ms, &[], &mut OutcomeSlots::new(), &mut facts) } + #[test] + fn factory_reset_waits_for_bond_removal_and_exposes_retry_or_restart() { + use crate::ble::{BleStatus, BondError, BondOutcome, BondStatus, ControllerClearance}; + use crate::input::Chord; + use crate::screen::ResetScreen; + use crate::settings::{IdleReturn, Settings, Units}; + + for controller in [ControllerClearance::Confirmed, ControllerClearance::Unconfirmed] { + let mut app = App::new_idle(AppState::new(0, 0, 1.0)); + let original = Settings { units: Units::Imperial, idle_return: IdleReturn::S15, ..Settings::default() }; + app.set_settings(original); + app.set_ble_status(BleStatus { paired: true, ..BleStatus::DISCONNECTED }); + assert!(app.ui.stack.push(Screen::Reset(ResetScreen::new())).is_ok()); + let mut outcomes = OutcomeSlots::new(); + let mut facts = ExternalFacts::NONE; + let mut plan = pass_with(&mut app, 10, &[Gesture::Press, Gesture::Hold], &mut outcomes, &mut facts); + let removal = plan.effects.bond.take().expect("the confirmed reset removes the phone first"); + assert!(plan.effects.settings.is_empty()); + assert_eq!(*app.settings(), original, "a reboot before the receipt does not enter setup"); + assert!(!app.apply_chord(Chord::Assistant)); + assert!(!app.apply_chord(Chord::Quick)); + app.apply_gesture(Gesture::BackHold); + quiet(&mut app, 60_000); + assert!(matches!(app.top_screen(), Screen::Reset(_)), "the pending operation stays visible"); + + outcomes + .bond + .try_put(BondOutcome::Failed { token: removal.token(), error: BondError::StoreWriteFailed }) + .unwrap(); + let plan = pass_with(&mut app, 60_010, &[], &mut outcomes, &mut facts); + assert_eq!(*app.settings(), original, "failed removal preserves the settings"); + assert!(matches!(app.state.bond_status, BondStatus::Failed(_))); + assert!(plan.effects.settings.is_empty()); + + let mut plan = pass_with(&mut app, 60_020, &[Gesture::Press], &mut outcomes, &mut facts); + let retry = plan.effects.bond.take().expect("Select retries the failed removal"); + outcomes.bond.try_put(BondOutcome::KeysRemoved { token: retry.token(), controller }).unwrap(); + app.set_ble_status(BleStatus::DISCONNECTED); + let mut plan = pass_with(&mut app, 60_030, &[], &mut outcomes, &mut facts); + assert_eq!(*app.settings(), Settings::FACTORY); + assert!(plan.effects.settings.take().is_some(), "the successful reset saves at once"); + match controller { + ControllerClearance::Confirmed => assert!(matches!(app.top_screen(), Screen::Hello(_))), + ControllerClearance::Unconfirmed => { + assert!(matches!(app.top_screen(), Screen::Reset(_)), "restart is required before setup"); + pass_with(&mut app, 60_040, &[Gesture::Press], &mut outcomes, &mut facts); + assert!(matches!(app.top_screen(), Screen::QuickDrawer(d) if d.selection_is_guarded())); + } + } + } + } + fn pass_with( app: &mut App, ms: u32, diff --git a/firmware/obc-app/src/harness/copy_fit.rs b/firmware/obc-app/src/harness/copy_fit.rs index 7e35de32f..70bea4f28 100644 --- a/firmware/obc-app/src/harness/copy_fit.rs +++ b/firmware/obc-app/src/harness/copy_fit.rs @@ -346,12 +346,33 @@ fn every_string_fits_the_panel_in_every_language() { offenders.extend(complaint(name, language, &drawn)); } } - // The pairing code's line while the USB interlock parks the radio. + // The pairing code explains the USB interlock and the rider's Bluetooth switch. let off = crate::BleStatus { link: crate::BleLink::Off, ..crate::BleStatus::DISCONNECTED }; - for (name, drawn) in walk(plain(vec![Screen::PairPhone(PairPhoneScreen)]).remove(0), language, &bytes, |app| { - app.set_ble_status(off) - }) { - offenders.extend(complaint(name, language, &drawn)); + for enabled in [true, false] { + for (name, drawn) in + walk(plain(vec![Screen::PairPhone(PairPhoneScreen)]).remove(0), language, &bytes, |app| { + app.set_settings(Settings { ble_enabled: enabled, ..*app.settings() }); + app.set_ble_status(off); + }) + { + offenders.extend(complaint(name, language, &drawn)); + } + } + for status in [ + crate::ble::BondStatus::Pending, + crate::ble::BondStatus::Failed(crate::ble::BondError::StoreWriteFailed), + crate::ble::BondStatus::RestartRequired, + ] { + for (name, drawn) in + walk(plain(vec![Screen::Reset(ResetScreen::new())]).remove(0), language, &bytes, |app| { + app.state.device.ble_paired = true; + app.apply_gesture(Gesture::Press); + app.apply_gesture(Gesture::Hold); + app.state.bond_status = status; + }) + { + offenders.extend(complaint(name, language, &drawn)); + } } // The All set page with a phone paired, sensors saved and the zones set. One sensor reads // shorter than two in every catalog. diff --git a/firmware/obc-app/src/screen/mod.rs b/firmware/obc-app/src/screen/mod.rs index 8eff7fdfe..74032e7e2 100644 --- a/firmware/obc-app/src/screen/mod.rs +++ b/firmware/obc-app/src/screen/mod.rs @@ -1036,7 +1036,8 @@ screens! { /// The Language pick list. Language(LanguageScreen) => Caps::settings(), About(AboutScreen) => Caps::settings(), - Reset(ResetScreen) => Caps::settings(), + /// The guarded reset and its bond-removal result. A successful reset saves immediately. + Reset(ResetScreen) => Caps::modal().blocks_escape(), /// The "Checking update..." wait while the board validates the staged package. The answer /// replaces it with the confirm screen or an error card. DfuCheck(DfuCheckScreen) => Caps::modal(), diff --git a/firmware/obc-app/src/screen/pair_code.rs b/firmware/obc-app/src/screen/pair_code.rs index 5aad2e029..50ac9264e 100644 --- a/firmware/obc-app/src/screen/pair_code.rs +++ b/firmware/obc-app/src/screen/pair_code.rs @@ -95,8 +95,13 @@ pub(crate) fn code_page(cv: &mut impl Surface, rx: &Render) { let name_at = Point::new(rx.w / 2, top + CODE_PX + 2); cv.text(&fit(name, copy_w(rx.w), Font::Label), name_at, Font::Label, TextAlign::Center, palette::INK); let caption = name_at + Point::new(0, Font::Label.line_height() as i32); - let cable = rx.settings.ble_enabled && rx.state.device.ble_link == BleLink::Off; - let line = if cable { Msg::PairUnplug } else { Msg::PairScan }; + let line = if !rx.settings.ble_enabled { + Msg::PairEnableBluetooth + } else if rx.state.device.ble_link == BleLink::Off { + Msg::PairUnplug + } else { + Msg::PairScan + }; cv.text(rx.t(line), caption, Font::Caption, TextAlign::Center, palette::INK); } diff --git a/firmware/obc-app/src/screen/quick_drawer.rs b/firmware/obc-app/src/screen/quick_drawer.rs index 36fbb28eb..61eb80647 100644 --- a/firmware/obc-app/src/screen/quick_drawer.rs +++ b/firmware/obc-app/src/screen/quick_drawer.rs @@ -130,6 +130,11 @@ impl QuickDrawerScreen { } } + /// The reset result's direct path to the existing guarded power-off control. + pub(crate) fn power_confirmation() -> Self { + Self { page: Page::PowerConfirm, slide_from: Page::PowerConfirm, ..Self::opening() } + } + /// The brightness the panel should show now: the editor's staged preview while it is open, and /// nothing, which means the committed row, everywhere else. pub(crate) fn staged_brightness(&self) -> Option { diff --git a/firmware/obc-app/src/screen/settings/reset.rs b/firmware/obc-app/src/screen/settings/reset.rs index 1f4522f35..94b8e210a 100644 --- a/firmware/obc-app/src/screen/settings/reset.rs +++ b/firmware/obc-app/src/screen/settings/reset.rs @@ -1,7 +1,8 @@ //! The Factory Reset screen. The long-press threshold is about 500 ms, which is too short to feel //! safe alone, so a reset takes two steps: a press to arm, then a hold to erase. A hold on an -//! un-armed screen does nothing. The reset clears the settings and forgets the paired phone, and -//! opens first-use setup, which can pair a phone again. It keeps the files on the card. +//! un-armed screen does nothing. A paired device waits for bond removal before it clears the +//! settings. Unconfirmed controller clearance asks for a restart before first-use setup. The +//! reset keeps the files on the card. use embedded_graphics::prelude::Point; use obc_render::{ @@ -10,9 +11,10 @@ use obc_render::{ Surface, }; +use crate::ble::BondStatus; use crate::input::Gesture; use crate::screen::vocab::chrome::{card_triangle, copy_w, title_frame, wrapped, TITLE_BAR_H}; -use crate::screen::{palette, Ctx, Render, Transition}; +use crate::screen::{palette, Ctx, QuickDrawerScreen, Render, Screen, Transition}; use crate::settings::Settings; use crate::Msg; @@ -20,30 +22,61 @@ use crate::Msg; #[derive(Debug, Default)] pub struct ResetScreen { armed: bool, + removing: bool, } impl ResetScreen { pub fn new() -> Self { - ResetScreen { armed: false } + ResetScreen { armed: false, removing: false } } /// True while the hold-to-erase bar is on screen and fills with the live hold progress. pub(crate) fn hold_fill_active(&self) -> bool { - self.armed + self.armed && !self.removing + } + + pub(crate) fn removing(&self) -> bool { + self.removing } pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { + if self.removing { + return match (g, cx.state.bond_status) { + (Gesture::Press, BondStatus::Failed(_)) => { + cx.state.ble_forget_requested = true; + Transition::None + } + (Gesture::Back, BondStatus::Failed(_)) => Transition::Pop, + (Gesture::Press, BondStatus::RestartRequired) => { + Transition::Push(Screen::QuickDrawer(QuickDrawerScreen::power_confirmation())) + } + _ => Transition::None, + }; + } match g { Gesture::Press if !self.armed => { self.armed = true; Transition::None } - // Setup leaves the settings subtree, so the cleared settings are saved on this pass, - // beside the forget. The forget takes the Forget phone path, and does nothing while no - // phone is paired. - Gesture::Hold if self.armed => { + Gesture::Hold if self.armed && cx.state.bond_status == BondStatus::RestartRequired => { + self.removing = true; *cx.settings = Settings::FACTORY; + Transition::None + } + // Keep the settings until bond removal succeeds. A failed clear must not boot setup + // with the old phone still paired. The App commits the reset on its removal receipt. + Gesture::Hold + if self.armed + && cx.state.bonding + && (cx.state.device.ble_paired + || matches!(cx.state.bond_status, BondStatus::Pending | BondStatus::Failed(_))) => + { + self.removing = true; cx.state.ble_forget_requested = true; + Transition::None + } + Gesture::Hold if self.armed => { + *cx.settings = Settings::FACTORY; crate::screen::setup::go_to(cx.settings) } Gesture::Back => Transition::Pop, @@ -57,6 +90,22 @@ impl ResetScreen { title_frame(cv, w, h, rx.t(Msg::ResetTitle), ""); card_triangle(cv, Point::new(w / 2, TITLE_BAR_H + 50), 24); + if self.removing { + let (message, action) = match rx.state.bond_status { + BondStatus::Failed(_) => (Msg::ResetPhoneFailed, Some(Msg::ResetRetry)), + BondStatus::RestartRequired => (Msg::BluetoothRestart, Some(Msg::QuickPower)), + _ => (Msg::BluetoothRemoving, None), + }; + let y = wrapped(cv, rx.t(message), w / 2, TITLE_BAR_H + 96, copy_w(w), Font::Body, INK); + if rx.state.bond_status == BondStatus::RestartRequired { + wrapped(cv, rx.t(Msg::ResetPowerCycle), w / 2, y + 12, copy_w(w), Font::Label, SUBTEXT); + } + if let Some(action) = action { + let area = super::super::vocab::rows::row_rect(h - 58, w, 42); + super::super::vocab::rows::action_row(cv, area, rx.t(action), None, true, true, false, 0.0); + } + return; + } wrapped(cv, rx.t(Msg::ResetFactory), w / 2, TITLE_BAR_H + 90, copy_w(w), Font::Body, WARNING); if !self.armed { @@ -104,7 +153,7 @@ mod tests { } #[test] - fn arm_then_hold_resets_to_factory_forgets_the_phone_and_starts_setup() { + fn arm_then_hold_resets_an_unpaired_device_and_starts_setup() { let mut s = Settings { units: Units::Imperial, power_saver: true, fix_interval_s: 30, ..Settings::default() }; let before = s; let mut st = AppState::new(0, 0, 1.0); @@ -119,7 +168,7 @@ mod tests { let t = run_in(&mut scr, &mut s, &mut st, Gesture::Hold); assert!(matches!(t, Transition::Root(crate::Screen::Hello(_))), "setup opens at once"); assert_eq!(s, Settings::FACTORY, "settings were cleared to factory defaults"); - assert!(st.ble_forget_requested, "the paired phone is forgotten"); + assert!(!st.ble_forget_requested, "there is no phone to forget"); } #[test] diff --git a/firmware/obc-fw-nrf54l/src/ble/mod.rs b/firmware/obc-fw-nrf54l/src/ble/mod.rs index b8dab1773..25b2e79aa 100644 --- a/firmware/obc-fw-nrf54l/src/ble/mod.rs +++ b/firmware/obc-fw-nrf54l/src/ble/mod.rs @@ -35,7 +35,7 @@ use core::mem::MaybeUninit; use defmt::{info, unwrap, warn}; use embassy_executor::Spawner; use embassy_futures::join::{join, join4}; -use embassy_futures::select::{select, select3, Either, Either3}; +use embassy_futures::select::{select, select4, Either, Either4}; use embassy_nrf::mode::Blocking; use embassy_nrf::{cracen, peripherals, Peri}; use embassy_time::Timer; @@ -387,26 +387,28 @@ pub async fn run( } let adv_name = advertised_name(&store.borrow()); - let conn = match select3( + let conn = match select4( advertise_lifecycle(adv_name.as_str(), &mut peripheral, server), state::radio_disabled(), FORGET_BOND.wait(), + crate::link_control::wait_advertised_name_change(), ) .await { - Either3::First(Ok(conn)) => conn, - Either3::First(Err(e)) => { + Either4::First(Ok(conn)) => conn, + Either4::First(Err(e)) => { // An advertise error must not take the firmware down and must not wedge the // loop. warn!("ble: advertise error: {:?} — retrying in 1 s", defmt::Debug2Format(&e)); Timer::after_secs(1).await; continue; } - Either3::Second(()) => continue, // radio off — park at the loop top - Either3::Third(()) => { + Either4::Second(()) => continue, // radio off — park at the loop top + Either4::Third(()) => { forget_bond(stack, store, shared).await; continue; } + Either4::Fourth(()) => continue, }; let peer = conn.raw().peer_address(); let mut peer_bytes = [0u8; 6]; diff --git a/firmware/obc-fw-nrf54l/src/link_control.rs b/firmware/obc-fw-nrf54l/src/link_control.rs index 92186e404..b8a05f731 100644 --- a/firmware/obc-fw-nrf54l/src/link_control.rs +++ b/firmware/obc-fw-nrf54l/src/link_control.rs @@ -70,10 +70,11 @@ pub(crate) fn take_dfu_install_ble() -> bool { /// change-detection save, so the phone's write reaches the UI same-session and is never clobbered. static BLE_CONFIG_WRITTEN: AtomicBool = AtomicBool::new(false); -/// Raised by the ride loop after it persists an on-device settings change; the BLE plane drains it +/// Raised by the RRAM store after a settings write; the BLE plane drains it /// and refreshes the [`LinkControl`] config cache from RRAM before serving a Config read (or the /// advertised name), so a read after an on-device units change is fresh without a reboot. static DEVICE_SETTINGS_CHANGED: AtomicBool = AtomicBool::new(false); +static ADVERTISED_NAME_CHANGED: Signal = Signal::new(); /// The ride loop's cue to reload BLE-written settings before its next save (see /// [`BLE_CONFIG_WRITTEN`]). `true` at most once per BLE Config write; drains on read. @@ -81,10 +82,17 @@ pub(crate) fn take_ble_config_written() -> bool { BLE_CONFIG_WRITTEN.swap(false, Ordering::Relaxed) } -/// The ride loop signals that it persisted an on-device settings edit, so the BLE plane's config -/// cache is now stale (see [`DEVICE_SETTINGS_CHANGED`]). Cheap: one relaxed store per settings save. -pub(crate) fn mark_device_settings_changed() { +/// A successful settings write invalidates the config cache and a changed name wakes advertising. +pub(crate) fn mark_device_settings_changed(renamed: bool) { DEVICE_SETTINGS_CHANGED.store(true, Ordering::Relaxed); + if renamed { + ADVERTISED_NAME_CHANGED.signal(()); + } +} + +/// A persisted rename restarts active advertising with the new name. +pub(crate) async fn wait_advertised_name_change() { + ADVERTISED_NAME_CHANGED.wait().await; } // A `setClock` command must not touch `App` from the BLE plane. Like the other crossings, the diff --git a/firmware/obc-fw-nrf54l/src/ride.rs b/firmware/obc-fw-nrf54l/src/ride.rs index c82084624..5d06b374c 100644 --- a/firmware/obc-fw-nrf54l/src/ride.rs +++ b/firmware/obc-fw-nrf54l/src/ride.rs @@ -2186,9 +2186,6 @@ pub(crate) async fn run_app( let outcome = match effect { SettingsEffect::PersistRevision { token, revision } => match settings_store.save(app.settings()) { Ok(()) => { - // The RRAM blob just moved, so the BLE config-read cache is stale. Flag - // it, so the BLE plane refreshes before its next read. - crate::link_control::mark_device_settings_changed(); // Push a changed GPS fix interval to the sensor task → it re-VALSETs the M10's rate. #[cfg(all(not(feature = "debug-uart"), not(feature = "synth")))] if app.settings().fix_interval_s != prev_interval { diff --git a/firmware/obc-fw-nrf54l/src/settings.rs b/firmware/obc-fw-nrf54l/src/settings.rs index 770027374..099580999 100644 --- a/firmware/obc-fw-nrf54l/src/settings.rs +++ b/firmware/obc-fw-nrf54l/src/settings.rs @@ -406,12 +406,14 @@ impl SettingsStore for RramSettingsStore { } fn save(&mut self, s: &Settings) -> Result<(), obc_ports::SettingsSaveError> { + let renamed = self.load().unwrap_or(Settings::FACTORY).device_name != s.device_name; let off = region_offset(); let bytes: [u8; SLOT_LEN] = obc_app::settings::encode(s); // No erase: RRAM overwrites in place. One aligned 16-byte line, so this is a single write. match self.rram.write(off, &bytes) { Ok(()) => { defmt::info!("settings: wrote {=usize} B to RRAM @ {=u32:#010x}", SLOT_LEN, off); + crate::link_control::mark_device_settings_changed(renamed); Ok(()) } Err(e) => { diff --git a/firmware/ui-frames.toml b/firmware/ui-frames.toml index 991c06c0e..2f76221e3 100644 --- a/firmware/ui-frames.toml +++ b/firmware/ui-frames.toml @@ -1940,6 +1940,13 @@ script = "p p u d b p p p p" expect = "SetupQr" args = ["--fresh", "--ble", "off"] +[[frame]] +name = "setup-qr-bluetooth-off" +map = "{grimsel}" +script = "p p u d b p p p p Q d p Q" +expect = "SetupQr" +args = ["--fresh"] + # Back on the pairing step asks whether to ride without the app. Skip passes the step, which the # later setup frames do. [[frame]] @@ -2473,6 +2480,20 @@ script = "B u p d*5 p d*5 p p" expect = "Reset" langs = ["de", "fr", "es"] +[[frame]] +name = "reset-remove-failed" +map = "{grimsel}" +script = "B u p d*5 p d*5 p p h" +expect = "Reset" +args = ["--ble", "paired", "--inject", "bond-clear=failed"] + +[[frame]] +name = "reset-restart" +map = "{grimsel}" +script = "B u p d*5 p d*5 p p h" +expect = "Reset" +args = ["--ble", "paired", "--inject", "bond-clear=restart"] + [[frame]] name = "dfu-confirm" map = "{grimsel}" diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index 75462fa85..e47f11524 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -234,6 +234,8 @@ ffb316c1bf0f8c11f533342b9b451ff40db1a4a4ccb76b6cc8de84cb017550b9 reset-confirm- 72a4212e578aba76210f7a02c8ae2ab7aa926700e574b44bf30b70e8527719ed reset-es.png fca2f95f0ac16aa6b95c261e522ef225f8deabbb21de39df77158ce14264395b reset-fr.png e63876548eb1e4730ae573c2ac7938f87cef245a99e29d15e16ee70e18d86650 reset-hold.png +21f127709fc7590fbe64d2e4e06f16367b74d38af501b0621e8f007d63eb8d6e reset-remove-failed.png +2101a577f258eda7a6debc580dd0f8efc985402d726e1bdf129eb17164bf4de2 reset-restart.png 75cfe61f88c6d4cc2867ff7e254914c992048d4b84d5e1ceb164d5a525c01d51 ride-climb-editor.png 1a95de6a3c6d67ead1021da889b7922a9cc9dc9a70f598148089d87665ebcd38 ride-context.png cd3b6d6120086c461e30e9affac2cb384f8d94a52aaf1ed737d4d653adb4cdba ride-detail-delete.png @@ -348,6 +350,7 @@ b58cd8802fb380914be14e60e97e19517de22b6fb392859b31d3e8eb58b4a684 setup-language 15513631159e40e237c10fb04790f0946be85151580a6a03fbacca42ccf2b697 setup-paired-es.png 0d24b8ef6fe2994fd8f2a2df7b29c694eb65586142aa659ad1f118bec88d43c9 setup-paired-fr.png a6c77edef81181bbd3fe84fc4e866c05801e160ddf764b75047e9d9b36274ad1 setup-paired.png +002c0965214b2a80de58ef7d17aca8d5cff04135eac4ee5630c726aba4bf30d4 setup-qr-bluetooth-off.png bd0c8f638320f465e33b75d537c1c632c561ad411db8cbb94051032ea6d297c7 setup-qr-cable.png 557695044d1badfafa2b1fc021622d79bbee03f798188070c13ed1d03a13cfba setup-qr-dark.png 9c038d20e74c53cb79d9837372920e0d1130c45a967d33bc8a3888da1a73af22 setup-qr-de.png From 091329e71d72e9d59931431538f28e0a829bc19e Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:29:49 +0200 Subject: [PATCH 36/37] test: use isolated landing anchor clearance check --- apps/obc-web-demo/tests/browser/reading-pages.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/obc-web-demo/tests/browser/reading-pages.test.js b/apps/obc-web-demo/tests/browser/reading-pages.test.js index 02e6839d5..277b4b4c6 100644 --- a/apps/obc-web-demo/tests/browser/reading-pages.test.js +++ b/apps/obc-web-demo/tests/browser/reading-pages.test.js @@ -84,11 +84,12 @@ test('landing anchors stay below the sticky header', async ({ page }) => { for (const width of [390, 1280]) { await page.setViewportSize({ width, height: 900 }); for (const anchor of ['demo', 'features']) { + // Each case is a deep link, independent of a previous smooth scroll or resize. + await page.goto('about:blank'); await page.goto(`/#${anchor}`); - // Wait for the smooth scroll to land: the next resize or fragment jump would interrupt it. - await expect.poll(() => page.locator(`#${anchor}`).evaluate(element => Math.abs( + await expect.poll(() => page.locator(`#${anchor}`).evaluate(element => element.getBoundingClientRect().top - document.querySelector('.site-head').getBoundingClientRect().bottom - ))).toBeLessThanOrEqual(1); + )).toBeGreaterThanOrEqual(-1); } } }); From b471dff0b7a76e8a69da838d15ff5aa7584836a0 Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:29:50 +0200 Subject: [PATCH 37/37] docs: show factory reset in phone pairing diagram --- docs/content/software/companion-link.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/content/software/companion-link.md b/docs/content/software/companion-link.md index 476aef629..58165ddf1 100644 --- a/docs/content/software/companion-link.md +++ b/docs/content/software/companion-link.md @@ -292,8 +292,9 @@ offers the rider an age-based cleanup instead of deciding alone. The durable for generic failure - only way in: - Forget phone + Forget phone + or + Factory reset